Claude Code Hooks Cheat Sheet

Copy-paste patterns. Zero fluff. Official docs

Basics Block Patterns Auto-Approve Monitor settings.json Debug Quick Recipes Exit Codes

How Hooks Work

EventWhenUse for
PreToolUseBefore any tool runsBlock/approve commands
PostToolUseAfter tool completesCheck results, monitor
StopClaude finishes respondingNotifications, cleanup
SubagentStopSubagent finishesMonitor sub-agents
Exit CodeEffect
0Allow (no action)
2BLOCK — model cannot bypass
stdout JSONOverride 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

CodeMeaningWhen to use
0Allow / no opinionDefault — hook has nothing to say
2BLOCKDangerous command detected
0 + stdout JSONOverride decisionAuto-approve with reason
1Hook error (ignored)Don't use — hook failures are silent
stdout JSON format: {"decision":"approve","reason":"Safe command"} or {"decision":"block","reason":"Too dangerous"}