Skip to main content

claude-code-hooks

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".

معلومات المصدر

المستودع
daymade/claude-code-skills
آخر نشاط في المصدر
٣١ أكتوبر ٢٠٢٦ في ٠٨:٣٧
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
١٬٥١٠
التفرعات
٢٣١

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

مستكشف الملفات
6 ملفات

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub