Chapter 15. Claude Code Hooks in Practice: Guardrails That Hold

To make Claude Code enforce a rule every time, write the rule as a hook instead of a CLAUDE.md line: a PreToolUse hook that exits 2 blocks the tool call before it runs. I learned the difference in May 2026, when a PR comment on solana-foundation/awesome-solana-ai#155 went out under Suede-AI, my deprecated bot account, instead of my own name, because nothing had checked which identity was active in gh before the mutating command ran. The fix I wrote at the time was prose: verify gh auth status shows the personal account before any mutating gh command. It went into memory, then into my global CLAUDE.md, and it is a good rule. It is also a “whenever X” promise, and prose cannot keep one.

A prose rule gets read at session start and then carried through hours of unrelated work. It competes with the task prompt, with compaction, with whatever else loaded into context, and it wins most of the time. Most of the time is the problem. That comment on the Solana repo shipped because “most of the time” had an off day, and a public byline is not retractable.

The config tooling in my own harness draws the line in one sentence: automated behaviors, anything shaped like “whenever X” or “before X” or “each time X”, require hooks configured in settings.json, because the harness executes hooks. Memory and CLAUDE.md can make a behavior more probable. They cannot make it certain. A hook fires whether or not the model remembered, whether or not context got compacted, whether or not a one-off task prompt argued for an exception. My CLAUDE.md still carries the prose versions of these rules, because prose is where the reasoning lives. The hook is where the enforcement lives.

Why a hook runs and blocks nothing

Exit 2 blocks and exit 1 does not, which surprises people who expect Unix convention to carry over. Exit 0 means success, and stdout gets parsed for JSON output. Exit 2 means a blocking error: stdout is ignored and stderr is fed to Claude as the reason. Any other exit code is a non-blocking notice. The one exception is WorktreeCreate, where any non-zero exit aborts the worktree creation.

Exit 2 only blocks on events built for it. On PreToolUse it blocks the tool call. On UserPromptSubmit it blocks and erases the prompt. On Stop it keeps the turn alive. On PostToolUse, Notification, SessionStart, and SessionEnd, exit 2 blocks nothing at all. This maps cleanly onto the two kinds of automation worth building: guards go on PreToolUse because that is where blocking works, and formatters go on PostToolUse because a formatter that can block your work is a formatter you will eventually disable.

One version-specific trap before the worked examples. As of Claude Code v2.1.214, a hook that exits 2 while printing JSON that fails schema validation still blocks, using stderr as the reason. Before v2.1.214, that combination was a non-blocking error and the action proceeded. My machine runs 2.1.211, so on this box a guard that exits 2 while emitting malformed JSON fails open. The consequence is a design rule: pick one output channel per hook. Either exit 2 with a message on stderr and no JSON, or exit 0 with decision JSON. Mixing them is the combination that rots.

Blocking a mutating command before it runs

The attribution rule becomes a PreToolUse hook on Bash. This lives in ~/.claude/settings.json so it applies in every repo; hook entries merge across settings levels rather than replacing each other, so project-level hooks stack on top of it.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(gh *)",
            "command": "~/.claude/hooks/gh-identity-gate.sh"
          }
        ]
      }
    ]
  }
}

The if field holds a single permission rule, no && or ||, and is evaluated only on the five tool events. Here it keeps the script from spawning on Bash calls that have nothing to do with gh.

#!/bin/bash
# gh-identity-gate.sh
# Blocks mutating gh commands unless the personal account is active.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')

case "$cmd" in
  *"gh pr create"*|*"gh pr comment"*|*"gh pr review"*|*"gh pr merge"*|\
  *"gh issue create"*|*"gh issue comment"*|*"gh release create"*) ;;
  *) exit 0 ;;   # read-only gh commands run under any account
esac

status=$(gh auth status 2>&1)
case "$status" in
  *JasonColapietro*|*jasoncola1*) exit 0 ;;
esac

echo "Blocked: mutating gh command with the wrong account active." >&2
echo "Switch to the personal account first. The keyring may still display the pre-rename name." >&2
exit 2

The double accept, JasonColapietro or jasoncola1, is load-bearing. After my 2026-05-11 GitHub rename, gh’s local keyring kept displaying the cached old name while the OAuth token resolved to the renamed account. Same account, stale label. A stricter check would reject my own identity on the strength of a display string, and a guard that false-positives gets deleted within a week. The stderr text matters too: on exit 2 that text is what Claude reads, so it should say what was blocked and what to do next, not a bare “denied.”

