Chapter 4 of 9. A hook that never fires looks exactly like a hook that passed. Prerequisite: Chapter 3. You should already have a
hooksblock in a settings file, a script on disk, and a working idea of whatexit 2is meant to do.
Everything below was checked against Claude Code 2.1.209 on 2026-07-14. Hook semantics are the part of Claude Code most likely to move under you, so treat every exit-code and JSON claim here as version-pinned. Re-read the reference before you trust an old blog post. Including this one.
While writing this chapter I ran one command against my own machine.
grep -c preflight ~/.claude/settings.json
# 0
Two of the scripts in ~/.claude/hooks/ are pre-flight blockers. archon-preflight.py blocks any Bash command containing archon workflow run, so that I have to look at my session quota before spawning another agent run; the incident behind it is the quota article. agent-audit-preflight.py blocks a git commit whose staged files span both code and tests, closing the gap where the same agent writes the implementation and the tests that bless it. My global CLAUDE.md describes both as a "Global PreToolUse hook". Between them they carry 41 passing tests. Neither is registered in the only user-level settings file on the machine.
They had not fired in weeks. Nothing told me. A hook is silent when it passes. It is also silent when it is absent. From the chair, those two are the same thing.
The five places a hook dies, in the order to check them
Work down this ladder. The causes are ordered by how often they are the answer, not by how interesting they are.
- Registration
The script exists, and no settings file points at it. Nothing runs.
- Matcher
The registration is on the right event. The pattern still never matches the tool name.
- Payload
The field the script reads is not the field the event sends.
- Exit code
Exit 1 prints the refusal. Claude Code proceeds anyway.
- Fail-open
An inner call throws, the
exceptswallows it, and you go through.
Five rungs, five different culprits: the settings file, the matcher, the payload field, the exit code, the exception handler. Only two of them live in the script you are staring at.
Is the hook registered at all? Start with /hooks
Claude Code ships a browser for exactly this. Per the hooks guide, typing /hooks opens "a list of all available hook events, with a count next to each event that has hooks configured," and selecting one "shows its details: the event, matcher, type, source file, and command."
Read that count first, before you read your script. The count is the registration. Registration is the thing that was wrong on my machine for weeks.
The same guide is blunt about the tool's limits: "The /hooks menu is read-only. To add, modify, or remove hooks, edit your settings JSON directly or ask Claude to make the change." So it is a viewer over your settings files. It is not a viewer over your hooks directory. A tested, executable, correct Python file in ~/.claude/hooks/ with no entry in a hooks block has no event. A hook with no event is a Python file.
Here is the PreToolUse block from my ~/.claude/settings.json, trimmed to the Bash matcher, with the guard script renamed for publication. The real file carries absolute paths; they are shown as ~/ here.
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'gh pr create|git push.*main|npm publish|bun publish' && echo '⚠️ PR/PUBLISH DETECTED — ensure build-validator passed first' || true"
},
{
"type": "command",
"command": "python3 ~/.claude/hooks/screen-lock-guard.py"
}
]
}
]
An inline grep that prints a warning, then one guard script. That is my entire PreToolUse surface on Bash. The two preflights are not in that list. They were never in that list.
If /hooks shows a count you did not expect, check which settings file you edited. Hooks live under the hooks key of a settings file. Four files can hold one: managed, user (~/.claude/settings.json), project (.claude/settings.json), local (.claude/settings.local.json). The settings docs give the precedence. They also rule out the reflex fix: "Claude Code watches your settings files and reloads them when they change, so edits to most keys apply to the running session without a restart." Restarting will not fix a hook you registered in the wrong file.
The matcher is a tool-name regex, and MCP tools carry a prefix
What the matcher filters on depends on the event you attached it to. Tool events match the string against the tool name (Bash, Write, Edit). SessionStart filters on where the session came from instead. The hooks reference gives those values: startup, resume, clear, compact. Stop takes no matcher at all and fires on every occurrence. Guess wrong about which regime you are in and your pattern is decoration.
Two failures dominate. The first is the wrong event entirely. PostToolUse cannot block, and the reference is explicit that it shows stderr to Claude after the tool has already run. If your real complaint is "my hook fires but nothing stops," you may have written a perfectly good hook on an event that has no veto.
The second is MCP. Tools from a server are callable as mcp__<server>__<tool>, and a plugin-bundled server produces the longer mcp__plugin_<plugin-name>_<server-name>__<tool-name>. The MCP page states the consequence directly: "A hook matcher written against the bare server key, such as mcp__database-tools__.*, never fires for a plugin-bundled server." That namespacing earns its ugliness. Per the MCP architecture docs, the host merges tools from every connected server into a single registry, so the prefix is what keeps two servers' search tools apart. Whatever that registry calls the tool is what your matcher has to match.
Run the hook by hand: it takes JSON on stdin
Once registration and matcher are ruled out, stop guessing and drive the script directly. A hook reads a JSON payload on stdin. For PreToolUse that payload carries tool_name and tool_input, so a Bash gate is reading tool_input.command.
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"archon workflow run fix-issue"}}' \
| python3 ~/.claude/hooks/archon-preflight.py
echo "exit=$?"
You want exit=2. If you get exit=0 and no output, your matcher was never the problem and your pattern is. Here is the one that decides:
_ARCHON_RE = re.compile(r"\barchon\b(?:\s+\S+){0,10}?\s+workflow\s+(?:run|resume)\b")
The {0,10} bound is ReDoS hardening. The source comment says so. It also means a command with eleven flags between archon and workflow sails past untouched. That is the class of bug you only find by feeding the hook the exact string that got through, not the string you wish you had tested.
Exit 1 is not exit 2
This is the single highest-yield line in the hooks reference, and it is worth quoting whole:
"For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use
exit 2."
Every instinct you have from shell scripting says a failing gate exits 1. A gate that exits 1 here prints its refusal. Then it watches the command run. This is the tail of archon-preflight.py:
print(" To proceed: re-run with BBBRAIN_PREFLIGHT_BYPASS=1 in env.", file=sys.stderr)
print(" To abort: don't re-run.", file=sys.stderr)
print("═════════════════════════", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())
Two rules follow from that contract. Return 2 to block and 0 to allow, and nothing in between. Then print your reason to stderr, because on exit 2 Claude Code "Ignores stdout and any JSON in it." A hook that explains itself on stdout and exits 2 blocks the command with no explanation attached. The model then invents one.
Pick one protocol per hook: exit codes or JSON
The reference leaves no room here: "You must choose one approach per hook, not both: either use exit codes alone for signaling, or exit 0 and print JSON for structured control. Claude Code only processes JSON on exit 0."
One of my guards takes the JSON route. It blocks synthetic keystrokes while the Mac's screen is locked, because on 2026-07-12 a keystroke drive typed a filename and a Return into the login password field.
json.dump({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"SCREEN IS LOCKED — synthetic keystrokes would be typed into the LOGIN "
"PASSWORD FIELD (this happened on 2026-07-12).\n\n"
"Do NOT retry, and do NOT work around it with a different tool. Stop and "
"tell Cj the Mac needs unlocking, then resume.\n\n"
"Note: checking whether the app is 'frontmost' does NOT detect the lock. "
"The only reliable test is CGSSessionScreenIsLocked via "
"`ioreg -n Root -d1 -a | plutil -extract IOConsoleUsers json -o - -`."
),
}
}, sys.stdout)
return 0
It denies, and it returns 0. Both halves are load-bearing. "Tighten" that hook by also returning 2 and the JSON is discarded. The structured permissionDecision never lands. The model gets an empty stderr where a deny reason should have told it not to route around the block. There are two correct designs. The hybrid is what you write when you are pattern-matching from a half-remembered example.
The gate that fails open
Every blocking gate in my estate fails open. Every one. archon-preflight.py wraps its stdin parse in except Exception: return 0. The audit gate is worse, and quietly so:
def _staged_files(cwd: str) -> list[str]:
try:
result = subprocess.run(
["git", "-C", cwd, "diff", "--cached", "--name-only"],
capture_output=True, text=True, timeout=5, check=False,
)
except Exception:
return []
if result.returncode != 0:
return []
return [line for line in result.stdout.splitlines() if line.strip()]
An empty list means "no staged files," which means "not a code-plus-tests commit," which means allow. Several unrelated conditions produce that same empty list and that same silent pass: a five-second timeout on a large repo, a cwd that is not a git worktree, a git binary missing from the hook's PATH.
That is deliberate. A sibling hook in my git-hooks repo states the calculus in a comment, and I stand by it: "the cost of a missed tick is recoverable; the cost of an unbypassable hook isn't." The debugging consequence is what you have to internalise. Fail-open hooks make silence meaningless. A hook that never speaks is either working perfectly or dead. You cannot tell which without going and looking.
How the harness spawns a hook, and where your bypass token actually lands
One last trap, and it is the one that will convince you a working hook is broken. The harness spawns the hook process before the shell ever evaluates the command string. An inline VAR=1 command prefix therefore never puts VAR in the hook's environment. What it does do is put the literal text VAR=1 inside tool_input.command, where the hook can read it as data. The comment in archon-preflight.py says it in full:
# Second bypass path: the literal string `BBBRAIN_PREFLIGHT_BYPASS=1`
# in the command itself. This is what makes `BBBRAIN_PREFLIGHT_BYPASS=1
# archon workflow run ...` work — the literal IS present in the cmd
# string the hook receives, even though the env-var assignment doesn't
# actually reach this Python process.
if "BBBRAIN_PREFLIGHT_BYPASS=1" in cmd:
return 0
Three hooks in my estate accept their bypass token in either form, as a real environment variable or as a literal in the command text. A fourth, a guard registered on Write, checks os.environ and nothing else. Prefix its bypass inline and nothing happens. The guard blocks. You go and read the script convinced it is broken. It is not. Your escape hatch was.
The transferable rule
Twenty-one tests prove archon-preflight.py blocks what it should block. Twenty prove agent-audit-preflight.py catches a code-and-tests commit. Forty-one green tests, and not one of them proves either hook is connected to an event, because the tests exercise the script and the wiring lives somewhere the script cannot see.
So test the wiring, not just the code. Keep one command per blocking hook that must be refused. Run it after any settings change: feed in the string that should be caught, then check the exit code that comes back. Open the /hooks browser afterwards and confirm the count next to the event is the number you think it is. Registration is state, not code. Nothing on your machine type-checks state.
A rule in CLAUDE.md is advisory; Anthropic's own docs say so. A rule enforced by a hook is deterministic. The third case has no name, which is why it goes unnoticed: a hook that is written, tested, and never registered. It feels like enforcement and is nothing at all. Chapter 3 argued that hooks are not voluntary. Registration is. Open /hooks and read the count before you trust another one.
Build a wiring smoke test for your own hooks
The chapter's 41 green tests proved two scripts worked and proved nothing about whether they were connected to an event. Write a smoke-test script for your own estate: for every blocking hook you believe is live, drive it by hand with the exact stdin JSON that must be refused, then separately confirm the script is registered under a hooks key in a settings file. Wire it to run after any settings change.
Expected behaviour
- Each blocking hook receives a should-be-refused PreToolUse JSON payload on stdin, and the script asserts exit 2, or exit 0 with a permissionDecision of deny for hooks on the JSON protocol
- Every file in your hooks directory is checked against the hooks blocks of all four settings scopes, and any script with no registration is printed as a file without an event
- A registered hook whose pattern fails to catch the test string is reported separately from an unregistered one, so rung one and rung two of the ladder stay distinguishable in the output
- The payload strings are taken from real commands that previously got through, in the spirit of the eleven-flag ReDoS gap, rather than strings you wish you had tested
PROVE IT Unregister one hook by editing the settings file, run the smoke test, and paste the output line that names the now-unwired script. Then restore the registration and show the clean run. A checker that has never caught a missing registration is decoration, which is exactly the chapter's charge against the 41 tests.
A blocking hook prints a JSON permissionDecision of deny to stdout and then exits 2. What actually happens?
Which slash command opens the read-only browser that lists every hook event with a count of what is configured against it?
A hook script carries a full passing test suite. Explain why that proves nothing about enforcement, and what test would.
Show answer
The tests exercise the script, but the wiring lives in a settings file the script cannot see, so a tested hook with no registration is never spawned and its silence looks identical to a pass. The test that counts feeds the live system a command that must be refused, checks the exit code that comes back, and then confirms the count in the /hooks browser matches expectation. Registration is state, and nothing on the machine type-checks state.
↺ re-read: “The transferable rule”