Claude Code Hooks
Claude Code fires hooks at tool-call boundaries. A hook is a shell command
that receives a JSON event on stdin and, for blocking hooks, decides via its
exit code whether the tool call proceeds. This is the only mechanism that
structurally stops a behavior — a prose rule in CLAUDE.md is a suggestion the
completion drive can override; a hook is a wall.
When a hook is the right tool (and when it isn't)
Write a hook when a rule keeps getting violated even though it's already
written down. The tell: you added the prose rule, it read clearly, and the
behavior recurred anyway — because at the moment of action, attention is 100%
on "get the thing done" and the reminder loses. That recurrence is the signal
to move the rule from prose (advisory) to a hook (enforced). Governance rule of
thumb: Tier-0 irreversible action + only prose, no hook → it should be a hook.
(Tier-0 here = an action whose damage cannot be undone from inside the session:
destroying uncommitted work, pushing secrets to a remote, deleting files, publishing
something outward. The test is reversibility, not severity.)
Do not reach for a hook when: the rule has never actually recurred (don't
pre-build guards for hypothetical mistakes — cost with no proven benefit), or
the "rule" is a judgment call with no mechanical signature (a hook can only
match tokens/patterns; it can't judge whether a design is good).
If the symptom is “it keeps reviewing / waiting / retrying,” do not assume the
answer is another hook. First complete the Loop Contract in rule 7 and read
pitfall #36.
The loop may be created entirely by an agent repeatedly applying a prose rule.
Hook types and what the exit code means
| Type |
Fires |
Exit 0 |
Exit 2 |
Other |
| PreToolUse |
before a tool runs |
allow |
block the call (stderr → shown to model as guidance) |
any other exit = "non-blocking error" → the call proceeds — but only while stdout carries no valid JSON. Claude Code reads JSON output on every exit code, and valid JSON overrides the code entirely. The skeletons here print nothing on stdout, so their fail-open reasoning holds; add a permissionDecision payload and the exit code stops being the decision |
| PostToolUse |
after a tool ran |
quiet unless it prints a hookSpecificOutput JSON on stdout — that is how context injection works, and it happens at exit 0 |
feedback to the model (can't un-run the tool) |
— |
| SessionStart |
session begins |
proceed |
cannot block — stderr shows the user a hook-error notice, Claude never sees it, the session starts anyway |
exit 0 anyway: not because a non-zero would block (it can't), but because anything non-zero puts a <hook> hook error in the user's transcript on every single session start. Takes a matcher on how the session started — startup, resume, clear, compact, fork |
Stop (+ SubagentStop) |
the model is about to finish responding |
let it stop |
block the stop — forces the model to keep going (stderr → fed back as the reason) |
loop safety: the hook checks stop_hook_active (necessary, not sufficient — rule 7). The harness's consecutive-block ceiling (default 8) is not a general backstop — its counter resets on any continuation that executed tools, so it never arrives for a hook whose remediation involves tool calls, which is most of them (#27). Carry your own bound. All Stop hooks for an event run in parallel — one block round can carry several hooks' feedback |
- PreToolUse is the workhorse for stopping a tool call — the four types in this
table are the ones this file teaches, not the complete set of blockable events, and
the official hooks reference (docs.claude.com / code.claude.com, not the
references/ files in this bundle — those cover only the four types above) now
lists many more blockable events, including UserPromptSubmit, PreCompact,
TeammateIdle, and task and config events. If what you need to gate is not a tool
call, look there before forcing it onto PreToolUse.
matcher selects the tool (Bash, Agent, WebFetch, …) — and how it is
matched depends on the characters you use: a matcher containing only letters,
digits, _, -, spaces, , and | is compared as an exact string (or a
|/,-separated list of exact strings); anything else is treated as an
unanchored JavaScript regex. Both directions bite silently — Edit.* also
matches NotebookEdit (anchor it ^Edit$), while mcp__memory matches nothing
because it is all exact-match characters and no tool is named exactly that (you
want mcp__memory__.*). Matching is case-sensitive. Exit 2 blocks and
the hook's stderr becomes the message the model sees — so put the why and
the correct alternative there, not just "blocked".
- PostToolUse can't undo, but it can inject authoritative context so a
later hallucination can't stand (e.g. re-read the real git HEAD after a commit
and surface it — the model can't "believe it committed" against injected truth).
- SessionStart is for health checks of the guard rails themselves —
silent when healthy, warn on breakage, always exit 0. Note why: it is not that
a non-zero exit would block the session (it cannot), but that it would print a
hook-error notice at every session start until someone fixes it — a check that
cries wolf on startup is a check people learn to scroll past.
set -euo pipefail vs set -uo pipefail — pick by contract, and know there
are two ways to keep an always-exit-0 contract. A hook that may block
(PreToolUse) wants -e: an unexpected failure aborting the script is
survivable, because the caller treats a non-0/2 exit as "proceed". A hook whose
contract is ALWAYS exit 0 (PostToolUse injectors, SessionStart checks) has
two honest shapes: (a) drop -e and ||-guard every risky command —
with -e on, one grep that legitimately finds nothing kills the hook
mid-way and the CLI surfaces a bare Failed with non-blocking status code
(pitfall #8, Pattern E's shape); or (b) keep -e and add trap 'exit 0' ERR
so any failure still converts to exit 0 while -e keeps guarding the plumbing
(git-commit-headcheck's production shape, Pattern D). Either is correct;
what you cannot do is -e alone with no trap and no ||-guards. Rule of
thumb: -e for hooks that decide; for hooks that report, drop -e or trap
it (pitfall #8).
- Stop is the odd one out, and the one most often reached for by mistake:
it's the only hook type that can react to what the model itself just
generated (its own reply text). Every other hook type — including
UserPromptSubmit, which sounds like a plausible place to police "what gets
said" — only ever sees the user's input; it structurally cannot see the
model's own current-turn output (that claim holds — this is still the
right reason to route such a rule to Stop). That guarantee, however, does
not extend to proving the .prompt field always originated from a
keystroke: a background task-notification's own report text can populate
it too, with nothing in the stdin JSON marking the difference — #30. A rule like "the model must not invent a shorthand name
for something it hasn't verified" belongs on Stop; put it on
UserPromptSubmit instead and it will (a) never once catch what it was
built for, since that text never flows through that event, and (b)
false-block the user's own unrelated typing whenever it happens to contain
the trigger pattern. This is a category mistake, not a tuning problem — no
amount of regex refinement on the wrong event fixes it. Full contract
(last_assistant_message vs transcript_path, the anti-loop check) in
Pattern E in references/hook_patterns.md.
- Stop has two block channels with identical loop protections — pick by
intent, and make the first (only) block carry everything.
decision: "block" + reason, or plain exit 2 + stderr, shows as a hook error — for
hard gates ("this must not stand"). hookSpecificOutput.additionalContext
shows as neutral "Stop hook feedback" with no error notification — for
coaching and reminders the model should weigh, not gates. Both count toward
the same consecutive-block ceiling from the table above, so the choice is
tone, not safety. What that means for message design: a blocked retry
round (stop_hook_active: true) is let through with whatever violations
remain — so a Stop guard gets exactly one informed bite. (The ceiling
reinforces this only when your remediation is "rewrite the reply"; if it
involves tool calls the counter resets and the ceiling never lands — #27.
Either way the one-bite conclusion holds, because it rests on the latch, not
on the ceiling.) Report all findings in that
one block (a guard that prints only the first loses the rest permanently —
pitfall #17), and write the message as an escape manual naming the exact
acceptable fix, not a verdict — the model converges in one round or it burns
the cap guessing. v2.1.145+ inputs background_tasks / session_crons let a
blocking hook tell "the session is done" from "the session is merely paused
waiting for background work" — blocking a pause forces pointless
continuations and wastes the same cap.
Full runnable skeletons: references/hook_patterns.md.
The skeleton (PreToolUse Bash guard)
#!/usr/bin/env bash
set -euo pipefail
IFS= read -rd '' INPUT || true # builtin; NOT $(cat) — see below
# 0-fork fast path: a builtin `case` on the raw JSON, BEFORE paying for python3.
# Your guard runs on EVERY matching tool call, so the irrelevant path is the one
# that has to be cheap. Keep this filter BROADER than what you actually block and
# never flag-level — it answers "is this even about X", nothing finer (#22).
case "$INPUT" in *TRIGGER*) ;; *) exit 0 ;; esac
TOOL=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null||echo "")
[ "$TOOL" != "Bash" ] && exit 0 # only guard the tool you mean to
CMD=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null||echo "")
[ -z "$CMD" ] && exit 0
printf '%s' "$CMD" | grep -qw 'TRIGGER' || exit 0 # precise relevance check
# ... precise detection here ...
if <command actually does the banned thing>; then
echo "BLOCKED: ... WHY ... USE INSTEAD: ..." >&2 # stderr = the guidance shown
exit 2
fi
exit 0
Why the first two lines are not stylistic. INPUT=$(cat) plus each
printf … | python3 -c … costs forks on every call this hook matches, including
the ones it has nothing to say about. A fleet of ~13 Bash-matcher hooks × parallel
sessions × sub-second tool cadence turned that into a sustained 40–200 forks/sec of
pure guard overhead and put Gatekeeper at the top of an all-day CPU ranking with no
runaway process anywhere — the fleet was fine; the irrelevant path's per-call cost
was the bug (#22, with the per-guard conversion recipe and its measured floor).
Three caveats before you copy the case line anywhere else — the first one is the
difference between a fast path and a bypass:
A coarse filter must be a SUPERSET of what you block, and a raw substring test
is not one. TRIG''GER -x runs TRIGGER — bash splices the quotes away before
execution — but the raw event text contains no TRIGGER substring, so a bare
case "$INPUT" in *TRIGGER*) exits 0 and the guard never sees it. Measured:
drop this exact line into the shipped Pattern A and TRIG''GER -x flips from
exit 2 to exit 0, a full bypass — while scripts/test_hook.sh still reports
21 pass / 0 fail, because no row carries a spliced trigger. Pattern A already
carries the fix and the reason ("de-splice — strip quotes and backslashes — and
check again; a false negative is a full bypass"); a coarse filter placed before
that de-splice makes it unreachable. Two safe shapes, in order of preference:
filter on something the splice cannot touch — a JSON key or a tool name
(case "$INPUT" in *'"tool_name":"Bash"'*)), since quote-splicing lives in the
command text and cannot rewrite the event's own structure; or de-splice
inside the filter before testing (strip ", ' and \ from a copy of the
input, then match). Prefer the first: it needs no escaping gymnastics, and a
filter whose own quoting you have to get right is a filter you can get wrong
silently. The skeleton above is safe
as written only because its own detection is likewise a plain word match; the
moment the guard below the filter is smarter than the filter, the filter decides.
This skeleton is fail-open on irrelevance (grep -qw … || exit 0), so a
coarse filter in front of it changes cost, not semantics. A fail-closed guard is
different: a bare substring filter silently converts its contract from
block-unknown to allow-unknown (measured — 'not json' sailed straight through the
first cut of that fix), and a *tool_name* marker alone re-opens the same hole from
the other side. Read #22's gate requirements before fitting a fast path to a guard
that is supposed to block on malformed input.
There is a cheaper layer above the script. A hook handler can carry an
if field in its registration — permission-rule syntax such as "Bash(git *)" —
and the hook command does not run at all when it doesn't match: zero forks,
because zero processes. It is best-effort by design (the docs say it fails open,
running your hook anyway, when the Bash command can't be parsed), so treat it as a
cost optimization and never as the gate — the in-script check still decides.
Three sharp edges: it holds exactly one rule (no &&/||), it is only evaluated
on tool events, and a hook that sets if on a non-tool event never runs at all.
Rules that separate a working guard from a session-poisoning one
Not style preferences — each is a specific failure we shipped and traced back.
1. Match at the token level with shlex, never awk-split the raw string
A guard that false-blocks a healthy command is worse than one that misses —
a guard people must bypass gets bypassed reflexively, and then it protects
nothing (the core discipline: 误杀健康输入比漏报更糟). The recurring cause of
false-blocks is matching on the raw command string.
- Wrong:
awk '{gsub(/&&|\|\||;|\|/,"\n")}' to split into segments — awk
doesn't understand shell quoting, so grep -E "a|TRIGGER|b" gets split at the
| inside the quoted regex, TRIGGER becomes a phantom command, and the
guard blocks a plain grep. (Shipped 2026-07-21; the guard's very first real use
was a false-block on my own grep.)
- Right: tokenize the whole command with the
shlex.shlex class, not the
shlex.split() function — split() only treats | ; & < > as separators when
they are space-separated, so ls|TRIGGER x tokenizes to ['ls|TRIGGER', 'x'] and
your command-position check never sees TRIGGER at all (measured; the class with
punctuation_chars=True yields ['ls', '|', 'TRIGGER', 'x']). Copy a shipped
walker verbatim rather than reaching for the one-liner — but copy the one that
passes scripts/test_hook.sh, which is Pattern A's. The
walker section is
a compact form and says so: it omits the per-wrapper valued-flag tables, so it
misses a target riding a valued-flag wrapper — measured, it returns "not in
command position" for timeout 5 TRIGGER, sudo -u root TRIGGER and
nice -n 10 TRIGGER, while Pattern A's version catches all three. (Bare
sudo TRIGGER is fine in both — it is the wrapper's own flag taking an argument
that the compact table doesn't know to skip.) Only one of those shapes is in the
shipped harness, so the run you actually see is 20 pass / 1 fail on
wrapper-timeout against 21/0 for Pattern A's; the other two fail silently
because no row covers them. A quoted
"a|TRIGGER|b" stays one token, so a regex argument is never mistaken for
a command. Then check whether your target is in a command position
(token[0], or right after a ;/&&/||/| separator, skipping VAR=val
env-assignment prefixes). Command-position walker in
references/hook_patterns.md.
- Corollary:
echo "…TRIGGER…", grep TRIGGER, # TRIGGER, man TRIGGER must
all pass. Your test set MUST include these mention-not-execute cases.
- Corollary — exempt
git write segments before they reach the walker. A
commit message is arbitrary data, and the whole message text reaches your
command-position walk as pseudo-command-text — git commit -F - <<EOF with a
body quoting foo|TRIGGER lands TRIGGER in command position, and the guard
blocks its own fix commit (pitfall #7 is exactly this, shipped). Any Bash guard
that inspects command strings must skip segments whose head is git +
commit/rebase/tag/am/cherry-pick — and do it at the whole-command
level, before any line splitting (Pattern A shows the order; the production
version is lib-git-commit-detect's adjacency check).
- Corollary — the walker is two-stage for a reason.
whitespace_split=True
treats newlines as ordinary whitespace, so a multiline block
(cd /x\ngit add\nTRIGGER -y) collapses into one segment headed by cd and
the trigger is never in command position — replayed trigger rate 0 on real
transcripts (pitfall #11). Split into lines shell-aware first (quote state
and backslash continuations honored, so quoted multiline strings don't
fragment), then shlex-walk each line — both Pattern A and the walker section
ship that splitter (split_shell_lines, production-proven in qlmanage-guard).
What even it cannot parse is a heredoc body (not quote syntax); when to accept
that residual is #11's call.
- But shlex isn't a silver bullet, and what you detect changes whether
fail-open is safe.
shlex.split() itself throws ValueError on an unbalanced
quote — a multi-line git commit -m "… message with a # or an unclosed quote
is the classic trigger. The except ValueError: cmd.split() fallback then
allows, which is right when you're detecting a banned modifier (does this
carry --no-verify? — missing it errs safe, Rule 1's direction), but
dangerous when you're detecting whether the command IS your target at all
(is this a git commit? — a ValueError there means the guard never recognises
the commit and silently doesn't fire; a real cross-domain commit shipped with no
confirmation dialog this way). For the is-this-the-command decision, prefer a
narrow regex (git and commit as separate words, any flag tokens between)
that's immune to multi-line-quote breakage; reserve the shlex walker for the
command-position / modifier checks where fail-open is the safe direction.
(The boundary: regex when the predicate is "is this a specific common command
at all" — git commit, git push — whose own message/arguments are what breaks
tokenizing; walker when the predicate is "is a banned command or modifier in
command position" — there the banned thing is rare and a ValueError fail-open
errs safe, Rule 1's direction.)
2. Test with bash -n + a real JSON event, end-to-end, BEFORE registering
A corrupted or wrong-logic PreToolUse hook poisons the entire session —
every later Bash call gets truncated / duplicated / falsely-failed / looks
hallucinated-executed, and you'll blame "the environment" when it's the hook you
just installed. (2026-07-05: a [^;&|] regex broke in one edit, ;& became a
bash case-fallthrough token, poisoned half a session until bash -n found it.)
"My tests passed at deploy" isn't enough — the file can corrupt in a later edit.
Gate before registering ANY hook:
bash -n hook.sh # syntax
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<trigger case>"}}' | ./hook.sh; echo "exit=$?" # want 2
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<healthy lookalike>"}}'| ./hook.sh; echo "exit=$?" # want 0
Bundle the harness: scripts/test_hook.sh runs a whole
table of trigger/allow cases. Self-block gotcha: once the hook is live in the
session you cannot test it by putting the trigger string in your own Bash
command — the live hook blocks your test command. Put the cases in a script
file and run bash test_hook.sh; the outer command doesn't contain the
trigger, so it isn't self-blocked.
Once a hook has caused one real incident (a false-block or a silent miss),
solo re-reading the code is not enough — a same-day rewrite of a Stop-hook
guard was itself re-broken twice by the author while fixing the first bug (a
quote inside a Python comment, invisible on re-read, only surfaced by running
the actual failing JSON case). The escalation is a multi-lens agent-team
review where every finding must be reproduced by executing a real payload
against the live script, not by reading the code and agreeing — this is the
general Counter Review methodology
(skill-creator's skill-development-methodology reference, Phase 6), applied to
a hook instead of a skill. In one such pass, 3 lenses (matching
logic / shell-embedding safety / event-contract robustness) surfaced 13
confirmed, independently-reproduced bugs and 1 finding whose own cited
evidence turned out to be a hallucinated doc quote — caught only because the
verifier was required to curl the raw source and grep for the exact string
rather than trust the citation.
3. SSOT + symlink so a reinstall can't silently disarm the guard
Real script in a version-controlled dir, symlinked into the hooks dir Claude reads:
~/scripts/claude-hooks/<name>.sh # SSOT (this setup: a private git repo)
~/.claude/hooks/<name>.sh # symlink → SSOT
# install / recover:
ln -s ~/scripts/claude-hooks/<name>.sh ~/.claude/hooks/<name>.sh
A ~/.claude reinstall wipes the hooks dir; the symlink target survives, and
recovery is one ln -s. A dangling symlink disables a Tier-0 guard with zero
signal — which is why a SessionStart health check exists (rule 4; runnable
skeleton: Pattern C in references/hook_patterns.md).
4. Registration is per-profile — converge ALL profiles, release via a HUMAN gate
- There are two registration surfaces, and the health check probably only
watches one. Besides the profile settings below, a repo's own
.claude/settings.json (and settings.local.json) registers hooks for
sessions in that repo. Those live outside ~/.claude/hooks/, so a guard-rail
check that walks that directory covers none of them — syntax, path,
--selftest, nothing. Two silent failures shipped in one such file and
survived two months under a SessionStart health check built to prevent
exactly that (#38, #39). Register project hooks by absolute path — a
relative one resolves against the session cwd and breaks the first time
someone starts claude from a subdirectory — and extend the health check to
walk up from the event's cwd for project settings.
- A hook in
~/.claude/hooks/ protects nothing if the active profile's
settings.json doesn't call it. Multi-profile users ran with zero guards until
every profile was converged. Register in the main profile's settings
(~/.claude/settings.json in this setup; the Registration section of
references/hook_patterns.md has the exact jsonc
shape) — PreToolUse → matcher Bash → your hook — then converge the rest (this setup
uses sync-profile-settings.py --all, owned by the claude-switch-models-setup
skill). A SessionStart health check greps each profile for the Tier-0 guards to
catch drift. Settings edits are picked up by the CLI's file watcher (official
hooks docs), so registration is live without a restart — confirm by watching
the guard fire on a safe probe, or at next session's health-check line.
- If the guard needs a release valve, make it a human gate, not an env var.
A static
GUARD_OK=1 escape hatch is no gate — the model can set the env var
itself. Use a native macOS dialog (osascript — the model can't click);
refuse/cancel/timeout = hard NO; log every prompt/bypass to an audit file.
Pattern in references/hook_patterns.md.
- ⚠️
/dev/tty is not a second channel — the docs say hooks cannot open it.
This file used to prescribe a typed YES on /dev/tty alongside the dialog.
The official reference is explicit: hooks "run in their own session without a
controlling terminal", and "the hook process and any child processes can't
open /dev/tty" (terminalSequence is the documented replacement for writing
to it). So a "two-channel" gate built that way is one channel plus dead code,
and on a box with no GUI session the gate can never be approved by anyone.
Consistent with local observation, though read the boundary carefully: in one
setup's shared audit log — 1,801 entries, several guards writing to it, three of
which implement a tty channel — 360 lines carry a channel tag (236 dialog
confirmations, 124 declines or timeouts) and not one line of any kind names the
tty channel. That means the tty branch was never entered, which on macOS is
what you would predict anyway, because the dialog answers first and short-
circuits it. So the log shows nothing here ever depended on tty; it is the
documentation, not this measurement, that establishes tty cannot work at all.
Keep the dialog; if you need a non-macOS gate,
you need a channel this file does not yet have a verified answer for.
- ⚠️ A human gate that outlives the hook timeout fails OPEN. Hook
command
timeout defaults to 600s (30s on UserPromptSubmit), and a timed-out hook
does not block the tool call — so an unanswered dialog does not become a
"no", it becomes an allow. Bound your wait well under the timeout and make
no-answer resolve to block yourself, before the harness resolves it for you.
- The docs also carry an in-UI channel — PreToolUse
hookSpecificOutput
permissionDecision: "ask", which prompts through Claude Code's own interface.
It is worth knowing about, but unverified here under bypassPermissions /
auto-accept, which is precisely the mode a Tier-0 gate must survive; the
dialog is prescribed because it does not depend on permission mode.
- Below Tier-0, where a model-serviceable escape is allowed, make it the
correct usage rather than a bypass flag. The rule above is absolute for Tier-0
and does not bend here — this is about the correctness guards that fall short of
it, which still need a way out for the legitimate case the detector cannot
distinguish. The question is what you make that way out be. A
SKIP=1 /
--force env escape trains exactly the reflex rule 1 warns about, and under
deadline it is indistinguishable from a bypass. Prefer an escape that is the
thing you wanted them to do anyway, so taking it improves the command instead of
disarming the guard: pipe-fallback-guard exits 0 the moment the command mentions
pipefail / PIPESTATUS / pipestatus, because an author who wrote any of those
has already demonstrated they understand pipeline exit codes — the guard has
nothing left to teach them. The test to apply: if someone takes my escape hatch,
is the resulting command better, or merely unblocked? If the honest answer is
"merely unblocked", you have a bypass flag with a nicer name. Sibling principle
for the Tier-0 case, where the override exists only so the gate can be tested:
the escape hatch may only make the gate stricter — see the GIT_GUARD_TEST
discussion in references/hook_patterns.md.
5. Decide the failure direction, and test that — not just the happy path
Rule 1 ranked detection-tuning errors: given that the guard ran, false-blocking a
healthy command beats missing a rare bad one, because a guard people must bypass
gets bypassed reflexively. This rule is about a different axis — the guard's
machinery not running at all — so "which is worse" is not being reversed here;
the two rankings never meet. A tuning miss costs you one case; this costs you the
guard, silently, on every input of that shape.
The failure: the guard cannot obtain the thing it judges on — a parse throws, a path doesn't
resolve, a dependency is missing, a subprocess times out — and the very
2>/dev/null || true that stops the hook from crashing quietly converts "I could
not check" into "nothing to report." The hook exits 0. That output is identical
to a real pass, which is why this survives for weeks.
So at every point where the hook obtains something (parses the command, reads
staged files, queries a service), decide explicitly: if this comes back empty, does
that mean allow or block? — and write the answer next to the branch. Fail-open is
often right for a modifier check (does this carry --no-verify? missing it costs
you one case). Fail-closed is usually right for the is-this-even-the-thing check
(is this a cross-domain commit? an empty answer means the guard never fired at all).
Then test the direction, not the happy path: hand it an unresolvable path or an
unparseable command on purpose and assert it still does what you decided. A suite
where every row passes because the hook silently allowed everything is
indistinguishable from a suite that passes.
Read those results carefully — the same input has opposite correct answers for
different guard classes. Take cd ~/no-such-dir && TRIGGER:
| Guard class |
Judges on |
Correct exit |
Why |
| Token matcher (is this a banned command form?) |
the command text alone |
2, block |
TRIGGER is right there in the text; an unresolvable cd doesn't make it not-a-trigger, and if the guard goes quiet here it will also go quiet on cd ~/real-dir && TRIGGER |
| State deriver (does the repo's staged set span domains?) |
state read from disk |
0, allow |
cd fails, && short-circuits, no commit ever happens — there is nothing to guard |
| Termination-state reader (has the remediation already happened?) |
a receipt / counter file (rule 7) |
0, allow — when the state file IS the termination condition |
an unreadable receipt means the hook cannot know it already fired; failing closed here blocks forever with no remediation possible and no human-visible cause — that is the loop, and it is the one failure worse than a missed case. Inverted sub-case — read this before copying the row: when the state is only a budget on top of an independent predicate (the block still clears by doing the work), allow-on-unreadable silently disables the entire hook — one unwritable directory makes it mute for every input, forever, which is the worst failure shape there is. There, fail back to the behavior before the budget existed (keep evaluating the predicate), not to silence. Tell the two apart with one question: if the state vanished, would remediation still be possible? No → receipt case, allow. Yes → budget case, keep checking. Worked answers, so nobody has to re-derive them: rule 7's mechanism 2 (receipt) and mechanism 3 (per-session counter) are both receipt case → allow — mechanism 3 is deliberately blind to whether R happened, so its counter is the only exit and muting it strands the turn. The budget case is a counter layered on a predicate the user can still satisfy on its own |
So decide which class your hook is before writing the row, and the harness's
unresolvable path template row expects 2 because that template targets the
token-matcher class. Getting this backwards produces a confident FAIL against a
correct guard. For a state-deriving guard the failure you are hunting is: the command would really
have run and the guard didn't see it — an unbalanced quote makes tokenizing throw,
the fallback allows, and a genuine cross-domain commit ships with no dialog (rule 1's
ValueError note). Ask of every allowed row: would this command actually have done
the thing? If no, the allow is correct.
Running this exact probe against a real state-deriving guard returned two allows on
the first pass: one was correct (the short-circuit above) and one was a genuine
fail-open. The probe finds things; you still have to classify what it found —
which is why the class table above comes before the rows.
Real case (2026-07-22): a scope guard read staged files via git -C "$REPO_DIR"
with REPO_DIR parsed out of the command text — so cd ~/repo && git commit
handed it a literal ~/repo, git -C failed, staged came back empty, and the guard
concluded "no cross-domain files, allow." Every cross-repo commit went unguarded and
nothing ever looked wrong. Anatomy + the shared-library twist: pitfall #10.
That parser has a second failure direction, and it is the nastier one. Once you
add a fallback so it stops failing open, the fallback becomes correct for one
reason and wrong for another — and both print the same line. git push (no
explicit target) legitimately falls back to the event's cwd; git -C "$R" push
names a target the hook cannot resolve, falls back to the same cwd, and then
renders a confident ✅ about a different repository. Those two cases render
byte-identically (measured, MD5-equal), so neither the hook nor the reader can
tell the honest verdict from the misbound one. A fallback value must carry the reason it was
chosen, and only "no explicit target" earns a verdict. Full anatomy, the
confused-deputy framing, and why fixtures with literal paths never catch it:
pitfall #28.
6. Judge on a fact the world can answer — never on your own rendering, never on a naming habit
Rules 1 and 5 are about how you match and which way you fail. This one is
about where the thing you match on came from, and it has two failure shapes
that both go silent:
- Never branch on a string you formatted for a human. If the hook builds a
report — sorted, joined, truncated to the first N with a
(+M more) tail — and
then pattern-matches its own decision against that report, the branch inherits
the rendering's losses. Items past the cutoff simply do not exist to it, so the
branch works on every small fixture and stops firing on exactly the large
sessions it was built for. Emit the machine fact on its own channel (one
untruncated KINDS:a,b,c line) and match that. A rendering is an output, not
a data source (pitfall #12).
- Prefer a checkable fact over a naming convention. Classifying by path shape
(
/skills?/[^/]+/references/) encodes one directory layout; a repo laid out any
other way is classified None — silently, forever. The fix is not to widen the
pattern, which trades a silent miss for machine-wide false positives (rule 1
forbids exactly that trade); it is to ask a question the filesystem can answer —
is there a SKILL.md beside this references/ directory? Facts survive
layout changes; conventions do not. (When the candidate is a SKILL.md,
there is no sibling to ask about — classify by basename; #13 explains why that
is a spec-defined fact and not the naming habit this rule warns against.)
A checkable fact can still be the wrong fact — anchor the question and filter by
type. test -f SKILL.md is true in a downloads folder too, and one guard that walked
ancestors looking for exactly that swallowed an entire home directory, then told a real
session to load a skill named after it — a name that cannot exist (rule 9's incident).
Anchor to a sibling of a specific directory or to a known install path; an unanchored
ancestor walk is a convention wearing a fact's clothes.
The tell for both: a branch that has never once fired in production while its
tests are green. Print the raw pre-formatting classification and you will see
which of the two you have.
7. If the hook demands remediation, prove the loop terminates
A hook that blocks (exit 2) until X is done — Stop hooks especially, since
they re-fire on every subsequent stop — is not a check, it's a feedback loop.
(A hook that merely injects a demand and exits 0 has no loop at all: nothing
re-evaluates. That is mechanism 0 below, and it is the right default more often
than people reach for it.)
condition T is true → hook demands remediation R → model performs R → T checked again
Write the Loop Contract before the first cycle — for hook-enforced loops and
agent-driven review / wait / retry loops alike:
LOOP KEY: immutable logical target / lineage + one failure axis
FIRE T: the condition that starts another cycle
REMEDIATION R: the exact action one cycle performs
VARIANT V: the well-founded quantity that strictly decreases for this key
BUDGET: maximum cycles, fixed before cycle 1
SUCCESS EXIT: the observable that proves the axis is clear
CAPPED EXIT: what is left blocked / unshipped / pending when the budget ends
No completed contract means no blocking Stop hook and no repeated reviewer or
polling loop. Freeze the key before cycle 1. A remediation snapshot, commit, or
reviewer name stays inside that same lineage and cannot mint a new budget. A
new, unrelated finding is a new key: record it separately; it does not reset
this loop's budget. A cycle that cannot name a new falsifying experiment or a
smaller V adds no evidence and stops.
For an agent-driven independent-review loop, the default budget is one
initial review plus one narrowly scoped re-review after substantive fixes. A
third reviewer is not automatic. If the re-review still reproduces a BLOCKER or
MAJOR on the same axis, leave the hook unregistered / artifact unshipped, report
the blocked state, and require a new user-authorized task whose Loop Contract
declares its budget before cycle 1. An agent-declared budget cannot authorize
itself. Inside that authorized task, name the concrete safety or business
failure caused by stopping now; optional polish does not qualify.
Filled review-loop example:
LOOP KEY: <initial frozen commit>'s review lineage + termination-contract fidelity
FIRE T: fresh review reports a same-axis BLOCKER / MAJOR
REMEDIATION R: reproduce that finding, apply one bounded fix, run its narrow check
VARIANT V: 2 - completed review cycles
BUDGET: 2 cycles total (initial review + one re-review)
SUCCESS EXIT: no same-axis BLOCKER / MAJOR
CAPPED EXIT: artifact stays unregistered / unshipped; report remaining findings
Every repair descendant of the initial frozen commit remains in this key. The
current snapshot changes so the reviewer can inspect the fix; the lineage and
its remaining budget do not.
Nothing mechanically enforces this hookless budget — it holds only while the
agent follows the Skill. That limitation is why the capped exit must be visible
and must never be reported as “completed.”
If completing R can make T true again, the loop does not converge. Nothing
errors, nothing crashes; it burns round after round until a human interrupts —
which is what usually happens, because each round is a complete remediation
cycle (dispatch, wait, adopt, edit
…(truncated)
1---2name: claude-code-hooks3description: How to write, test, register, and debug Claude Code hooks — PreToolUse / PostToolUse / SessionStart / Stop Bash guards that enforce a rule the model would otherwise talk itself past. Use whenever the user wants to create a hook, block/intercept a tool call, turn a repeatedly-violated rule into a hard gate, add a guard rail, debug a hook that misfires or "poisons the session", register a hook across profiles, or mentions hooks / PreToolUse / Stop hook / 拦截 / 守卫 / 钩子 / 拦下. Bakes in the hard-won pitfalls: UserPromptSubmit only ever sees user input, never Claude's own text — a rule about Claude's own output belongs on Stop instead; token-level shlex matching (never awk splitting); bash -n + real-JSON end-to-end testing BEFORE registering (a corrupted PreToolUse hook poisons every Bash call); SSOT + symlink so a ~/.claude reinstall can't lose it; multi-profile convergence; and human-confirmation release gates. Reach for this even for "make it stop doing X" — a durable stop is a hook, not a reminder.4---5
6# Claude Code Hooks
7
8Claude Code fires **hooks** at tool-call boundaries. A hook is a shell command
9that receives a JSON event on stdin and, for blocking hooks, decides via its
10**exit code** whether the tool call proceeds. This is the only mechanism that
11*structurally* stops a behavior — a prose rule in CLAUDE.md is a suggestion the
12completion drive can override; a hook is a wall.
13
14## When a hook is the right tool (and when it isn't)
15
16Write a hook when **a rule keeps getting violated even though it's already
17written down**. The tell: you added the prose rule, it read clearly, and the
18behavior recurred anyway — because at the moment of action, attention is 100%
19on "get the thing done" and the reminder loses. That recurrence is the signal
20to move the rule from prose (advisory) to a hook (enforced). Governance rule of
21thumb: *Tier-0 irreversible action + only prose, no hook → it should be a hook.*
22(**Tier-0** here = an action whose damage cannot be undone from inside the session:
23destroying uncommitted work, pushing secrets to a remote, deleting files, publishing
24something outward. The test is reversibility, not severity.)
25
26Do **not** reach for a hook when: the rule has never actually recurred (don't
27pre-build guards for hypothetical mistakes — cost with no proven benefit), or
28the "rule" is a judgment call with no mechanical signature (a hook can only
29match tokens/patterns; it can't judge whether a design is good).
30
31If the symptom is “it keeps reviewing / waiting / retrying,” do not assume the
32answer is another hook. First complete the **Loop Contract** in rule 7 and read
33[pitfall #36](references/hook_pitfalls.md#36-a-self-applied-review-rule-can-loop-without-any-hook).
34The loop may be created entirely by an agent repeatedly applying a prose rule.
35
36## Hook types and what the exit code means
37
38| Type | Fires | Exit 0 | Exit 2 | Other |
39|---|---|---|---|---|
40| **PreToolUse** | before a tool runs | allow | **block** the call (stderr → shown to model as guidance) | any other exit = "non-blocking error" → **the call proceeds** — but only while stdout carries no valid JSON. Claude Code reads JSON output on **every** exit code, and valid JSON overrides the code entirely. The skeletons here print nothing on stdout, so their fail-open reasoning holds; add a `permissionDecision` payload and the exit code stops being the decision |
41| **PostToolUse** | after a tool ran | quiet **unless it prints a `hookSpecificOutput` JSON on stdout — that is how context injection works, and it happens at exit 0** | feedback to the model (can't un-run the tool) | — |
42| **SessionStart** | session begins | proceed | **cannot block** — stderr shows the user a hook-error notice, Claude never sees it, the session starts anyway | **exit 0 anyway**: not because a non-zero would block (it can't), but because anything non-zero puts a `<hook> hook error` in the user's transcript on every single session start. Takes a `matcher` on *how the session started* — `startup`, `resume`, `clear`, `compact`, `fork` |
43| **Stop** (+ `SubagentStop`) | the model is about to finish responding | let it stop | **block the stop** — forces the model to keep going (stderr → fed back as the reason) | loop safety: the hook checks `stop_hook_active` (necessary, **not** sufficient — rule 7). The harness's consecutive-block ceiling (default 8) is **not** a general backstop — its counter resets on any continuation that executed tools, so it never arrives for a hook whose remediation involves tool calls, which is most of them (#27). Carry your own bound. All Stop hooks for an event run **in parallel** — one block round can carry several hooks' feedback |
44
45- **PreToolUse** is the workhorse for stopping a *tool call* — the four types in this
46 table are the ones this file teaches, not the complete set of blockable events, and
47 the **official** hooks reference (docs.claude.com / code.claude.com, not the
48 `references/` files in this bundle — those cover only the four types above) now
49 lists many more blockable events, including `UserPromptSubmit`, `PreCompact`,
50 `TeammateIdle`, and task and config events. If what you need to gate is not a tool
51 call, look there before forcing it onto PreToolUse.
52 `matcher` selects the tool (`Bash`, `Agent`, `WebFetch`, …) — **and how it is
53 matched depends on the characters you use**: a matcher containing only letters,
54 digits, `_`, `-`, spaces, `,` and `|` is compared as an **exact string** (or a
55 `|`/`,`-separated list of exact strings); anything else is treated as an
56 **unanchored JavaScript regex**. Both directions bite silently — `Edit.*` also
57 matches `NotebookEdit` (anchor it `^Edit$`), while `mcp__memory` matches **nothing**
58 because it is all exact-match characters and no tool is named exactly that (you
59 want `mcp__memory__.*`). Matching is case-sensitive. Exit 2 blocks and
60 the hook's **stderr** becomes the message the model sees — so put the *why* and
61 the *correct alternative* there, not just "blocked".
62- **PostToolUse** can't undo, but it can **inject authoritative context** so a
63 later hallucination can't stand (e.g. re-read the real git HEAD after a commit
64 and surface it — the model can't "believe it committed" against injected truth).
65- **SessionStart** is for **health checks of the guard rails themselves** —
66 silent when healthy, warn on breakage, always exit 0. Note *why*: it is not that
67 a non-zero exit would block the session (it cannot), but that it would print a
68 hook-error notice at every session start until someone fixes it — a check that
69 cries wolf on startup is a check people learn to scroll past.
70- **`set -euo pipefail` vs `set -uo pipefail` — pick by contract, and know there
71 are two ways to keep an always-exit-0 contract.** A hook that may block
72 (PreToolUse) wants `-e`: an unexpected failure aborting the script is
73 survivable, because the caller treats a non-0/2 exit as "proceed". A hook whose
74 contract is **ALWAYS exit 0** (PostToolUse injectors, SessionStart checks) has
75 two honest shapes: (a) **drop `-e`** and `||`-guard every risky command —
76 with `-e` on, one `grep` that legitimately finds nothing kills the hook
77 mid-way and the CLI surfaces a bare `Failed with non-blocking status code`
78 (pitfall #8, Pattern E's shape); or (b) **keep `-e` and add `trap 'exit 0' ERR`**
79 so any failure still converts to exit 0 while `-e` keeps guarding the plumbing
80 (`git-commit-headcheck`'s production shape, Pattern D). Either is correct;
81 what you cannot do is `-e` alone with no trap and no `||`-guards. Rule of
82 thumb: **`-e` for hooks that decide; for hooks that report, drop `-e` or trap
83 it** (pitfall #8).
84- **Stop is the odd one out, and the one most often reached for by mistake**:
85 it's the *only* hook type that can react to what the model **itself just
86 generated** (its own reply text). Every other hook type — including
87 `UserPromptSubmit`, which sounds like a plausible place to police "what gets
88 said" — only ever sees the **user's** input; it structurally cannot see the
89 model's own *current-turn* output (that claim holds — this is still the
90 right reason to route such a rule to Stop). That guarantee, however, does
91 not extend to proving the `.prompt` field always originated from a
92 keystroke: a background task-notification's own report text can populate
93 it too, with nothing in the stdin JSON marking the difference — #30. A rule like "the model must not invent a shorthand name
94 for something it hasn't verified" belongs on Stop; put it on
95 `UserPromptSubmit` instead and it will (a) never once catch what it was
96 built for, since that text never flows through that event, and (b)
97 false-block the user's own unrelated typing whenever it happens to contain
98 the trigger pattern. This is a category mistake, not a tuning problem — no
99 amount of regex refinement on the wrong event fixes it. Full contract
100 (`last_assistant_message` vs `transcript_path`, the anti-loop check) in
101 Pattern E in [references/hook_patterns.md](references/hook_patterns.md).
102- **Stop has two block channels with identical loop protections — pick by
103 intent, and make the first (only) block carry everything.** `decision:
104 "block"` + `reason`, or plain exit 2 + stderr, shows as a hook *error* — for
105 hard gates ("this must not stand"). `hookSpecificOutput.additionalContext`
106 shows as neutral "Stop hook feedback" with no error notification — for
107 coaching and reminders the model should weigh, not gates. Both count toward
108 the same consecutive-block ceiling from the table above, so the choice is
109 tone, not safety. What that means for message design: a blocked retry
110 round (`stop_hook_active: true`) is let through **with whatever violations
111 remain** — so a Stop guard gets exactly **one** informed bite. (The ceiling
112 reinforces this only when your remediation is "rewrite the reply"; if it
113 involves tool calls the counter resets and the ceiling never lands — #27.
114 Either way the one-bite conclusion holds, because it rests on the latch, not
115 on the ceiling.) Report *all* findings in that
116 one block (a guard that prints only the first loses the rest permanently —
117 pitfall #17), and write the message as an escape manual naming the exact
118 acceptable fix, not a verdict — the model converges in one round or it burns
119 the cap guessing. v2.1.145+ inputs `background_tasks` / `session_crons` let a
120 blocking hook tell "the session is done" from "the session is merely paused
121 waiting for background work" — blocking a pause forces pointless
122 continuations and wastes the same cap.
123
124Full runnable skeletons: [references/hook_patterns.md](references/hook_patterns.md).
125
126## The skeleton (PreToolUse Bash guard)
127
128```bash
129#!/usr/bin/env bash
130set -euo pipefail
131IFS= read -rd '' INPUT || true # builtin; NOT $(cat) — see below
132# 0-fork fast path: a builtin `case` on the raw JSON, BEFORE paying for python3.
133# Your guard runs on EVERY matching tool call, so the irrelevant path is the one
134# that has to be cheap. Keep this filter BROADER than what you actually block and
135# never flag-level — it answers "is this even about X", nothing finer (#22).
136case "$INPUT" in *TRIGGER*) ;; *) exit 0 ;; esac
137TOOL=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null||echo "")
138[ "$TOOL" != "Bash" ] && exit 0 # only guard the tool you mean to
139CMD=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null||echo "")
140[ -z "$CMD" ] && exit 0
141printf '%s' "$CMD" | grep -qw 'TRIGGER' || exit 0 # precise relevance check
142# ... precise detection here ...
143if <command actually does the banned thing>; then
144 echo "BLOCKED: ... WHY ... USE INSTEAD: ..." >&2 # stderr = the guidance shown
145 exit 2
146fi
147exit 0
148```
149
150**Why the first two lines are not stylistic.** `INPUT=$(cat)` plus each
151`printf … | python3 -c …` costs forks **on every call this hook matches, including
152the ones it has nothing to say about**. A fleet of ~13 Bash-matcher hooks × parallel
153sessions × sub-second tool cadence turned that into a sustained 40–200 forks/sec of
154pure guard overhead and put Gatekeeper at the top of an all-day CPU ranking with no
155runaway process anywhere — the fleet was fine; the *irrelevant path's* per-call cost
156was the bug (#22, with the per-guard conversion recipe and its measured floor).
157
158Three caveats before you copy the `case` line anywhere else — the first one is the
159difference between a fast path and a bypass:
160
161- **A coarse filter must be a SUPERSET of what you block, and a raw substring test
162 is not one.** `TRIG''GER -x` runs `TRIGGER` — bash splices the quotes away before
163 execution — but the raw event text contains no `TRIGGER` substring, so a bare
164 `case "$INPUT" in *TRIGGER*)` exits 0 and the guard never sees it. **Measured**:
165 drop this exact line into the shipped Pattern A and `TRIG''GER -x` flips from
166 exit 2 to exit 0, a full bypass — while `scripts/test_hook.sh` still reports
167 21 pass / 0 fail, because no row carries a spliced trigger. Pattern A already
168 carries the fix and the reason ("de-splice — strip quotes and backslashes — and
169 check again; a false negative is a full bypass"); a coarse filter placed *before*
170 that de-splice makes it unreachable. Two safe shapes, in order of preference:
171 **filter on something the splice cannot touch** — a JSON key or a tool name
172 (`case "$INPUT" in *'"tool_name":"Bash"'*)`), since quote-splicing lives in the
173 *command* text and cannot rewrite the event's own structure; or **de-splice
174 inside the filter** before testing (strip `"`, `'` and `\` from a copy of the
175 input, then match). Prefer the first: it needs no escaping gymnastics, and a
176 filter whose own quoting you have to get right is a filter you can get wrong
177 silently. The skeleton above is safe
178 as written only because its own detection is likewise a plain word match; the
179 moment the guard below the filter is smarter than the filter, the filter decides.
180
181- **This skeleton is fail-open on irrelevance** (`grep -qw … || exit 0`), so a
182 coarse filter in front of it changes cost, not semantics. **A fail-closed guard is
183 different**: a bare substring filter silently converts its contract from
184 block-unknown to allow-unknown (measured — `'not json'` sailed straight through the
185 first cut of that fix), and a `*tool_name*` marker alone re-opens the same hole from
186 the other side. Read #22's gate requirements *before* fitting a fast path to a guard
187 that is supposed to block on malformed input.
188- **There is a cheaper layer above the script.** A hook handler can carry an
189 `if` field in its registration — permission-rule syntax such as `"Bash(git *)"` —
190 and the hook command **does not run at all** when it doesn't match: zero forks,
191 because zero processes. It is best-effort by design (the docs say it fails *open*,
192 running your hook anyway, when the Bash command can't be parsed), so treat it as a
193 cost optimization and **never as the gate** — the in-script check still decides.
194 Three sharp edges: it holds exactly one rule (no `&&`/`||`), it is only evaluated
195 on tool events, and a hook that sets `if` on a non-tool event **never runs at all**.
196
197## Rules that separate a working guard from a session-poisoning one
198
199Not style preferences — each is a specific failure we shipped and traced back.
200
201### 1. Match at the **token level with shlex**, never awk-split the raw string
202
203A guard that **false-blocks a healthy command is worse than one that misses** —
204a guard people must bypass gets bypassed reflexively, and then it protects
205nothing (the core discipline: *误杀健康输入比漏报更糟*). The recurring cause of
206false-blocks is matching on the raw command string.
207
208
209- **Wrong**: `awk '{gsub(/&&|\|\||;|\|/,"\n")}'` to split into segments — awk
210 doesn't understand shell quoting, so `grep -E "a|TRIGGER|b"` gets split at the
211 `|` *inside the quoted regex*, `TRIGGER` becomes a phantom command, and the
212 guard blocks a plain grep. (Shipped 2026-07-21; the guard's very first real use
213 was a false-block on my own grep.)
214- **Right**: tokenize the whole command with the **`shlex.shlex` class**, not the
215 `shlex.split()` function — `split()` only treats `| ; & < >` as separators when
216 they are space-separated, so `ls|TRIGGER x` tokenizes to `['ls|TRIGGER', 'x']` and
217 your command-position check never sees `TRIGGER` at all (measured; the class with
218 `punctuation_chars=True` yields `['ls', '|', 'TRIGGER', 'x']`). Copy a shipped
219 walker verbatim rather than reaching for the one-liner — but **copy the one that
220 passes `scripts/test_hook.sh`**, which is **Pattern A's**. The
221 [walker section](references/hook_patterns.md#the-shlex-command-position-walker) is
222 a *compact* form and says so: it omits the per-wrapper valued-flag tables, so it
223 misses a target riding a **valued-flag wrapper** — measured, it returns "not in
224 command position" for `timeout 5 TRIGGER`, `sudo -u root TRIGGER` and
225 `nice -n 10 TRIGGER`, while Pattern A's version catches all three. (Bare
226 `sudo TRIGGER` is fine in both — it is the wrapper's *own* flag taking an argument
227 that the compact table doesn't know to skip.) Only one of those shapes is in the
228 shipped harness, so the run you actually see is **20 pass / 1 fail on
229 `wrapper-timeout`** against 21/0 for Pattern A's; the other two fail silently
230 because no row covers them. A quoted
231 `"a|TRIGGER|b"` stays **one token**, so a regex argument is never mistaken for
232 a command. Then check whether your target is in a **command position**
233 (token[0], or right after a `;`/`&&`/`||`/`|` separator, skipping `VAR=val`
234 env-assignment prefixes). Command-position walker in
235 [references/hook_patterns.md](references/hook_patterns.md).
236- Corollary: `echo "…TRIGGER…"`, `grep TRIGGER`, `# TRIGGER`, `man TRIGGER` must
237 all pass. Your test set MUST include these mention-not-execute cases.
238- **Corollary — exempt `git` write segments before they reach the walker.** A
239 commit message is arbitrary data, and the whole message text reaches your
240 command-position walk as pseudo-command-text — `git commit -F - <<EOF` with a
241 body quoting `foo|TRIGGER` lands `TRIGGER` in command position, and the guard
242 blocks its own fix commit (pitfall #7 is exactly this, shipped). Any Bash guard
243 that inspects command strings must skip segments whose head is `git` +
244 `commit`/`rebase`/`tag`/`am`/`cherry-pick` — and do it at the whole-command
245 level, before any line splitting (Pattern A shows the order; the production
246 version is `lib-git-commit-detect`'s adjacency check).
247- **Corollary — the walker is two-stage for a reason.** `whitespace_split=True`
248 treats newlines as ordinary whitespace, so a multiline block
249 (`cd /x\ngit add\nTRIGGER -y`) collapses into one segment headed by `cd` and
250 the trigger is never in command position — replayed trigger rate 0 on real
251 transcripts (pitfall #11). Split into lines **shell-aware** first (quote state
252 and backslash continuations honored, so quoted multiline strings don't
253 fragment), then shlex-walk each line — both Pattern A and the walker section
254 ship that splitter (`split_shell_lines`, production-proven in qlmanage-guard).
255 What even it cannot parse is a heredoc body (not quote syntax); when to accept
256 that residual is #11's call.
257- **But shlex isn't a silver bullet, and *what* you detect changes whether
258 fail-open is safe.** `shlex.split()` itself throws `ValueError` on an unbalanced
259 quote — a multi-line `git commit -m "…` message with a `#` or an unclosed quote
260 is the classic trigger. The `except ValueError: cmd.split()` fallback then
261 *allows*, which is right when you're detecting a **banned modifier** (does this
262 carry `--no-verify`? — missing it errs safe, Rule 1's direction), but
263 **dangerous when you're detecting whether the command IS your target at all**
264 (is this a `git commit`? — a ValueError there means the guard never recognises
265 the commit and silently doesn't fire; a real cross-domain commit shipped with no
266 confirmation dialog this way). For the *is-this-the-command* decision, prefer a
267 narrow **regex** (`git` and `commit` as separate words, any flag tokens between)
268 that's immune to multi-line-quote breakage; reserve the shlex walker for the
269 *command-position / modifier* checks where fail-open is the safe direction.
270 (The boundary: regex when the predicate is "is this a specific common command
271 at all" — `git commit`, `git push` — whose own message/arguments are what breaks
272 tokenizing; walker when the predicate is "is a *banned* command or modifier in
273 command position" — there the banned thing is rare and a ValueError fail-open
274 errs safe, Rule 1's direction.)
275
276### 2. Test with **bash -n + a real JSON event, end-to-end, BEFORE registering**
277
278**A corrupted or wrong-logic PreToolUse hook poisons the *entire* session** —
279every later Bash call gets truncated / duplicated / falsely-failed / looks
280hallucinated-executed, and you'll blame "the environment" when it's the hook you
281just installed. (2026-07-05: a `[^;&|]` regex broke in one edit, `;&` became a
282bash case-fallthrough token, poisoned half a session until `bash -n` found it.)
283"My tests passed at deploy" isn't enough — the file can corrupt in a *later* edit.
284
285Gate before registering ANY hook:
286```bash
287bash -n hook.sh # syntax
288printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<trigger case>"}}' | ./hook.sh; echo "exit=$?" # want 2
289printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<healthy lookalike>"}}'| ./hook.sh; echo "exit=$?" # want 0
290```
291Bundle the harness: [scripts/test_hook.sh](scripts/test_hook.sh) runs a whole
292table of trigger/allow cases. **Self-block gotcha:** once the hook is live in the
293session you cannot test it by putting the trigger string in your *own* Bash
294command — the live hook blocks your test command. Put the cases in a **script
295file** and run `bash test_hook.sh`; the outer command doesn't contain the
296trigger, so it isn't self-blocked.
297
298**Once a hook has caused one real incident (a false-block or a silent miss),
299solo re-reading the code is not enough** — a same-day rewrite of a Stop-hook
300guard was itself re-broken twice by the author while fixing the first bug (a
301quote inside a Python comment, invisible on re-read, only surfaced by running
302the actual failing JSON case). The escalation is a multi-lens agent-team
303review where every finding must be reproduced by *executing* a real payload
304against the live script, not by reading the code and agreeing — this is the
305general Counter Review methodology
306(skill-creator's `skill-development-methodology` reference, Phase 6), applied to
307a hook instead of a skill. In one such pass, 3 lenses (matching
308logic / shell-embedding safety / event-contract robustness) surfaced 13
309confirmed, independently-reproduced bugs and 1 finding whose own cited
310evidence turned out to be a hallucinated doc quote — caught only because the
311verifier was required to curl the raw source and grep for the exact string
312rather than trust the citation.
313
314### 3. SSOT + symlink so a reinstall can't silently disarm the guard
315
316Real script in a version-controlled dir, **symlinked** into the hooks dir Claude reads:
317```
318~/scripts/claude-hooks/<name>.sh # SSOT (this setup: a private git repo)
319~/.claude/hooks/<name>.sh # symlink → SSOT
320
321# install / recover:
322ln -s ~/scripts/claude-hooks/<name>.sh ~/.claude/hooks/<name>.sh
323```
324A `~/.claude` reinstall wipes the hooks dir; the symlink target survives, and
325recovery is one `ln -s`. A dangling symlink disables a Tier-0 guard with **zero
326signal** — which is why a SessionStart health check exists (rule 4; runnable
327skeleton: Pattern C in [references/hook_patterns.md](references/hook_patterns.md)).
328
329### 4. Registration is per-profile — converge ALL profiles, release via a HUMAN gate
330
331- **There are two registration surfaces, and the health check probably only
332 watches one.** Besides the profile settings below, a repo's own
333 `.claude/settings.json` (and `settings.local.json`) registers hooks for
334 sessions in that repo. Those live outside `~/.claude/hooks/`, so a guard-rail
335 check that walks that directory covers none of them — syntax, path,
336 `--selftest`, nothing. Two silent failures shipped in one such file and
337 survived two months under a SessionStart health check built to prevent
338 exactly that (#38, #39). Register project hooks by **absolute** path — a
339 relative one resolves against the session cwd and breaks the first time
340 someone starts `claude` from a subdirectory — and extend the health check to
341 walk up from the event's cwd for project settings.
342- A hook in `~/.claude/hooks/` protects **nothing** if the *active profile's*
343 `settings.json` doesn't call it. Multi-profile users ran with zero guards until
344 every profile was converged. Register in the **main** profile's settings
345 (`~/.claude/settings.json` in this setup; the Registration section of
346 [references/hook_patterns.md](references/hook_patterns.md) has the exact jsonc
347 shape) — PreToolUse → matcher `Bash` → your hook — then converge the rest (this setup
348 uses `sync-profile-settings.py --all`, owned by the `claude-switch-models-setup`
349 skill). A SessionStart health check greps each profile for the Tier-0 guards to
350 catch drift. Settings edits are picked up by the CLI's file watcher (official
351 hooks docs), so registration is live without a restart — confirm by watching
352 the guard fire on a safe probe, or at next session's health-check line.
353- **If the guard needs a release valve, make it a *human* gate, not an env var.**
354 A static `GUARD_OK=1` escape hatch is no gate — the model can set the env var
355 itself. Use a native macOS dialog (`osascript` — the model can't click);
356 refuse/cancel/timeout = hard NO; log every prompt/bypass to an audit file.
357 Pattern in [references/hook_patterns.md](references/hook_patterns.md).
358 - ⚠️ **`/dev/tty` is not a second channel — the docs say hooks cannot open it.**
359 This file used to prescribe a typed `YES` on `/dev/tty` alongside the dialog.
360 The official reference is explicit: hooks "run in their own session **without a
361 controlling terminal**", and "the hook process and any child processes **can't
362 open `/dev/tty`**" (`terminalSequence` is the documented replacement for writing
363 to it). So a "two-channel" gate built that way is one channel plus dead code,
364 and on a box with no GUI session the gate can never be approved by anyone.
365 Consistent with local observation, though read the boundary carefully: in one
366 setup's shared audit log — 1,801 entries, several guards writing to it, three of
367 which implement a tty channel — **360 lines carry a channel tag (236 dialog
368 confirmations, 124 declines or timeouts) and not one line of any kind names the
369 tty channel.** That means the tty branch was never *entered*, which on macOS is
370 what you would predict anyway, because the dialog answers first and short-
371 circuits it. So the log shows nothing here ever depended on tty; it is the
372 documentation, not this measurement, that establishes tty cannot work at all.
373 Keep the dialog; if you need a non-macOS gate,
374 you need a channel this file does not yet have a verified answer for.
375 - ⚠️ **A human gate that outlives the hook timeout fails OPEN.** Hook `command`
376 timeout defaults to 600s (30s on `UserPromptSubmit`), and a timed-out hook
377 **does not block the tool call** — so an unanswered dialog does not become a
378 "no", it becomes an allow. Bound your wait well under the timeout and make
379 no-answer resolve to block *yourself*, before the harness resolves it for you.
380 - The docs also carry an in-UI channel — PreToolUse `hookSpecificOutput`
381 `permissionDecision: "ask"`, which prompts through Claude Code's own interface.
382 It is worth knowing about, but **unverified here under `bypassPermissions` /
383 auto-accept**, which is precisely the mode a Tier-0 gate must survive; the
384 dialog is prescribed because it does not depend on permission mode.
385 - **Below Tier-0, where a model-serviceable escape *is* allowed, make it the
386 correct usage rather than a bypass flag.** The rule above is absolute for Tier-0
387 and does not bend here — this is about the correctness guards that fall short of
388 it, which still need a way out for the legitimate case the detector cannot
389 distinguish. The question is what you make that way out *be*. A `SKIP=1` /
390 `--force` env escape trains exactly the reflex rule 1 warns about, and under
391 deadline it is indistinguishable from a bypass. Prefer an escape that is **the
392 thing you wanted them to do anyway**, so taking it improves the command instead of
393 disarming the guard: `pipe-fallback-guard` exits 0 the moment the command mentions
394 `pipefail` / `PIPESTATUS` / `pipestatus`, because an author who wrote any of those
395 has already demonstrated they understand pipeline exit codes — the guard has
396 nothing left to teach them. The test to apply: *if someone takes my escape hatch,
397 is the resulting command better, or merely unblocked?* If the honest answer is
398 "merely unblocked", you have a bypass flag with a nicer name. Sibling principle
399 for the Tier-0 case, where the override exists only so the gate can be tested:
400 **the escape hatch may only make the gate stricter** — see the `GIT_GUARD_TEST`
401 discussion in [references/hook_patterns.md](references/hook_patterns.md).
402
403### 5. Decide the failure **direction**, and test *that* — not just the happy path
404
405Rule 1 ranked *detection-tuning* errors: given that the guard ran, false-blocking a
406healthy command beats missing a rare bad one, because a guard people must bypass
407gets bypassed reflexively. **This rule is about a different axis — the guard's
408machinery not running at all** — so "which is worse" is not being reversed here;
409the two rankings never meet. A tuning miss costs you one case; this costs you the
410guard, silently, on every input of that shape.
411
412The failure: the guard **cannot obtain the thing it judges on** — a parse throws, a path doesn't
413resolve, a dependency is missing, a subprocess times out — and the very
414`2>/dev/null || true` that stops the hook from crashing quietly converts *"I could
415not check"* into *"nothing to report."* The hook exits 0. **That output is identical
416to a real pass**, which is why this survives for weeks.
417
418So at every point where the hook *obtains* something (parses the command, reads
419staged files, queries a service), decide explicitly: **if this comes back empty, does
420that mean allow or block?** — and write the answer next to the branch. Fail-open is
421often right for a *modifier* check (does this carry `--no-verify`? missing it costs
422you one case). Fail-closed is usually right for the *is-this-even-the-thing* check
423(is this a cross-domain commit? an empty answer means the guard never fired at all).
424
425**Then test the direction, not the happy path**: hand it an unresolvable path or an
426unparseable command on purpose and assert it still does what you decided. A suite
427where every row passes *because the hook silently allowed everything* is
428indistinguishable from a suite that passes.
429
430**Read those results carefully — the same input has opposite correct answers for
431different guard classes.** Take `cd ~/no-such-dir && TRIGGER`:
432
433| Guard class | Judges on | Correct exit | Why |
434|---|---|---|---|
435| **Token matcher** (is this a banned command form?) | the command text alone | **2, block** | `TRIGGER` is right there in the text; an unresolvable `cd` doesn't make it not-a-trigger, and if the guard goes quiet here it will also go quiet on `cd ~/real-dir && TRIGGER` |
436| **State deriver** (does the repo's staged set span domains?) | state read from disk | **0, allow** | `cd` fails, `&&` short-circuits, no commit ever happens — there is nothing to guard |
437| **Termination-state reader** (has the remediation already happened?) | a receipt / counter file (rule 7) | **0, allow** — *when the state file IS the termination condition* | an unreadable receipt means the hook cannot know it already fired; failing closed here blocks forever with no remediation possible and no human-visible cause — that *is* the loop, and it is the one failure worse than a missed case. **Inverted sub-case — read this before copying the row:** when the state is only a **budget on top of an independent predicate** (the block still clears by doing the work), allow-on-unreadable **silently disables the entire hook** — one unwritable directory makes it mute for every input, forever, which is the worst failure shape there is. There, fail back to *the behavior before the budget existed* (keep evaluating the predicate), not to silence. **Tell the two apart with one question: if the state vanished, would remediation still be possible?** No → receipt case, allow. Yes → budget case, keep checking. Worked answers, so nobody has to re-derive them: rule 7's mechanism 2 (receipt) **and** mechanism 3 (per-session counter) are both **receipt case → allow** — mechanism 3 is deliberately blind to whether R happened, so its counter is the only exit and muting it strands the turn. The budget case is a counter layered on a predicate the user can still satisfy on its own |
438
439So decide which class your hook is *before* writing the row, and the harness's
440`unresolvable path` template row expects **2** because that template targets the
441token-matcher class. Getting this backwards produces a confident FAIL against a
442correct guard. For a state-deriving guard the failure you are hunting is: **the command would really
443have run and the guard didn't see it** — an unbalanced quote makes tokenizing throw,
444the fallback allows, and a genuine cross-domain commit ships with no dialog (rule 1's
445ValueError note). Ask of every allowed row: *would this command actually have done
446the thing?* If no, the allow is correct.
447
448Running this exact probe against a real state-deriving guard returned two allows on
449the first pass: one was correct (the short-circuit above) and one was a genuine
450fail-open. **The probe finds things; you still have to classify what it found** —
451which is why the class table above comes before the rows.
452
453Real case (2026-07-22): a scope guard read staged files via `git -C "$REPO_DIR"`
454with `REPO_DIR` parsed out of the **command text** — so `cd ~/repo && git commit`
455handed it a literal `~/repo`, `git -C` failed, staged came back empty, and the guard
456concluded "no cross-domain files, allow." Every cross-repo commit went unguarded and
457nothing ever looked wrong. Anatomy + the shared-library twist: pitfall #10.
458
459That parser has a second failure direction, and it is the nastier one. Once you
460add a fallback so it stops failing open, the fallback becomes correct for one
461reason and wrong for another — and both print the same line. `git push` (no
462explicit target) legitimately falls back to the event's `cwd`; `git -C "$R" push`
463*names* a target the hook cannot resolve, falls back to the same `cwd`, and then
464renders a confident ✅ about a different repository. Those two cases render
465byte-identically (measured, MD5-equal), so neither the hook nor the reader can
466tell the honest verdict from the misbound one. **A fallback value must carry the reason it was
467chosen**, and only "no explicit target" earns a verdict. Full anatomy, the
468confused-deputy framing, and why fixtures with literal paths never catch it:
469pitfall #28.
470
471### 6. Judge on a fact the world can answer — never on your own rendering, never on a naming habit
472
473Rules 1 and 5 are about *how* you match and *which way* you fail. This one is
474about **where the thing you match on came from**, and it has two failure shapes
475that both go silent:
476
477- **Never branch on a string you formatted for a human.** If the hook builds a
478 report — sorted, joined, truncated to the first N with a `(+M more)` tail — and
479 then pattern-matches its own decision against that report, the branch inherits
480 the rendering's losses. Items past the cutoff simply do not exist to it, so the
481 branch works on every small fixture and stops firing on exactly the large
482 sessions it was built for. Emit the machine fact on its own channel (one
483 untruncated `KINDS:a,b,c` line) and match *that*. A rendering is an output, not
484 a data source (pitfall #12).
485- **Prefer a checkable fact over a naming convention.** Classifying by path shape
486 (`/skills?/[^/]+/references/`) encodes one directory layout; a repo laid out any
487 other way is classified `None` — silently, forever. The fix is *not* to widen the
488 pattern, which trades a silent miss for machine-wide false positives (rule 1
489 forbids exactly that trade); it is to ask a question the filesystem can answer —
490 *is there a `SKILL.md` beside this `references/` directory?* Facts survive
491 layout changes; conventions do not. (When the candidate **is** a `SKILL.md`,
492 there is no sibling to ask about — classify by basename; #13 explains why that
493 is a spec-defined fact and not the naming habit this rule warns against.)
494 **A checkable fact can still be the wrong fact — anchor the question and filter by
495 type.** `test -f SKILL.md` is true in a downloads folder too, and one guard that walked
496 ancestors looking for exactly that swallowed an entire home directory, then told a real
497 session to load a skill named after it — a name that cannot exist (rule 9's incident).
498 Anchor to a sibling of a *specific* directory or to a known install path; an unanchored
499 ancestor walk is a convention wearing a fact's clothes.
500
501The tell for both: a branch that has never once fired in production while its
502tests are green. Print the raw pre-formatting classification and you will see
503which of the two you have.
504
505### 7. If the hook **demands remediation**, prove the loop terminates
506
507A hook that **blocks** (exit 2) until X is done — Stop hooks especially, since
508they re-fire on every subsequent stop — is not a check, it's a **feedback loop**.
509(A hook that merely *injects* a demand and exits 0 has no loop at all: nothing
510re-evaluates. That is mechanism 0 below, and it is the right default more often
511than people reach for it.)
512
513```
514condition T is true → hook demands remediation R → model performs R → T checked again
515```
516
517**Write the Loop Contract before the first cycle — for hook-enforced loops and
518agent-driven review / wait / retry loops alike:**
519
520```text
521LOOP KEY: immutable logical target / lineage + one failure axis
522FIRE T: the condition that starts another cycle
523REMEDIATION R: the exact action one cycle performs
524VARIANT V: the well-founded quantity that strictly decreases for this key
525BUDGET: maximum cycles, fixed before cycle 1
526SUCCESS EXIT: the observable that proves the axis is clear
527CAPPED EXIT: what is left blocked / unshipped / pending when the budget ends
528```
529
530No completed contract means no blocking Stop hook and no repeated reviewer or
531polling loop. Freeze the key before cycle 1. A remediation snapshot, commit, or
532reviewer name stays inside that same lineage and cannot mint a new budget. A
533new, unrelated finding is a **new key**: record it separately; it does not reset
534this loop's budget. A cycle that cannot name a new falsifying experiment or a
535smaller V adds no evidence and stops.
536
537For an **agent-driven independent-review loop**, the default budget is one
538initial review plus one narrowly scoped re-review after substantive fixes. A
539third reviewer is not automatic. If the re-review still reproduces a BLOCKER or
540MAJOR on the same axis, leave the hook unregistered / artifact unshipped, report
541the blocked state, and require a new user-authorized task whose Loop Contract
542declares its budget before cycle 1. An agent-declared budget cannot authorize
543itself. Inside that authorized task, name the concrete safety or business
544failure caused by stopping now; optional polish does not qualify.
545
546Filled review-loop example:
547
548```text
549LOOP KEY: <initial frozen commit>'s review lineage + termination-contract fidelity
550FIRE T: fresh review reports a same-axis BLOCKER / MAJOR
551REMEDIATION R: reproduce that finding, apply one bounded fix, run its narrow check
552VARIANT V: 2 - completed review cycles
553BUDGET: 2 cycles total (initial review + one re-review)
554SUCCESS EXIT: no same-axis BLOCKER / MAJOR
555CAPPED EXIT: artifact stays unregistered / unshipped; report remaining findings
556```
557
558Every repair descendant of the initial frozen commit remains in this key. The
559current snapshot changes so the reviewer can inspect the fix; the lineage and
560its remaining budget do not.
561
562Nothing mechanically enforces this hookless budget — it holds only while the
563agent follows the Skill. That limitation is why the capped exit must be visible
564and must never be reported as “completed.”
565
566**If completing R can make T true again, the loop does not converge.** Nothing
567errors, nothing crashes; it burns round after round until a human interrupts —
568which is what usually happens, because each round is a *complete* remediation
569cycle (dispatch, wait, adopt, edit
570
571…(truncated)