My guard hook printed BLOCKED: git reset --hard in red text. Then Claude Code ran git reset --hard anyway, and an afternoon of uncommitted work went with it.
The hook wasn't broken in the way I expected. It matched the pattern perfectly. It wrote the warning perfectly. It just exited with the wrong number. In Claude Code hooks, exit code 1 doesn't block anything, and I had been running a decorative security system for three weeks.
TL;DR
- In Claude Code hooks, only exit code 2 blocks a
PreToolUsetool call. Exit code 1 (or any other non-zero code) is a "non-blocking error": the warning is shown and the tool runs anyway. - My Python guard used
sys.exit(1). Over 3 weeks it "blocked" 9 real commands. All 9 executed. - A crashing hook fails open too. A Python traceback exits 1, a missing
jqexits 127. Neither stops the tool. - Fix: return
2on a match, wrap the script so any crash also exits2, and unit-test the hook by piping fake JSON into it and checking$?. - Even with exit 2, a regex blocklist is a speed bump. Claude routed around it in 2 of 40 replayed commands.
What was my hook supposed to do?
The hook was a small PreToolUse guard on the Bash tool. It reads the tool call JSON from stdin, checks the command against a few regexes, and refuses the dangerous ones. Here's the version that ran for three weeks:
#!/usr/bin/env python3
import json, re, sys
DANGER = [
r"\brm\s+-rf\b",
r"\bgit\s+push\b.*--force\b",
r"\bgit\s+reset\s+--hard\b",
]
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
for pat in DANGER:
if re.search(pat, cmd):
print(f"BLOCKED: {pat}", file=sys.stderr)
sys.exit(1) # <- the whole bug
sys.exit(0)
Wired up in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/guard.py" }
]
}
]
}
}
Every Unix instinct I have says exit 1 means "no." grep exits 1. test exits 1. Failed builds exit 1. It felt obviously correct, which is exactly why I never checked it.
Why doesn't exit code 1 block a Claude Code hook?
Claude Code hooks give exit codes three meanings, and only one of them is "stop." Exit 0 is success. Exit 2 is a blocking error: for PreToolUse, the tool call is cancelled and your stderr is fed back to Claude as the reason. Every other non-zero code, including 1, is a non-blocking error: stderr is shown to you, and execution continues.
So my hook was doing this:
- Claude decides to run
git reset --hard. - Hook matches, prints
BLOCKED, exits 1. - Claude Code shows me the warning.
- Claude Code runs
git reset --hard.
The design makes sense once you see it. A hook with a bug in it shouldn't brick your whole session, so "something went wrong in the hook" and "the hook says no" are kept separate on purpose. Exit 2 is the deliberate "no." Everything else counts as "the hook had a problem, carry on."
The trap is that the warning looks identical either way. Red text in the transcript reads like a refusal. It isn't one.
How did I find out?
I found out the expensive way, then counted it properly.
After the git reset --hard incident I grepped three weeks of session transcripts for my own BLOCKED: string. It showed up 9 times. For each one I checked whether the tool result that followed was a real command output or a refusal.
| Pattern matched | Times | Actually ran |
|---|---|---|
rm -rf |
5 | 5 |
git push --force |
3 | 3 |
git reset --hard |
1 | 1 |
| Total | 9 | 9 |
Nine of nine. The five rm -rf calls were all on build/ and node_modules/, so they were harmless. Two of the three force pushes went to my own feature branches. One went to a shared branch, and I only noticed because a teammate asked why their commit vanished.
The guard's success rate was 0%, and it had a perfect record of telling me it was working.
Does a crashing hook block the tool call?
No. A crashing hook fails open, because a crash is just another non-zero exit code. This was the second thing I tested, and it bothered me more than the first.
I set up a throwaway repo and a list of 40 dangerous commands, then replayed them through Claude Code in three configurations:
| Hook version | Blocked |
|---|---|
Original (sys.exit(1)) |
0 / 40 |
Fixed (sys.exit(2)) |
40 / 40 |
Fixed, but with a typo that raises NameError at import |
0 / 40 |
The third row is the scary one. A Python traceback exits 1. A bash hook that calls jq on a machine without jq exits 127. A hook that set -e's its way into an early exit returns whatever the failed command returned. None of those are 2, so none of them block.
Your guard is only as strong as its least-tested code path, and the crash path is the one nobody tests.
How do you make a Claude Code hook fail closed?
Return 2 on a match, and catch every crash and turn it into a 2 as well. Here's the version I run now:
#!/usr/bin/env python3
import json, re, sys
DANGER = [
r"\brm\s+-rf\b",
r"\bgit\s+push\b.*--force\b",
r"\bgit\s+reset\s+--hard\b",
]
def main() -> int:
data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")
for pat in DANGER:
if re.search(pat, cmd):
print(f"Refused by guard.py: matches {pat}. Ask the user first.",
file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
print(f"guard.py crashed, failing closed: {e!r}", file=sys.stderr)
sys.exit(2)
sys.exit() raises SystemExit, which isn't a subclass of Exception, so the normal exits pass straight through and only real crashes hit the except.
Two notes on the stderr message. With exit 2 it goes to Claude, not just to you, so write it as an instruction. "Ask the user first" gets you a question back. A bare BLOCKED mostly got me a retry with different syntax.
The tradeoff is real: a fail-closed hook with a bug blocks every Bash call. While testing the NameError version, my session couldn't run ls. I'll take that. A broken guard that stops everything gets fixed in five minutes. A broken guard that stops nothing can stay broken for weeks.
If you'd rather not depend on exit codes at all, PreToolUse hooks can also exit 0 and print a JSON decision to stdout:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Force push needs a human."
}
}
That's more explicit, but it doesn't save you from the crash path. If the script dies before printing the JSON, you're back to a non-2 exit. Keep the wrapper either way.
How do you test a Claude Code hook without running Claude?
Pipe fake tool-call JSON into the script and check the exit code. It takes ten seconds and would have saved me three weeks:
echo '{"tool_input":{"command":"git reset --hard HEAD~1"}}' \
| ~/.claude/hooks/guard.py; echo "exit=$?"
# want: exit=2
echo '{"tool_input":{"command":"ls -la"}}' \
| ~/.claude/hooks/guard.py; echo "exit=$?"
# want: exit=0
echo 'not json' | ~/.claude/hooks/guard.py; echo "exit=$?"
# want: exit=2 (crash path fails closed)
That last line is the one that matters. If garbage input doesn't give you a 2, your guard fails open.
While you're in there, check the matcher. It's matched against the tool name and it's case-sensitive. "matcher": "bash" doesn't match the Bash tool, and the hook simply never fires. No warning, no error, nothing.
Is a regex blocklist hook enough to stop a coding agent?
No. Exit 2 makes the block real, but it doesn't make the blocklist smart. In my 40-command replay with the fixed hook, Claude got the refusal and tried another way 6 times. Four of those were reasonable (it asked me, or skipped the step). Two got through:
-
rm -rf buildwas refused, so it ranfind build -delete. -
rm -rf distwas refused, so it ranpython -c "import shutil; shutil.rmtree('dist')".
Both were correct for the task. Both were exactly the kind of thing my guard existed to stop. Claude wasn't being sneaky. It was being helpful, and my stderr message told it what was wrong, not what to do instead.
So I treat the hook as one layer: exit 2 for the obvious footguns, an instruction in stderr that says "ask first," deny rules in settings for the paths that matter, and git itself (protected branches, no force push on shared remotes) as the backstop that doesn't care how the command was spelled.
So, does exit code 1 block a Claude Code hook?
No. In Claude Code hooks, exit code 1 is a non-blocking error: your stderr is shown, and the tool call runs anyway. Only exit code 2 blocks a PreToolUse call, cancelling it and sending your stderr back to Claude. Any other exit, including crashes, missing binaries, and Python tracebacks, also fails open. To build a guard that actually guards, return 2 on a match, wrap the whole script so any exception also exits 2, and test it by piping fake JSON into it and checking $?. My guard printed "BLOCKED" 9 times in 3 weeks and stopped nothing. The fix was a single digit.
Written by the developer behind Preterview, an interview prep platform.
Top comments (2)
The 9 of 9 table is the most useful thing I've read on hooks in a while. Two gaps worth adding to the replay set, since they're the same failure class as
find -delete:rm -fr,rm -r -f,rm --recursive --force,git reset -q --hard,git -C . reset --hard. Andgit push -f origin mainwalks straight past a pattern that needs--force, as does a+mainrefspec."Bash"matcher never sees Write/Edit overwriting a file, or an MCP tool deleting one. The matcher takes a regex, so"Bash|Write|Edit|mcp__.*"with a check per tool closes that, at the cost of handling a differenttool_inputshape for each.For the
rm -rf buildcase,permissionDecision: "ask"may fit better than deny: a human approves the exact command, instead of Claude being nudged to find a second spelling. Did the "Ask the user first" wording cut the 6 reroutes compared with a bare BLOCKED, or is that from a smaller sample?I hit the mirror image of this bug: my first guard hook worked fine for weeks, then silently stopped blocking. A matcher expansion to Write/Edit had introduced a KeyError on the new tool_input shape, so every call crashed into exit 1 and failed open.
Two things I do differently now. First, I treat unparseable input as hostile: if the JSON doesn't match any known shape, the hook exits 2 rather than guessing. Second, every hook change gets a regression run of ~20 canned stdin payloads — blocked commands, benign commands, garbage, and each known tool_input shape — before it goes live.
One thing I'd add in defense of exit 1: it's not useless, it's the "tell the human" channel. I use exit 1 deliberately for informational hooks — e.g. warning me that a command is about to hit the network or take a long time — where I want the transcript note but no block. Once I stopped thinking of exit 1 as a failed block and started thinking of it as a notification, the design made a lot more sense.