Hook footguns — the gate that is open and the gate you cannot get past
By the end you can
- Diagnose a hook that is configured and never fires
- Recognise the ways a gate you believe in is silently open
- Read the debug log instead of assuming the hook ran
- Escape a Stop hook that will not let a turn end
- Avoid the slow UserPromptSubmit that stalls every turn
- Keep a path check from being defeated by a separator or a traversal
Your PreToolUse hook has been in .claude/settings.json for a week. /hooks
lists it under the right event with the right matcher. Nothing bad has happened.
You cannot tell whether that is because the gate is holding or because it has never once run. From outside, those are the same observation: nothing happened. Lesson 5.5 called this the green that isn't, and a hook is its purest form — the only thing you configure whose success is indistinguishable from its absence.
Lesson 9.1 picked the event. Lesson 9.2 established what blocks. This lesson is the list of ways both of those can be right and the gate still be open, plus the two ways a gate shuts so hard you cannot get past it.
The gate that is not there
The handler never ran at all. Seven causes, and only two of them produce anything you would notice without looking.
| Cause | How you confirm it | Where it is taught |
|---|---|---|
| Matcher compared as an exact string, or cased wrong | /hooks shows the matcher you actually wrote | 9.1 |
The event never sees the thing — an @ reference, a sed -i, a failure on PostToolUse | Reproduce with the tool you meant to catch | 9.1 |
if set on a non-tool event, which disables the hook entirely | Read the handler in /hooks | 9.1 |
| Script path wrong, or not executable | Failed with non-blocking status code: notice in the transcript | 9.2 |
| Workspace trust not yet accepted for this folder | You have not seen the trust dialog for this checkout | Here |
disableAllHooks left true somewhere in the settings chain | /hooks is your evidence for what loaded | Here |
| The file watcher missed your edit | The hook is absent from /hooks entirely | Here |
"Matchers are case-sensitive" is the cheapest of these to hit and the cheapest to rule out. The last three are worth their own sentences.
Trust gates hooks, including your own. Before it runs any hook from a settings file, Claude Code checks whether the folder is trusted:
Interactive session: Claude Code holds back hooks from every settings file, including your own
~/.claude/settings.json, until you accept the workspace trust dialog for the folder, or for a parent directory whose trust extends to it. — Hooks reference
So a hook that works in a repository you trusted does nothing in the one you
cloned this morning, until you accept the dialog there. Note the reach, because
the obvious guess is too narrow: trust is keyed on the git repository root and a
worktree uses the main checkout's root, so a worktree of a trusted repository is
already covered. What is not covered is a repository nested inside a trusted one
— a submodule — which gets its own dialog. The -p case is the mirror image
and much worse — no
dialog, the folder treated as trusted — which is Lesson 9.7's problem.
disableAllHooks resolves after precedence, and the direction surprises
people. A project's "disableAllHooks": false overrides a true in your user
settings: the project can switch your hooks back on. Managed hooks are the
exception; nothing outside managed settings can disable those.
A settings edit that never landed looks exactly like a hook that never
matched. Edits are normally picked up by the file watcher; when they are not,
restart the session. The tell is that /hooks does not list the hook at all
rather than listing it and staying quiet.
The gate that is there and does not block
Worse category. The handler ran, did its job, decided to refuse — and the action happened anyway.
| Why | Taught in |
|---|---|
exit 1, the conventional Unix failure code, is a non-blocking error | 9.2 |
| The script could not start, so the shell exited 127 | 9.2 |
| Valid JSON that fails schema validation, even on exit 0 | 9.2 |
A timed-out command, http or mcp_tool hook — the call continues | 9.3 |
async: true, which makes permissionDecision inert | 9.3 |
| The event cannot block at all | Here |
That last row is the one that has no error message anywhere. "Can this event block" is a per-event fact, and several events people reach for cannot:
| Event | Exit 2 does what instead |
|---|---|
SessionStart | Shows stderr to the user only |
SubagentStart | Shows stderr to the user only |
PostToolUse | Shows stderr to Claude; the tool already ran |
PostToolUseFailure | Shows stderr to Claude; the tool already failed |
PermissionDenied | Exit code and stderr ignored — the denial already happened |
Notification | Exit code and stderr ignored |
MessageDisplay | The original text is displayed |
For the first two — and for PostModelSwitch, an event that arrived in
v2.1.251 — exit 2's stderr is rendered as a hook error notice,
"Claude doesn't see it, and the session or subagent proceeds."
A SessionStart hook that exits 2 because the branch is dirty has not refused
to start the session. It has written you a note.
And the after-the-fact events cannot repair anything either:
PostToolUsehooks can't undo actions since the tool has already executed. — Automate actions with hooks
The blocking hook you cannot get past
The opposite failure, and it arrives in a hurry.
Lesson 5.2 had you write the stop_hook_active guard into a Stop hook. This is
what it guards against: a hook that blocks the end of a turn on a condition
Claude cannot resolve, so every turn ends by being told to continue. Claude Code
has a backstop — "Claude Code overrides the hook and ends the turn after 8
consecutive blocks" — and CLAUDE_CODE_STOP_HOOK_BLOCK_CAP moves it, with 0
switching the cap off entirely. Setting it to 0 on a hook with no
stop_hook_active guard is how you build a session that will not stop.
UserPromptSubmit has a smaller version with the same shape:
| Event | Exit 2 does |
|---|---|
UserPromptSubmit | Blocks prompt processing and erases the prompt |
Your carefully typed prompt is gone, not returned to the input for editing. A validation hook on this event should be sure about what it rejects.
The escape hatch is blunter than people expect:
To turn hooks off for one run whatever the project's settings say, pass
--settings '{"disableAllHooks": true}', which takes precedence over project and local settings. There is no way to disable an individual hook while keeping it in the configuration. — Hooks reference
All of them, or none of them. Which is a design argument, not just a fact: a
hook that is wrong often enough to want a switch for should be narrowed with a
matcher or an if filter, because the only switch available takes your other
gates down with it.
The slow UserPromptSubmit
Because this hook runs before every prompt and blocks model processing until it completes, a stuck hook stalls the session. — Hooks reference
Lesson 9.3's table shows the lowered 30-second default on this event, which caps the damage without removing it: 30 seconds of dead terminal, before every prompt, is a session nobody will keep working in.
The reflex is to raise timeout. The move that works is to change cadence,
using Lesson 9.1's table — something that changes daily belongs on
SessionStart, paid once; a working-tree scan belongs on Stop, paid once per
turn and after the changes exist. async: true looks like the third option and
is a trap on any hook with a decision to make; 9.3 has the reason.
The hook that eats its own error
A hook that exits 0 has no channel that reaches your screen.
Stderr from a hook that exits 0 goes to the debug log only, never the transcript, and Claude never sees it. — Hooks reference
Both JSON failure modes announce themselves — a validation message when the
stdout parsed and failed the schema, a parse message when it looked like an
object and was not valid JSON, "both happen even on exit 0." The second of
those notices arrived in v2.1.248. Before it, stdout that started with {
and did not parse was passed on as plain text — which, on every event outside the
handful that add plain text as context, means the debug log and nowhere else. A
stray backslash in a path inside your reason string produced no notice at all.
It produced a hook that looked like it did nothing.
The docs' fix for the parse failure is to build the payload with jq or another
JSON encoder rather than string concatenation, so the quotes and backslashes
inside your values get escaped. The notice is a backstop behind that advice now,
which makes the encoder cheap insurance rather than the only thing standing
between you and a symptomless failure. The encoder does nothing for the
validation failure, which is a wrong-shape problem: well-formed JSON with fields
the event's schema does not accept — and that one does surface.
Then there is the failure that announces nothing on any version, and it is the reason this lesson exists.
The same failure hides behind a missing dependency. jq: command not found on
stderr, exit 127, exit 0 from the rest of the pipeline — a hook that has been
blocking nothing since the day you moved machines. Exit 0 is not an approval
either, as 9.2 laboured: on PreToolUse it means no objection, and the normal
permission flow still decides. Test the script the way the docs suggest, outside
Claude Code, before you trust it:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $? # Check the exit codePath checks a separator defeats
One thing here works better than you would guess, and one thing works worse.
The good news: for Write, Edit and Read, tool_input.file_path arrives
absolute. "Claude Code expands ~ and relative paths before hooks run, so a
hook that matches on paths can't be bypassed via ~ or a relative spelling of
the same path." Nobody sneaks ../../infra/prod.tf past a check on
/infra/.
The bad news is one character wide:
On Windows, the path arrives with backslash separators, even when your hook runs under Git Bash where
$PWDlooks like/c/project. A comparison written with forward slashes, such as a/src/check, never matches a backslash path, and the tool call proceeds as if the hook had nothing to block. — Hooks reference
A gate that holds on your laptop and is open on every Windows machine in the team, with no error on either. Normalise first, then match a path segment rather than anchoring, since the path is absolute:
FILE_PATH="${FILE_PATH//\\//}"
case "$FILE_PATH" in
*/infra/*) echo "infra changes go through review" >&2; exit 2 ;;
esacThat exit 2 is not decoration — see 9.2 for why the obvious exit 1 would
have let the write through.
Lesson 9.3's hygiene list — quote every variable, check for .., use absolute
paths, skip .env and keys — earns its place here because "command hooks
execute shell commands with your full user permissions". One item it does not
cover: the environment a hook inherits carries the parent's credentials unless
you set CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1.
Proving it: /hooks, the transcript notice, and --debug-file
Three instruments, in ascending order of effort.
/hooks answers "is it configured and where did it come from" — event,
matcher, type, source file, full command. It does not answer "did it run".
The transcript, opened with Ctrl+O, has exactly three outcomes:
| What you see | What happened |
|---|---|
| Nothing | Successful run — "to confirm a hook ran, check for its effect" |
| The hook's feedback | Blocking error: the JSON's reason, or the stderr |
A hook error notice | Non-blocking error, and the action proceeded |
Read the first row again. Silence is the display for success and the display for the profile-banner failure above — and, before v2.1.248, it was the display for a JSON parse failure too. The transcript can tell you a hook blocked; it cannot tell you a hook ran.
The debug log does, and it is not on your screen. The docs' other route to the same answer — "check for its effect, like a reformatted file" — needs a hook that leaves an effect, which a gate that decided not to block does not.
Hook execution details, including which hooks matched, their exit codes, and full stdout and stderr, are written to the debug log file. […] The
--debugflag doesn't print to the terminal. — Hooks reference
claude --debug-file /tmp/claude.log
tail -f /tmp/claude.log # in a second terminalMid-session, /debug turns logging on and tells you the path. The docs' own
example of what a working PostToolUse hook looks like in that log is also a
worked example of the silent failure:
2026-07-19T02:03:24.382Z [DEBUG] Hook output does not start with {, treating as plain text
2026-07-19T02:03:24.382Z [DEBUG] Hook PostToolUse:Write (PostToolUse) success:
hook-ransuccess, and the decision was thrown away. For matcher-level detail — which
patterns were tested, how many matched — set
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose.
The acceptance test
A gate is not installed when the JSON is valid. It is installed when you have watched it refuse something. Four minutes, once, per hook:
- Pipe sample JSON into the script by hand and check
echo $?is 2. - Start with
--debug-fileand do the forbidden thing on purpose. - Confirm the transcript shows the block, with the message you wrote.
- Confirm the debug log names your handler, and that stdout is what you think it is.
Every failure in this lesson is caught by step 2 or step 4. None of them is caught by reading the settings file again.
Checkpoint
-
A teammate's
PreToolUsehook denies writes toinfra/. It works on their Mac. On a colleague's Windows machine the writes go through, with no hook error in the transcript and no message anywhere. What is wrong, and why is there nothing to see?Show answerAnswer
The path comparison is one character wide from working. On Windows
tool_input.file_patharrives with backslash separators, even when the hook runs under Git Bash where$PWDlooks like/c/project, so a check written with forward slashes — a/infra/test — never matches a backslash path, and the tool call proceeds as if the hook had nothing to block.There is nothing to see because nothing failed. The hook ran, found no match, and exited 0 — and stderr from a hook that exits 0 goes to the debug log only, never the transcript, and Claude never sees it. Silence is the transcript's display for a successful run, which is why it cannot tell you a hook ran. Fix it by normalising the separators first and then matching a path segment rather than anchoring, since the path arrives absolute with
~and relative spellings already expanded — and make the refusalexit 2, because the obviousexit 1would let the write through on the Mac too. -
You add a
SessionStarthook that exits 2 with "working tree is dirty" on stderr, expecting the session to refuse to start. What actually happens, who sees the message, and where would that check have to live to have teeth?Show answerAnswer
The session starts.
SessionStartis one of the events that cannot block: exit 2 there shows stderr to the user only, rendered as ahook errornotice, and "Claude doesn't see it, and the session or subagent proceeds." You have written yourself a note, not refused anything.SubagentStartbehaves the same way, and so doesPostModelSwitch, the event that arrived in v2.1.251.So the message reaches you and nobody else — not Claude, and not the tool call you were trying to prevent. For the check to have teeth it has to sit where the thing can still be stopped:
PreToolUse, the event that blocks a tool call before it happens. That is the general rule this lesson keeps returning to — if the thing must not happen, it belongs onPreToolUse, not on an event whose exit 2 can only write you a note after the fact. -
Your hook prints exactly one valid JSON object and exits 0. The transcript is silent and the tool call happened. Name two different causes that produce precisely that, and the one command that distinguishes them.
Show answerAnswer
Two categories, and they look identical from outside. The hook never ran: the matcher was compared as an exact string or cased wrong, the event never saw the thing you meant to catch, an
iffield on a non-tool event disabled the handler entirely, workspace trust has not been accepted for this folder, a"disableAllHooks": trueis sitting somewhere in the settings chain, or the file watcher missed your settings edit. The hook ran and its output was thrown away: something else printed to stdout first — a shell profile that echoes a banner unconditionally is the classic — so the combined output no longer starts with{, all of stdout is treated as plain text, and on exit 0 nothing is reported in the transcript at all.The command that separates them is
claude --debug-file /tmp/claude.log, then tailing that file: the debug log records which hooks matched, their exit codes, and full stdout and stderr. A hook that never ran is simply absent; a hook whose JSON was discarded shows the docs' own line,Hook output does not start with {, treating as plain text, followed bysuccess. Do not reach for/hookshere — it answers "is it configured and where did it come from", never "did it run". Note also what the premise rules out: a JSON object that fails schema validation, and since v2.1.248 one that fails to parse, both raise a notice even on exit 0, so neither of those is silent on a current build. -
A
Stophook is meant to keep Claude working until the suite passes. You setCLAUDE_CODE_STOP_HOOK_BLOCK_CAP=0because eight iterations was not enough. What have you removed, and what must be in the script before that is safe?Show answerAnswer
You removed the backstop that ends a runaway loop. Claude Code normally overrides a
Stophook and ends the turn after 8 consecutive blocks;CLAUDE_CODE_STOP_HOOK_BLOCK_CAPmoves that number, and0switches the cap off entirely — so if the suite never passes, every turn ends by being told to continue, with nothing left to stop it.Before that is safe the script has to carry the
stop_hook_activeguard from Lesson 5.2, so the hook stops blocking once it is already the reason the turn continued. Know the escape hatch too, because it is blunter than people expect:--settings '{"disableAllHooks": true}'turns hooks off for one run and outranks project and local settings, and there is no way to disable an individual hook while keeping it in the configuration — so the switch you reach for takes your other gates down with it.