ai newsMo., 19. Okt.
EilmeldungMy LLM chat API taught me that '50+ models' is a maintenance tax, not a feature

Claude Code Hooks: Exit Code 1 Doesn't Block. 9 of 9 Commands Ran

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

dev.toMontag 01:26 · 19.10.8 Min. Lesezeit
Bild: dev.to

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.

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 ) Enter fullscreen mode Exit fullscreen mode Wired up in ~/.claude/settings.json:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "~/.claude/hooks/guard.py" } ] } ] } } Enter fullscreen mode Exit fullscreen mode 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.

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.

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.

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.

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.

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:

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.

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 ) Enter fullscreen mode Exit fullscreen mode 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." } } Enter fullscreen mode Exit fullscreen mode 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.

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) Enter fullscreen mode Exit fullscreen mode 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.

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:

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.

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.

Expand Auten Auten Auten Follow Auten gives your AI agent hands: it clicks, types and reads a real screen on your computer (Mac/Windows/Linux) and Android phone (beta). Works with any MCP client. Joined Oct 7, 2026 • Oct 18 Dropdown menu Copy link Hide 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:

For the rm -rf build case, 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?

Quelle: dev.to (dev.to). Original öffnen