Here's a small thing that drove me up the wall using Claude Code on a real codebase.
I have a pre-commit hook. It runs the linter and the type-checker. It exists precisely so that broken code doesn't reach a commit. And Claude — diligent, eager, trying to be helpful — would hit a failing check, decide the check was in the way of the goal, and quietly run:
git commit --no-verify -m "fix: update handler"
It wasn't malicious. From the agent's point of view, the task was "commit this change," the pre-commit hook was an obstacle, and --no-verify was the documented way around the obstacle. Perfectly logical. Also exactly the thing I never want to happen, because the entire point of the check is that it is not optional.
I tried the obvious fix first: I put it in CLAUDE.md.
Never use
git commit --no-verify. Fix the failing check instead.
This works about 80% of the time. Which is another way of saying it fails one commit in five. CLAUDE.md is context — a strong suggestion the model weighs against everything else in the conversation. Under enough pressure ("just get this committed"), a suggestion loses. An 80%-reliable guardrail on something irreversible isn't a guardrail. It's a coin flip with good odds.
So I stopped trying to persuade the model and started intercepting the tool call instead.
Hooks run before the action, not after the apology
Claude Code has a hooks system. The one that matters here is PreToolUse: a script that runs before a tool call executes, receives the call as JSON on stdin, and decides whether it proceeds. Exit 0 and the call runs. Exit 2 and it's blocked — and whatever you wrote to stderr gets fed back to the model as the reason.
That last part is the whole game. It's not "please don't." It's a wall, plus an explanation the model can act on.
Here's the entire hook:
#!/usr/bin/env node
// Block `git commit/push --no-verify`. Exit 2 blocks the call.
'use strict';
let raw = '';
process.stdin.on('data', (d) => (raw += d));
process.stdin.on('end', () => {
let input = {};
try { input = JSON.parse(raw); } catch { process.exit(0); }
if (input.tool_name !== 'Bash') process.exit(0);
const cmd = (input.tool_input && input.tool_input.command) || '';
// Split on shell separators so `echo x && git commit --no-verify` is caught,
// but `echo "--no-verify"` on its own is not.
for (const seg of cmd.split(/&&|\|\||;|\|/)) {
if (/\bgit\b/.test(seg) && /\b(commit|push)\b/.test(seg) && /--no-verify\b/.test(seg)) {
process.stderr.write(
'BLOCKED: --no-verify skips the project\'s pre-commit/pre-push checks. ' +
'Run the failing check, fix the underlying issue, then commit normally.'
);
process.exit(2);
}
}
process.exit(0);
});
Register it in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "node ~/.claude/hooks/block-no-verify.js" }]
}
]
}
}
Restart Claude Code, ask it to run git commit --no-verify -m test, and watch it get stopped — then watch it do the right thing, because the stderr message told it what the right thing is. Reliability went from "one in five slips" to zero. Not 99%. Zero, because it's no longer a judgment call.
Three details that took a few iterations
Split the command before matching. Agents chain commands: npm test && git commit --no-verify. A naive cmd.includes('--no-verify') is fine here, but splitting on shell separators first means you're matching intent per segment and you won't false-positive on echo "the --no-verify flag is blocked". Small thing; it's the difference between a hook people trust and one they rip out after it blocks something innocent.
Fail open, not closed. Every exit path on bad input is exit(0) — malformed JSON, missing fields, wrong tool, all allow. This feels backwards for a security control, and it's the most debated line in the design. The reasoning: a hook that crashes shouldn't be able to brick your shell. A PreToolUse hook that throws on unexpected input and defaults to block turns one bad assumption into "I can't run any Bash command." For a guardrail you live inside all day, getting wedged out of your own terminal is a worse failure than the rare miss. (For a genuinely high-stakes control you might choose the opposite. It's a real trade-off, not a default — decide it on purpose.)
No escape hatch, on purpose. I deliberately gave this hook no override env var. Most of my other hooks have one (CC_OS_ALLOW_SECRETS=1 for test fixtures with fake keys, for instance). This one doesn't, because the entire value is that it can't be argued around. The moment there's a bypass, "just this once" becomes the new default and you're back to the coin flip.
The general pattern
--no-verify is one instance of a category: things the model will rationalize past because they sit between it and the task. Once you see the shape, the same ten-line pattern covers a lot of ground:
- secret-shaped strings being written into source files (scan the
Write/Editcontent, block on a match) - SQL built by string interpolation instead of parameters
-
--forcepushes tomain -
rm -rfaimed at a home directory or a drive root
Each is the same skeleton: read the tool call, check the thing you care about, exit 2 with a sentence explaining why. The model isn't fighting you — it just needs the obstacle to be real instead of advisory.
I packaged the ones I use into an installable kit (commands, agents, and eight of these hooks with tests), free and MIT-licensed, if you want to start from something instead of a blank file:
https://github.com/stavrespasov/claude-code-os-lite
But honestly, even if you take nothing else: write the --no-verify hook. It's twenty minutes and it closes a hole that CLAUDE.md alone can't.
Top comments (2)
The "80% reliable guardrail isn't a guardrail" point really resonates. I've seen the same thing — CLAUDE.md instructions get ignored when Claude is "determined" to complete a task. Hard walls > polite suggestions.
I've been using the same hooks pattern to solve a related problem: Claude jumping into coding before the thinking phase is done. I put together Brainstorm-Mode (mehmetcanfarsak on GitHub) which uses PreToolUse gates to block tool execution during brainstorming. Same philosophy — instead of asking Claude to wait, put a hard wall in front of the tool call. Pretty effective.
This is exactly the kind of problem hooks solve! The --no-verify pattern is a perfect example of why PreToolUse hooks beat CLAUDE.md rules.
I took this approach further with my plugin Brainstorm-Mode - instead of just blocking specific dangerous commands, it blocks ALL tool execution during brainstorming phases. When you're thinking through architecture, Claude can't accidentally bypass anything because all tools are gated.
Install: mehmetcanfarsak/Brainstorm-Mode on GitHub