Claude Code Hooks:退出碼 1 不會阻止工具呼叫,9 次攔截全部失效
Claude Code Hooks: Exit Code 1 Doesn't Block. 9 of 9 Commands Ran
Claude Code 的 PreToolUse hook 只有以退出碼 2 結束才會阻止工具呼叫;退出碼 1 只顯示警告,命令仍會執行。作者回查 3 週紀錄,發現標示 BLOCKED 的 9 條命令全部實際執行;hook 崩潰也會因退出碼不是 2 而失敗開放。修正方式是命中時回傳 2、把例外轉成 2,並用假 JSON 測試退出碼;正則封鎖清單仍有 2/40 次被其他命令繞過。
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.
來源:DEV Community · dev.to