Claude Code Hooks Cheat Sheet
Copy-paste patterns. Zero fluff. Official docs
How Hooks Work
| Event | When | Use for |
PreToolUse | Before any tool runs | Block/approve commands |
PostToolUse | After tool completes | Check results, monitor |
Stop | Claude finishes responding | Notifications, cleanup |
SubagentStop | Subagent finishes | Monitor sub-agents |
| Exit Code | Effect |
0 | Allow (no action) |
2 | BLOCK — model cannot bypass |
| stdout JSON | Override decision (approve/block with reason) |
Key insight: CLAUDE.md rules degrade as context fills up. Hooks run every single time, enforced at the process level.
Block Patterns
Block a specific command BLOCK
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -qE 'rm\s+.*-rf\s+/'; then
echo "BLOCKED: rm -rf on root" >&2
exit 2
fi
exit 0
Block file edits to specific paths BLOCK
#!/bin/bash
FILE=$(cat | jq -r '.tool_input.file_path // empty' 2>/dev/null)
[ -z "$FILE" ] && exit 0
case "$FILE" in
*/.env*|*/.ssh/*|*/.aws/*|*/credentials*)
echo "BLOCKED: Protected file" >&2; exit 2 ;;
esac
exit 0
Block git push to main BLOCK
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*\b(main|master)\b'; then
echo "BLOCKED: Direct push to main" >&2; exit 2
fi
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force'; then
echo "BLOCKED: Force push" >&2; exit 2
fi
exit 0
Block destructive git when dirty BLOCK
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
echo "$COMMAND" | grep -qE 'git\s+(checkout\s+--|reset\s+--hard|clean\s+-f)' || exit 0
DIRTY=$(git status --porcelain 2>/dev/null)
if [ -n "$DIRTY" ]; then
echo "BLOCKED: Uncommitted changes would be lost" >&2; exit 2
fi
exit 0
Block commits with conflict markers BLOCK
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
echo "$COMMAND" | grep -qE 'git\s+commit' || exit 0
CONFLICTS=$(git diff --cached --name-only 2>/dev/null | while read f; do
[ -f "$f" ] && grep -lE '^(<{7}|={7}|>{7})' "$f" 2>/dev/null
done)
if [ -n "$CONFLICTS" ]; then
echo "BLOCKED: Conflict markers in staged files" >&2; exit 2
fi
exit 0
Auto-Approve Patterns
Auto-approve safe commands APPROVE
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -qE '^\s*(npm\s+test|cargo\s+test|go\s+test|pytest|make\s+test)'; then
echo '{"decision":"approve","reason":"Safe test command"}'
exit 0
fi
exit 0
Auto-approve read-only git APPROVE
#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -qE '^\s*git\s+(status|log|diff|show|branch|remote|tag\s+-l)'; then
echo '{"decision":"approve","reason":"Read-only git"}'
fi
exit 0
Monitor Patterns
Log all tool calls MONITOR
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // .tool_input.file_path // ""' 2>/dev/null)
echo "[$(date -Iseconds)] $TOOL: $CMD" >> ~/.claude/tool-calls.log
exit 0
Warn on large output WARN
#!/bin/bash
OUTPUT=$(cat | jq -r '.tool_result // empty' 2>/dev/null)
LEN=${#OUTPUT}
if [ "$LEN" -gt 50000 ]; then
echo "WARNING: Output is ${LEN} chars — consuming context fast" >&2
fi
exit 0
Desktop notification on session end MONITOR
#!/bin/bash
# TRIGGER: Stop MATCHER: ""
MSG=$(cat | jq -r '.stop_reason // "completed"' 2>/dev/null)
notify-send "Claude Code" "Session $MSG" 2>/dev/null || # Linux
osascript -e "display notification \"$MSG\"" 2>/dev/null # macOS
exit 0
settings.json Reference
Minimal setup (one hook)
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "bash ~/.claude/hooks/my-guard.sh"
}]
}]
}
}
Multiple hooks on different triggers
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type":"command","command":"bash ~/.claude/hooks/destructive-guard.sh"},
{"type":"command","command":"bash ~/.claude/hooks/branch-guard.sh"}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{"type":"command","command":"bash ~/.claude/hooks/scope-guard.sh"}
]
}
],
"PostToolUse": [{
"matcher": "",
"hooks": [{"type":"command","command":"bash ~/.claude/hooks/monitor.sh"}]
}],
"Stop": [{
"matcher": "",
"hooks": [{"type":"command","command":"bash ~/.claude/hooks/notify.sh"}]
}]
}
}
Matcher: Empty string "" matches all tools. "Bash" matches Bash only. "Edit|Write" matches either. Matcher is a regex against the tool name.
Debug Hooks
Test a hook manually
# Test PreToolUse hook with sample input
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | bash ~/.claude/hooks/my-guard.sh
echo "Exit code: $?"
Check hook is executable
ls -la ~/.claude/hooks/
# Fix: chmod +x ~/.claude/hooks/*.sh
Validate settings.json
python3 -c "import json; json.load(open('$HOME/.claude/settings.json')); print('Valid')"
Quick health check
npx cc-safe-setup --quickfix # Auto-detect and fix problems
npx cc-safe-setup --doctor # Detailed diagnosis
npx cc-safe-setup --verify # Test each hook
Quick Recipes
I want to block all sudo commands
npx cc-safe-setup --install-example no-sudo-guard
I want to block database drops
npx cc-safe-setup --install-example block-database-wipe
I want to auto-approve Python tools
npx cc-safe-setup --install-example auto-approve-python
I want to prevent deploys on Fridays
npx cc-safe-setup --install-example no-deploy-friday
I want to track session cost
npx cc-safe-setup --install-example token-budget-guard
I want to generate a custom hook
npx cc-safe-setup --create "block npm publish without tests"
I want to detect what my stack needs
npx cc-safe-setup --scan
Exit Code Reference
| Code | Meaning | When to use |
0 | Allow / no opinion | Default — hook has nothing to say |
2 | BLOCK | Dangerous command detected |
0 + stdout JSON | Override decision | Auto-approve with reason |
1 | Hook error (ignored) | Don't use — hook failures are silent |
stdout JSON format: {"decision":"approve","reason":"Safe command"} or {"decision":"block","reason":"Too dangerous"}