The complete guide to what exit codes mean in Claude Code hooks. Includes the bash syntax error trap that can lock your entire session.
| Exit Code | Meaning | Effect |
|---|---|---|
| 0 | Allow / Passthrough | Tool call proceeds normally. Default for hooks that don't match. |
| 1 | Hook Error (ignored) | Hook crashed or failed. Tool call still proceeds. Error shown in UI. |
| 2 | Block | Tool call is prevented. Claude sees "blocked by hook" message. |
Hooks can also output JSON to stdout for more control:
| stdout JSON | Effect |
|---|---|
{"decision":"approve","reason":"Safe command"} | Auto-approve (skip permission prompt) |
{"decision":"block","reason":"Too dangerous"} | Block the tool call |
Example of the trap:
#!/bin/bash
INPUT=$(cat)
if [ -z "$INPUT" ] # Missing 'then'
fi # bash: syntax error near unexpected token 'fi'
exit 0
This script never reaches exit 0. Bash aborts at the syntax error with exit code 2. Claude Code sees exit 2 and blocks the tool call.
If the hook's matcher is "" (all tools), this blocks Read, Write, Edit, Bash, Grep, and Agent — making the session completely unrecoverable.
# Always syntax-check before deploying a hook:
bash -n ~/.claude/hooks/my-hook.sh
# Test with empty input:
echo '{}' | bash ~/.claude/hooks/my-hook.sh
echo $? # Must be 0, not 2
# Or use the automated validator:
npx cc-safe-setup --validate
"" during development. Use "Bash" to limit blast radius if something goes wrong.
| Situation | Solution |
|---|---|
| Hook crashes (exit 1) | No action needed — tool call proceeds. Fix the hook when convenient. |
| Hook blocks (exit 2, intentional) | Working as designed. The hook prevented a dangerous operation. |
| Hook blocks (exit 2, syntax error) | From outside Claude Code: rm ~/.claude/hooks/broken-hook.sh |
| All tools blocked (matcher "") | From outside: rm ~/.claude/hooks/broken-hook.sh then restart Claude Code |
| Can't identify the broken hook | npx cc-safe-setup --validate (auto-detects and disables) |
| Emergency: disable everything | npx cc-safe-setup --safe-mode |
#!/bin/bash
# Safe hook template — always exits 0 on empty/unknown input
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
# Only process relevant tools
[[ "$TOOL" != "Bash" ]] && exit 0
[ -z "$COMMAND" ] && exit 0
# Your logic here
# if dangerous: exit 2
# if safe: echo '{"decision":"approve","reason":"..."}' && exit 0
# otherwise: exit 0
exit 0