Stopping a delete while a process is still writing

On 2026-07-25 I was cleaning up worktrees in suede-agent-studio. The Claude session registry reported isRunning: false for two sessions that had a live claude process plus node children working inside their worktrees. One 581 MB worktree went from idle to writing files in the minutes between the audit and the planned delete. Trusting the registry would have destroyed a running agent’s workspace mid-edit. Two rules came out of that sweep: check lsof for processes whose cwd is inside the worktree, never the session registry, and re-check in the moment before deleting, because an audit ten minutes old is not evidence about now.

That second rule is the one prose cannot keep. An instruction can tell an agent to re-check; the agent decides when “before deleting” is. A hook removes the decision, because PreToolUse runs at the moment of the tool call by construction.

#!/bin/bash
# worktree-guard.sh
# PreToolUse on Bash. Refuses worktree removal while any process lives inside it.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')

case "$cmd" in
  *"git worktree remove"*|*"rm -rf"*".worktrees"*) ;;
  *) exit 0 ;;
esac

target=$(printf '%s' "$cmd" | grep -oE '[^ ]*\.worktrees[^ ]*' | tail -1)
[ -z "$target" ] && exit 0

if lsof -d cwd 2>/dev/null | grep -q "$target"; then
  echo "Blocked: a live process has its cwd inside $target." >&2
  echo "Verify with lsof -d cwd, not the session registry. It lies." >&2
  exit 2
fi
exit 0

The lsof runs when the removal command is already on the table, so the gap between evidence and action shrinks from minutes to the interval between hook exit and tool execution. The 581 MB near-miss happened inside a ten-minute window. This hook does not leave a window.

Refusing a file write that would deploy as a public route

On 2026-07-26 I found that GET https://scan.suedeai.ai/api/check.test returned 200 text/plain with the body partial. Vercel turns every .js and .ts file under the Root Directory’s api/ folder into a public serverless function, and a colocated test file in the suede-geo repo had deployed as a live unauthenticated route. Requesting it executed the test suite inside a production function, booted the mock HTTP server from inside the test, and hung past 25 seconds, holding a function open toward the 300-second ceiling. Anyone could loop it. The fix landed in PR #27 via site/.vercelignore.

The prose rule that followed says to exclude colocated tests from the deploy and to curl production per file. Both are remediation. The hook version refuses the write in the first place, and since Write and Edit deliver a structured file_path in tool_input, there is no shell parsing involved:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/api-test-guard.sh" }
        ]
      }
    ]
  }
}
#!/bin/bash
# api-test-guard.sh
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')

