The Exit Code 2 Trap: Why Your Claude Code Hook Silently Fails on Edit and Write
Your hook works perfectly on Bash commands. It blocks rm -rf /, it blocks git push --force, the JSON output is correct, the tests all pass. Then Claude uses Edit to modify a file, your hook fires, returns the same block JSON, exits with code 2, and the edit goes through anyway.
No error message. No warning. The hook ran, returned the right output, and was silently ignored.
This is the exit-code-2 trap, and it affects every Claude Code hook that uses exit 2 to signal a denied operation.
What happens
Claude Code treats exit code 2 from a hook as a crash, not a denial. For Bash tool calls, crashed hooks still block the operation. But for Edit and Write tools, crashed hooks are silently skipped. The tool proceeds as if the hook never existed.
The result: your hook appears to work in every test you run (because you test with Bash commands), but fails silently on the exact operations it was supposed to guard.
Why this is hard to catch
Most hook authors test by having Claude run dangerous bash commands. The hook blocks them. Everything looks correct. But Edit and Write are separate tool types with different crash-handling behavior. Unless you specifically test exit 2 with an Edit or Write operation, you will never see the failure.
There is no log entry, no error output, no indication that the hook was ignored. Claude just edits the file.
The fix
Always exit 0. Communicate the decision entirely through JSON on stdout.
Before (broken for Edit/Write):
if should_block "$input"; then
echo '{"decision":"block","reason":"blocked by hook"}'
exit 2
fi
echo '{"decision":"allow"}'
exit 0
After (works for all tools):
if should_block "$input"; then
echo '{"decision":"block","reason":"blocked by hook"}'
exit 0
fi
echo '{"decision":"allow"}'
exit 0
The only difference is the exit code on the block path. Every other line is identical. But this single change determines whether your hook actually protects files from being edited.
How to check your hooks
Search your hook scripts for exit 2:
grep -rn 'exit 2' ~/.claude/hooks/
If any hook uses exit 2 on a deny path, change it to exit 0. The block JSON is what tells Claude Code to stop, not the exit code.
You can also run safety-check, which scans for this pattern automatically:
curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/safety-check/check.sh | bash
It warns if any installed hook uses exit code 2 for deny decisions.
Background
This behavior was documented in claude-code#37210. The root cause is that Edit and Write tools wrap hook execution with hookSpecificOutput, which treats non-zero exit codes as hook failures and falls through to allow the operation. Bash tools use a different code path that treats any non-zero exit as a block.
We hit this in our own worktree-guard hook, which was using exit 2 on the deny path. It correctly blocked ExitWorktree calls from Bash, but if the same operation came through a different tool type, the block was ignored. The fix was one line: exit 2 to exit 0.
Summary
exit 2= hook crash = silently ignored on Edit/Writeexit 0+ block JSON = actual denial for all tool types- Test your hooks against Edit and Write, not just Bash
grep -rn 'exit 2'your hooks directory right now