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

Datos de origen

Repositorio
daymade/claude-code-skills
Última actividad en el origen
25 de octubre de 2026 a las 17:03
Idioma detectado de SKILL.md
inglés
Estrellas
1496
Forks
229

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Explorador de archivos
5 archivos

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
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
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub