Claude Code Hooks
Writes, tests, registers and debugs Claude Code hooks (PreToolUse, PostToolUse, SessionStart, Sto…
by daymade · MIT
The skill
Writes, tests, registers and debugs Claude Code hooks (PreToolUse, PostToolUse, SessionStart, Stop) that turn a rule the model keeps breaking into a hard gate. Use when the user wants to block or intercept a tool call, add a guard rail, fix a misfiring hook, or says 拦截 / 守卫 / 钩子 — including "make it stop doing X".
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 | normal permission flow unless JSON supplies allow/deny/ask | block the call even when JSON says allow; read valid JSON fields (v2.1.214+). JSON blocking reason or stderr → model |
without schema-valid JSON, another nonzero exit is a non-blocking error and the call proceeds; with valid JSON, the supported fields decide and that status is ignored. Use exit 0 for structured control. Follow the official exit-code contract |
| 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 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 types above) now lists many more blockable events, includingUserPromptSubmit,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.matcherselects 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 matchesNotebookEdit(anchor it^Edit$), whilemcp__memorymatches nothing because it is all exact-match characters and no tool is named exactly that (you wantmcp__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 bounded deployment and liveness probes 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. Run the full
regression battery after edits in the build/commit check, not at session start.
Invoke only owner-declared bounded offline liveness modes at startup; missing
modes leave logic unknown/incomplete, without falling back to
--selftest(build order step 4; pitfall #50). set -euo pipefailvsset -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-eand||-guard every risky command — with-eon, onegrepthat legitimately finds nothing kills the hook mid-way and the CLI surfaces a bareFailed with non-blocking status code(pitfall #8, Pattern E's shape); or (b) keep-eand addtrap 'exit 0' ERRso any failure still converts to exit 0 while-ekeeps guarding the plumbing (git-commit-headcheck's production shape, Pattern D). Either is correct; what you cannot do is-ealone with no trap and no||-guards. Rule of thumb:-efor hooks that decide; for hooks that report, drop-eor 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.promptfield always originated from a keystroke: a background task-notification's own report text can populate it too, and so can a teammate's or another session's message — no field in the stdin JSON marks the difference, only the wrapper tag the text opens with — #30. A rule like "the model must not invent a shorthand name for something it hasn't verified" belongs on Stop; put it onUserPromptSubmitinstead 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_messagevstranscript_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.additionalContextshows 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+ inputsbackground_tasks/session_cronslet 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).
Check these caveats before copying the case line — 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 -xrunsTRIGGER— bash splices the quotes away before execution — but the raw event text contains noTRIGGERsubstring, so a barecase "$INPUT" in *TRIGGER*)exits 0 and the guard never sees it. Measured: drop this exact line into the shipped Pattern A andTRIG''GER -xflips from exit 2 to exit 0, a full bypass — whilescripts/test_hook.shstill 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
iffield 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. Check its limits: it holds exactly one rule (no&&/||), it is only evaluated on tool events, and a hook that setsifon 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, sogrep -E "a|TRIGGER|b"gets split at the|inside the quoted regex,TRIGGERbecomes 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.shlexclass, not theshlex.split()function —split()only treats| ; & < >as separators when they are space-separated, sols|TRIGGER xtokenizes to['ls|TRIGGER', 'x']and your command-position check never seesTRIGGERat all (measured; the class withpunctuation_chars=Trueyields['ls', '|', 'TRIGGER', 'x']). Copy a shipped walker verbatim rather than reaching for the one-liner — but copy the one that passesscripts/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" fortimeout 5 TRIGGER,sudo -u root TRIGGERandnice -n 10 TRIGGER, while Pattern A's version catches all three. (Baresudo TRIGGERis 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 onwrapper-timeoutagainst 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, skippingVAR=valenv-assignment prefixes). Command-position walker in references/hook_patterns.md. - Corollary:
echo "…TRIGGER…",grep TRIGGER,# TRIGGER,man TRIGGERmust all pass. Your test set MUST include these mention-not-execute cases. - Corollary — exempt
gitwrite 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 - <<EOFwith a body quotingfoo|TRIGGERlandsTRIGGERin 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 isgit+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 islib-git-commit-detect's adjacency check). - Corollary — the walker is two-stage for a reason.
whitespace_split=Truetreats newlines as ordinary whitespace, so a multiline block (cd /x\ngit add\nTRIGGER -y) collapses into one segment headed bycdand 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 throwsValueErroron an unbalanced quote — a multi-linegit commit -m "…message with a#or an unclosed quote is the classic trigger. Theexcept 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 agit 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 (gitandcommitas 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
Run the end-to-end case in the verbatim form the registration will use —
if settings.json will say $HOME/.claude/hooks/x.sh, execute exactly that string.
Any interpreter-explicit form (bash hook.sh, python3 hook.py, and a hook's own
--selftest that re-invokes itself through bash) bypasses the exec bit, so it
structurally cannot catch a dead-on-arrival registration. Symptom index: a
repeating non-blocking PreToolUse hook error: … Permission denied after install
means a bare-path-registered hook lost its exec bit — the gate has been dead
since install; a green selftest is not evidence against this, because the
selftest never exercised the registered form.
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. Installer-owned source, installation and recovery
Keep the entrypoint and its rule modules in version-controlled source. Use the owning installer's current entry map for installation and recovery; a rule module does not need its own link or registration. When that installer uses symlinks, the layout is:
~/scripts/claude-hooks/<name>.sh # SSOT (this setup: a private git repo)
~/.claude/hooks/<name>.sh # symlink → SSOT
After a reinstall, restore only the entries the current installer declares active; do not reconstruct registrations from old filenames or reinstall retired aliases. Preserve independent rule state and authorization writers when migrating entries. Verify the consumed entrypoint, modules and effective settings, then exercise a real matching event in a fresh native session. A dangling link can silently disarm a guard; use bounded deployment/liveness checks (rule 4; 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(andsettings.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 startsclaudefrom 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'ssettings.jsondoesn't call it. Multi-profile users ran with zero guards until every profile was converged. Use the owning installer to update the main profile's settings (~/.claude/settings.jsonin this setup; the Registration section of references/hook_patterns.md has the exact jsonc shape). Update an existing engine's matcher only when its coverage changes; preserve each rule's narrower internal selector. In this setup the converger is registered as a SessionStart hook without arguments (sync-profile-settings.py, owned by theclaude-switch-models-setupskill), so the next profile to start a session carries the registration into every profile.--allis the explicit convergence mode; run it when immediate profile synchronization is needed, without treating that write as activation proof. A SessionStart health check greps each profile for the Tier-0 guards to catch drift. For new or changed registrations, verify a real matching event and debug evidence in a fresh native session. Claim current-session hot reload only after observing that session invoke the changed registration; a successful settings write or startup health line alone is insufficient. The current ConfigChange contract allows settings changes to be applied to a running session; it does not prove this particular registration has fired. - If the guard needs a release valve, make it a human gate, not an env var.
A static
GUARD_OK=1escape 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/ttyis not a second channel — the docs say hooks cannot open it. This file used to prescribe a typedYESon/dev/ttyalongside 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" (terminalSequenceis 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
commandtimeout defaults to 600s (30s onUserPromptSubmit), 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. - ⚠️ A human gate is only worth raising where its dialog can carry the decision. The person sees what the hook puts in the box and nothing else — so on a path where the hook's own evidence is empty (it reads state before the command runs, and this command creates that state), the dialog is empty too, and what comes back is a reflexive click, not a judgement. On those paths block mechanically and tell the model how to restructure the command so the state becomes observable; keep the dialog for paths that can name the target, the command and the objects. Whatever model-authored text the dialog shows (command, paths) gets whitespace-folded first. Symptom, fix and the recorder-stub calibration: references/hook_pitfalls.md #44.
- ⚠️ The remedy you print instead has to be executable — prove it by running it.
Where the hook blocks mechanically, the
stderrinstruction is the whole product of that interception, and a string folded for display (above) is not one the model can paste back. Test each printed remedy the only way that counts: assemble the exact bytes, run them, drive the same event through the hook again, assert it now passes. That some other parser decodes the string is not evidence — the checkpoint is the gate's own tokenizer. references/hook_pitfalls.md #45. - The docs also carry an in-UI channel — PreToolUse
hookSpecificOutputpermissionDecision: "ask", which prompts through Claude Code's own interface. It is worth knowing about, but unverified here underbypassPermissions/ 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/--forceenv 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-guardexits 0 the moment the command mentionspipefail/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 theGIT_GUARD_TESTdiscussion 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 untruncatedKINDS:a,b,cline) 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 classifiedNone— 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 aSKILL.mdbeside thisreferences/directory? Facts survive layout changes; conventions do not. (When the candidate is aSKILL.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.mdis 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 unanchore
Use this skill
Connect your agent to the Skill Store MCP server and ask for it by ref: daymade/claude-code-hooks-28049998188.
Details
- Publisher
- daymade
- Category
- AI & Agents › Tool & MCP integration
- License
- MIT
- Updated
- 2026-10-19
References
More from daymade
- App Evolver Improves an existing application using its code, product contract, authorized work history and co…
- Auto Repo Setup Diagnoses and repairs repository setup and guarded Git workflows for Claude Code or Codex — envir…
- Bilibili Source Fetches real, citable Bilibili (B站) video data — stats, metadata, tags, and full danmaku text — v…
- Cli Demo Generator Generates professional animated CLI demos as GIFs using VHS terminal recordings.