# The Claude Code hook that ended --no-verify commits forever

> Source: <https://dev.to/stavrespasov/the-claude-code-hook-that-ended-no-verify-commits-forever-2516>
> Published: 2026-06-12 09:19:18+00:00

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.
