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 2The 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 0The 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 0This 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 0Placement 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 0An 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.