The Claude Code hook that ended --no-verify commits forever A developer built a pre-tool hook for Claude Code that blocks `git commit --no-verify` commands, solving a persistent problem where the AI agent would bypass pre-commit checks. The hook intercepts Bash tool calls, detects `--no-verify` flags in any command segment, and exits with code 2 while feeding the model an explanation of why the action was blocked. The approach achieved 100% reliability in preventing bypassed commits, compared to the 80% success rate of instructing the model via `CLAUDE.md`. 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. 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: bash /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. 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. --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: Write / Edit content, block on a match --force pushes to main rm -rf aimed at a home directory or a drive rootEach 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 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.