case "$path" in
  */api/*.test.js|*/api/*.test.ts|*/api/*.spec.js|*/api/*.spec.ts)
    cat <<'EOF'
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Files under api/ deploy as public routes on Vercel. Put the test outside api/, or exclude it via .vercelignore at the Root Directory."
  }
}
EOF
    ;;
esac
exit 0

This one uses the JSON decision channel instead of exit 2, and the choice is not cosmetic. permissionDecision supports allow, deny, ask, and defer, and when several PreToolUse hooks disagree the precedence is deny over defer over ask over allow, so this deny survives an allow from any sibling hook. JSON is parsed only on exit 0, which keeps it clear of the exit-2-plus-JSON trap on my 2.1.211 install. One more trap in this shape: additionalContext has to sit inside hookSpecificOutput next to hookEventName. Placed at the top level of the JSON it is ignored without an error, and you will spend an afternoon wondering why your context never arrived.

Running a formatter on every write without blocking the write

A formatter belongs on PostToolUse with async: true, where it cannot block the edit that triggered it. A formatter earns its keep by never being noticed.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/format-on-write.sh", "async": true }
        ]
      }
    ]
  }
}
#!/bin/bash
# format-on-write.sh
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
case "$path" in
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css)
    npx prettier --write "$path" >/dev/null 2>&1 ;;
esac
exit 0

Placement does the safety work here. PostToolUse is one of the events where exit 2 does not block, so a crashed formatter cannot stop an edit, which is the correct failure mode: I want formatted code, not a formatting gate. async: true, available on command hooks only, moves the run off the critical path entirely; an async hook cannot block or return decisions, and its output arrives on the next conversation turn. Command hooks default to a 600-second timeout, so even a slow run on a large file finishes without ceremony. If the hook needs to know whether the write succeeded before formatting, PostToolUse input carries the tool’s structured result in a field named tool_response. Not tool_output. I have watched that guess fail.

Routing a permission prompt to a desktop notification

The Notification event carries a matcher for the states worth routing: permission_prompt, idle_prompt, auth_success, elicitation_dialog, elicitation_complete, elicitation_response, plus agent_needs_input and agent_completed, the last two requiring v2.1.198 or later and an open agent view. This Mac runs multiple concurrent Claude Code sessions, and a session parked on a permission prompt is compute doing nothing while I look at a different window.

There is a constraint that shapes the implementation. As of v2.1.139, command hooks run in their own session with no controlling terminal on macOS and Linux, so a script cannot open /dev/tty and paint an alert itself. The sanctioned channel is terminalSequence in the JSON output, which requires v2.1.141 or later and is allowlisted to OSC 0, 1, 2, 9 (including 9;4 taskbar progress), 99, 777, and bare BEL. Anything outside the allowlist causes the field to be ignored.

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/notify-route.sh" }
        ]
      }
    ]
  }
}
#!/bin/bash
# notify-route.sh
input=$(cat)
sid=$(printf '%s' "$input" | jq -r '.session_id' | cut -c1-8)
printf '{"terminalSequence": "\\u001b]9;Claude session %s needs input\\u0007"}' "$sid"
exit 0

An OSC 9 sequence surfaces as a desktop notification on terminals that support it, tagged with enough of the session id to tell eight concurrent sessions apart. Exit 2 blocks nothing on Notification, so this class of hook can only inform. That is the point: routing is a formatter-shaped problem, not a guard-shaped one.

Three matcher mistakes that disarm a hook

Three matcher behaviors have either bitten me or sit one typo away from it. First, the character rule: a matcher containing only letters, digits, underscores, hyphens, spaces, commas, and pipes is treated as an exact string or a separated list of exact strings; any other character promotes it to an unanchored JavaScript regex with no warning. Unanchored means code-reviewer on a pre-2.1.195 install also fires for senior-code-reviewer. Hyphens joined the exact-match set in v2.1.195 and commas in v2.1.191; my 2.1.211 has both, but a settings file synced to an older machine does not.

Second, MCP tools match under mcp__<server>__<tool>, and a bare prefix like mcp__memory contains only exact-match characters, so it matches no tool at all. Covering a whole server takes mcp__memory__.*. I use the per-tool form for one standing rule: on 2026-05-15 image sends kept freezing my chat client, so screenshots are off by default here, and a PreToolUse deny matched to mcp__computer-use__screenshot holds that rule without relying on anyone’s memory.

Third, ten events ignore the matcher field outright and always fire, Stop and UserPromptSubmit among them. No warning, no error. A scoped matcher on Stop is a comment, not a filter.

What a hook cannot enforce

The harness caps what a hook can hold, and says so in its own mechanics. A Stop hook that keeps blocking gets overridden after 8 consecutive blocks and the turn ends anyway; CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the cap but the cap exists. All matching hooks run in parallel, and a deny from one does not prevent its siblings from executing, so any side effects in a sibling hook happen regardless of the verdict. When two PreToolUse hooks both return updatedInput, the last one to finish wins, nondeterministically; I keep one mutating hook per event and let the rest observe. once: true is honored only in skill frontmatter and ignored in settings files, so a settings-level hook that should run once has to track its own state. PermissionRequest hooks do not fire in non-interactive -p runs at all, which is why the guards in this chapter sit on PreToolUse: headless sessions are the ones that need walls most. And command hooks run with my full user permissions, which means a hook is not a sandbox. It is trusted code, and a bug in a guard is a bug with my credentials.

The sorting rule I ended up with is short. When an incident produces a lesson, I ask whether the lesson is a fact or a whenever. Facts, like which distribution team signs iOS builds or which account the keyring mislabels, go in CLAUDE.md, where reading them once is enough. Whenevers get a hook, and the prose that stays behind in CLAUDE.md explains why the hook exists, so the agent that hits exit 2 tomorrow gets the reason along with the refusal.