{"slug": "the-claude-code-hook-that-ended-no-verify-commits-forever", "title": "The Claude Code hook that ended --no-verify commits forever", "summary": "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`.", "body_md": "Here's a small thing that drove me up the wall using Claude Code on a real codebase.\n\nI 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:\n\n```\ngit commit --no-verify -m \"fix: update handler\"\n```\n\nIt 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`\n\nwas 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*.\n\nI tried the obvious fix first: I put it in `CLAUDE.md`\n\n.\n\nNever use`git commit --no-verify`\n\n. Fix the failing check instead.\n\nThis works about 80% of the time. Which is another way of saying it fails one commit in five. `CLAUDE.md`\n\nis 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.\n\nSo I stopped trying to persuade the model and started intercepting the tool call instead.\n\nClaude Code has a hooks system. The one that matters here is `PreToolUse`\n\n: a script that runs *before* a tool call executes, receives the call as JSON on stdin, and decides whether it proceeds. Exit `0`\n\nand the call runs. Exit `2`\n\nand it's **blocked** — and whatever you wrote to stderr gets fed back to the model as the reason.\n\nThat last part is the whole game. It's not \"please don't.\" It's a wall, plus an explanation the model can act on.\n\nHere's the entire hook:\n\n``` bash\n#!/usr/bin/env node\n// Block `git commit/push --no-verify`. Exit 2 blocks the call.\n'use strict';\n\nlet raw = '';\nprocess.stdin.on('data', (d) => (raw += d));\nprocess.stdin.on('end', () => {\n  let input = {};\n  try { input = JSON.parse(raw); } catch { process.exit(0); }\n  if (input.tool_name !== 'Bash') process.exit(0);\n\n  const cmd = (input.tool_input && input.tool_input.command) || '';\n\n  // Split on shell separators so `echo x && git commit --no-verify` is caught,\n  // but `echo \"--no-verify\"` on its own is not.\n  for (const seg of cmd.split(/&&|\\|\\||;|\\|/)) {\n    if (/\\bgit\\b/.test(seg) && /\\b(commit|push)\\b/.test(seg) && /--no-verify\\b/.test(seg)) {\n      process.stderr.write(\n        'BLOCKED: --no-verify skips the project\\'s pre-commit/pre-push checks. ' +\n        'Run the failing check, fix the underlying issue, then commit normally.'\n      );\n      process.exit(2);\n    }\n  }\n  process.exit(0);\n});\n```\n\nRegister it in `~/.claude/settings.json`\n\n:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [{ \"type\": \"command\", \"command\": \"node ~/.claude/hooks/block-no-verify.js\" }]\n      }\n    ]\n  }\n}\n```\n\nRestart Claude Code, ask it to run `git commit --no-verify -m test`\n\n, 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.\n\n**Split the command before matching.** Agents chain commands: `npm test && git commit --no-verify`\n\n. A naive `cmd.includes('--no-verify')`\n\nis 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\"`\n\n. Small thing; it's the difference between a hook people trust and one they rip out after it blocks something innocent.\n\n**Fail open, not closed.** Every exit path on bad input is `exit(0)`\n\n— 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`\n\nhook 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.)\n\n**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`\n\nfor 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.\n\n`--no-verify`\n\nis 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:\n\n`Write`\n\n/`Edit`\n\ncontent, block on a match)`--force`\n\npushes to `main`\n\n`rm -rf`\n\naimed at a home directory or a drive rootEach is the same skeleton: read the tool call, check the thing you care about, exit `2`\n\nwith a sentence explaining why. The model isn't fighting you — it just needs the obstacle to be real instead of advisory.\n\nI 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:\n\n[https://github.com/stavrespasov/claude-code-os-lite](https://github.com/stavrespasov/claude-code-os-lite)\n\nBut honestly, even if you take nothing else: write the `--no-verify`\n\nhook. It's twenty minutes and it closes a hole that `CLAUDE.md`\n\nalone can't.", "url": "https://wpnews.pro/news/the-claude-code-hook-that-ended-no-verify-commits-forever", "canonical_source": "https://dev.to/stavrespasov/the-claude-code-hook-that-ended-no-verify-commits-forever-2516", "published_at": "2026-06-12 09:19:18+00:00", "updated_at": "2026-06-12 09:42:29.235824+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "ai-safety", "large-language-models"], "entities": ["Claude Code", "CLAUDE.md", "PreToolUse"], "alternates": {"html": "https://wpnews.pro/news/the-claude-code-hook-that-ended-no-verify-commits-forever", "markdown": "https://wpnews.pro/news/the-claude-code-hook-that-ended-no-verify-commits-forever.md", "text": "https://wpnews.pro/news/the-claude-code-hook-that-ended-no-verify-commits-forever.txt", "jsonld": "https://wpnews.pro/news/the-claude-code-hook-that-ended-no-verify-commits-forever.jsonld"}}