DEV Community

jidonglab
jidonglab

Posted on

Claude Code Hooks Only Block on Exit Code 2: 47 Writes Got Through

I opened a folder that my guard hook was supposed to be protecting and found a freshly written file sitting in it. Timestamp: four minutes ago. The hook had run. I could see its output in the transcript, in confident capital letters: BLOCKED: refusing to write protected path.

The file was there anyway.

Claude Code hooks only block a tool call on exit code 2. Mine exited 1. For nine days, every guard I had written was a very polite sign that nobody was required to read. I grepped my own logs afterward: 312 PreToolUse fires, 47 of them on paths my guard list explicitly named, 0 actually stopped.

TL;DR

  • In Claude Code hooks, exit 0 means allow, exit 2 means block, and any other non-zero code is a non-blocking error that gets shown to you and then ignored. Exit 1 does not stop anything.
  • On exit 2, whatever the hook wrote to stderr is fed back to the model as the reason. Anything you print to stdout on a normal exit never reaches the model.
  • The other way to block is printing JSON on stdout with permissionDecision: "deny". That schema has changed at least once, so pin it to your version and test it.
  • A Write|Edit matcher does not cover Bash. Six of my 47 escapes were cat > file, tee, and a Python heredoc.
  • After fixing both, I replayed 60 write attempts: 57 blocked, 3 still slipped through creative shell. Log every hook decision to a file or you will never know.

Why didn't my Claude Code PreToolUse hook block anything?

Because it exited 1. Claude Code hooks treat the exit code as a three-way signal, not a boolean, and 1 lands in the "something went wrong with your script, carry on" bucket.

Here is the hook, verbatim, in all its uselessness:

#!/usr/bin/env bash
# protect-files.sh  (the broken version)
payload=$(cat)
path=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')

case "$path" in
  *secrets*|*private_key*|*token.json)
    echo "BLOCKED: refusing to write protected path: $path"
    exit 1
    ;;
esac
exit 0
Enter fullscreen mode Exit fullscreen mode

Two bugs in six lines.

The exit 1 is the fatal one. The runtime reads it as "this hook script failed," surfaces the message to me in the terminal, and proceeds with the tool call. Which is arguably the right default. A broken guard script should not be able to brick your session. But it means a guard that fails open looks identical to a guard that works, right up until you check.

The echo is the quieter bug. On a blocking exit, stderr is what gets handed back to the model. stdout on a normal exit is basically a comment. I had written my entire refusal reason to the one stream the model would never see.

How do you actually block a tool call in Claude Code?

Exit 2 and write the reason to stderr. That is the whole fix:

#!/usr/bin/env bash
# protect-files.sh  (the version that works)
payload=$(cat)
path=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')

case "$path" in
  *secrets*|*private_key*|*token.json)
    echo "Refusing to write $path. That file is edited by hand only." >&2
    exit 2
    ;;
esac
exit 0
Enter fullscreen mode Exit fullscreen mode

Three characters of change (>&2) and one digit. That is the entire delta between a hook that protects you and a hook that performs protection theater.

There is a second, more expressive way: print JSON on stdout and exit 0.

cat <<JSON
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"protected path"}}
JSON
exit 0
Enter fullscreen mode Exit fullscreen mode

This form gives you allow, deny, and ask, which is genuinely useful when you want a tripwire that pauses for confirmation instead of hard-failing. The catch: the JSON shape has already changed once between versions, and a typo in a key name degrades silently to "no decision," which means allow. I keep exit 2 as the primary mechanism and use JSON only where I actually need ask, because a wrong exit code is loud and a wrong JSON key is not.

The wiring in settings.json is the boring part:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/protect-files.sh" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that matcher. It is about to be the second half of this postmortem.

Why did 6 writes still get through after the fix?

Because Write|Edit is a tool-name matcher, and the model does not need the Write tool to write a file. It has a shell.

