CCLearn

Hook footguns — the gate that is open and the gate you cannot get past

verified against Claude Code 2.1.261 · 2026-09-0914 min

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.

CauseHow you confirm itWhere it is taught
Matcher compared as an exact string, or cased wrong/hooks shows the matcher you actually wrote9.1
The event never sees the thing — an @ reference, a sed -i, a failure on PostToolUseReproduce with the tool you meant to catch9.1
if set on a non-tool event, which disables the hook entirelyRead the handler in /hooks9.1
Script path wrong, or not executableFailed with non-blocking status code: notice in the transcript9.2
Workspace trust not yet accepted for this folderYou have not seen the trust dialog for this checkoutHere
disableAllHooks left true somewhere in the settings chain/hooks is your evidence for what loadedHere
The file watcher missed your editThe hook is absent from /hooks entirelyHere

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

WhyTaught in
exit 1, the conventional Unix failure code, is a non-blocking error9.2
The script could not start, so the shell exited 1279.2
Valid JSON that fails schema validation, even on exit 09.2
A timed-out command, http or mcp_tool hook — the call continues9.3
async: true, which makes permissionDecision inert9.3
The event cannot block at allHere

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:

EventExit 2 does what instead
SessionStartShows stderr to the user only
SubagentStartShows stderr to the user only
PostToolUseShows stderr to Claude; the tool already ran
PostToolUseFailureShows stderr to Claude; the tool already failed
PermissionDeniedExit code and stderr ignored — the denial already happened
NotificationExit code and stderr ignored
MessageDisplayThe 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:

PostToolUse hooks 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:

EventExit 2 does
UserPromptSubmitBlocks 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:

bash
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?  # Check the exit code

Path 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 $PWD looks 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:

bash
FILE_PATH="${FILE_PATH//\\//}"
case "$FILE_PATH" in
  */infra/*) echo "infra changes go through review" >&2; exit 2 ;;
esac

That 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 seeWhat happened
NothingSuccessful run — "to confirm a hook ran, check for its effect"
The hook's feedbackBlocking error: the JSON's reason, or the stderr
A hook error noticeNon-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 --debug flag doesn't print to the terminal. — Hooks reference

bash
claude --debug-file /tmp/claude.log
tail -f /tmp/claude.log          # in a second terminal

Mid-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:

text
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-ran

success, 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:

  1. Pipe sample JSON into the script by hand and check echo $? is 2.
  2. Start with --debug-file and do the forbidden thing on purpose.
  3. Confirm the transcript shows the block, with the message you wrote.
  4. 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

  1. A teammate's PreToolUse hook denies writes to infra/. 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 answer

    The path comparison is one character wide from working. On Windows tool_input.file_path arrives with backslash separators, even when the hook runs under Git Bash where $PWD looks 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 refusal exit 2, because the obvious exit 1 would let the write through on the Mac too.

  2. You add a SessionStart hook 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 answer

    The session starts. SessionStart is one of the events that cannot block: exit 2 there shows stderr to the user only, rendered as a hook error notice, and "Claude doesn't see it, and the session or subagent proceeds." You have written yourself a note, not refused anything. SubagentStart behaves the same way, and so does PostModelSwitch, 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 on PreToolUse, not on an event whose exit 2 can only write you a note after the fact.

  3. 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 answer

    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 if field on a non-tool event disabled the handler entirely, workspace trust has not been accepted for this folder, a "disableAllHooks": true is 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 by success. Do not reach for /hooks here — 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.

  4. A Stop hook is meant to keep Claude working until the suite passes. You set CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=0 because eight iterations was not enough. What have you removed, and what must be in the script before that is safe?

    Show answer

    You removed the backstop that ends a runaway loop. Claude Code normally overrides a Stop hook and ends the turn after 8 consecutive blocks; CLAUDE_CODE_STOP_HOOK_BLOCK_CAP moves that number, and 0 switches 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_active guard 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.

Sources