- name
- claude-code-hooks
- description
- 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](references/hook_pitfalls.md#36-a-self-applied-review-rule-can-loop-without-any-hook).
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](https://code.claude.com/docs/en/hooks#exit-code-output) |
| **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, 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 **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 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, 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 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](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](references/hook_patterns.md).
## The skeleton (PreToolUse Bash guard)
```bash
#!/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 -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.
Check its limits: 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](references/hook_patterns.md#the-shlex-command-position-walker) 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](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
Ver en GitHub