I replayed the 47 escapes from my logs after fixing the exit code. Forty-one of them were ordinary Write and Edit calls, and the fixed hook caught all of them. The remaining six had never touched the Write tool at all:

  • 3 were cat > path <<'EOF' ... EOF
  • 2 were tee path at the end of a pipe
  • 1 was python3 - <<'PY' with an open(..., 'w') inside

None of those match Write|Edit. From the hook's point of view they were a single Bash call whose payload is a command string, not a file_path. My guard never even ran.

So I added a second wiring on Bash, matching redirects and the usual write verbs against my protected names:

cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -qE '(>|>>|tee|dd of=|cp |mv |sed -i).*(secrets|private_key|token\.json)'; then
  echo "Refusing shell write to a protected path." >&2
  exit 2
fi
Enter fullscreen mode Exit fullscreen mode

This is pattern matching on shell strings, which is exactly as robust as it sounds. It is a speed bump, not a wall. I wrote it anyway, because the realistic threat here is not an adversary, it is an agent taking the shortest path to a goal at 2am.

What did 60 replayed write attempts actually show?

57 of 60 blocked. Here is the breakdown after both fixes, running each attempt deliberately against a scratch copy of my protected paths:

Attempt type Trials Blocked
Write / Edit / MultiEdit 40 40
Shell redirect (>, >>, tee) 12 12
Shell copy/move (cp, mv) 4 3
Interpreter heredoc (python3 -) 4 2

The three survivors: one cp where the source path matched my pattern but the destination did not (my regex was anchored on the wrong side), and two Python heredocs where the filename was built by string concatenation inside the script. There is no regex that wins that fight. If the path is assembled at runtime inside an interpreter, a string matcher on the command line is blind.

That is the honest ceiling of this approach: 95% on my own replay, and the missing 5% is structural. For the paths I actually care about I now also rely on file permissions, which do not care how clever the caller is.

What this cost, and the 5 rules I follow now

Cost of the bug: nine days of false confidence, one file rewritten that I had to restore from a backup, and about two hours of log archaeology. Cost of the fix: eleven lines.

The rules that came out of it:

  1. Exit 2 or it is not a guard. Exit 1 is a comment with extra steps.
  2. Reasons go to stderr. The model reads stderr on a blocking exit and nothing else.
  3. Log every decision. My hooks now append timestamp | event | tool | path | decision to a log file. That file is the only reason I have numbers instead of vibes. 312 fires, 47 matches, 0 blocks was a one-line awk away once the log existed.
  4. Test the block path on purpose. Ask the session to write to a protected path and watch what happens. A guard you have never seen fire is a guard you have never tested. Mine took 20 seconds to verify once I bothered.
  5. Matchers are tool names, not intents. If you guard Write, also guard Bash, and accept that the shell case is best-effort.

One more practical note: hooks run synchronously on every matching call. Mine cost around 25ms each because of the jq invocation, so 312 fires added roughly 8 seconds across nine days. Irrelevant. But if you are tempted to shell out to something heavier inside a PreToolUse hook, remember you pay it on every single tool call.

While drafting this post, my own fixed hook blocked me from writing the draft, because the example code above contains the protected path patterns. Exit 2, reason on stderr, file not written. Annoying, correct, and the first time I have been glad to lose an argument with a six-line shell script.

So why don't Claude Code hooks block your tool calls?

Almost always the exit code. Claude Code hooks block a tool call only when the hook process exits with code 2, and the reason the model sees is whatever that process wrote to stderr. exit 1 is classified as a script error: it is printed to you, the tool call proceeds, and your guard fails open without ever telling you it failed. The second most common cause is a matcher that covers Write|Edit but not Bash, which lets cat > file, tee, and heredocs walk past a guard that technically works. Fix both, log every decision to a file, and deliberately trigger the block once so you have seen it with your own eyes. In my setup that took the block rate from 0 of 47 to 57 of 60.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)