A PreToolUse hook can be inert with no signal anywhere: a missing script fails open, and nothing detects a rule whose matcher was never registered
Preflight
- [x] Searched existing issues. Closest is #75081 / #75071 (one schema-invalid matcher silently disables all hooks). Same family — hooks failing silently — but a different failure: there the config is rejected; here the config is valid and accepted, and the hook still never runs. Neither is detectable from inside the product.
- [x] Single bug report.
- [x] Claude Code 2.1.217.
Summary
A PreToolUse hook used as a guardrail can be completely inert while every observable signal says it is configured. Two distinct paths, both silent.
A. A missing or unrunnable hook command fails OPEN. From the hooks reference:
Any other exit code: Non-blocking error. The tool call proceeds. Execution continues.
A hook whose script has been deleted, renamed, or moved exits non-zero-but-not-2, so the tool call proceeds — with no warning, nothing surfaced from stderr, and nothing in /doctor. For a security or safety hook this is the worst available default: the control disappears, and the only evidence is its absence.
This is not hypothetical for hooks pointing into a working tree. A git checkout can delete the script out from under a configured hook, after which every session is ungated and nothing says so. (We install our hook outside every checkout precisely because of this — but that workaround exists because we reasoned about the failure mode, not because anything reported it.)
B. A rule whose tool has no matcher never fires, and nothing compares the two. A hook script that branches on tool names is only invoked for tools a matcher routes to it. Register the script for Bash|PowerShell but implement a rule for EnterWorktree, and that rule is dead code: valid config, no error, no diagnostic.
We shipped exactly this. A rule sat implemented, declared by our installer, and covered by tests for five days while being absent from every live matcher set — 85 tests passed the entire time. We found it only by hand-writing a parity check that compares the script's $tool -in @(...) branches against the matchers in the live settings.json files. That is not something every user should have to invent.
Why /hooks doesn't cover it
Type /hooks in Claude Code to open a read-only browser for your configured hooks.
It shows what is configured. It does not check that a command exists or is executable, that a matcher corresponds to a real tool, or that a script's implemented rules are all reachable. Configured and effective are different properties, and only the first is observable today.
Reproduction
A — silent fail-open:
- Register any
PreToolUsehook that denies something, and confirm that it denies. - Rename or delete the script it points at. Change nothing else.
- Retry the same tool call. It succeeds. No warning, no error, nothing in
/doctor;/hooksstill lists the hook as configured.
B — unreachable rule:
- Write a hook script that denies for tool
Foo. - Register it with
"matcher": "Bash"only. - Nothing reports that the
Foobranch is unreachable./hooksshows a healthy configuration.
Provenance, stated plainly: B is measured — it is a real rule in our own hook, verified absent from all five config dirs while present in the script and declared by the installer. A is derived from the documented exit-code contract above, not from a fresh experiment, because reproducing it means pointing a live hook at a missing script and disarming our own guardrail. The documented language is unambiguous, but the steps deserve confirmation.
Expected
Any one of these would help; the first matters most.
- Report a hook that cannot be executed. Surface it at session start or in
/doctor— "hook command not found:<path>" — instead of proceeding silently. Better still, make an unrunnablePreToolUsehook fail closed, or make fail-open a deliberate opt-in (e.g."onError": "allow" | "deny"). A guardrail that vanishes quietly is worse than one never installed, because the operator believes they are covered.
- A validation command.
claude hooks doctor, or an extension to/doctor, reporting per configured hook: does the command resolve, is it executable, did it run this session, and what did it return.
- Make effectiveness observable. A counter or log of hook invocations and decisions would let anyone answer "did this fire?". Today the only way to know a
PreToolUsedeny happened is to catch it in a transcript — we had to add our own deny log to make our guardrail falsifiable at all.
Impact
Both paths defeat the reason a guardrail is installed. The operator sees a configured hook and believes the control is live; it is not; and the failure is undetectable from inside the product. For teams using hooks for safety, secret handling, or multi-session isolation, "silently off" is the failure mode that matters most.
Environment
Claude Code 2.1.217 · Windows 11 Pro 26200 · hooks registered at user scope across 5 config dirs (~/.claude plus ~/.claude-account-*).
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
A third path to the same end state, measured since I filed this — and it is the one I would now call the most common, because it needs nothing to break.
C. A hook that is in the repository is not a hook that is installed, and nothing anywhere says so.
I merged a
UserPromptSubmithook tomainon a Saturday evening. The next morning it had still never executed: no entry in any of my five config roots, so its state directory did not exist, so there was not a single receipt. The installer that wires it had last been run before that hook existed, and merging a hook does not install one. Paths A and B in the original report both assume the entry is present; here the entry was simply never created, and every check I would naturally reach for said things were fine.Two details make this worse than it sounds, and both are about the reassurance rather than the failure:
The reassuring text did not come from the control. A hook belonging to a different repository sat wired on the same event in the same user settings file, firing on every prompt and resolving nothing. It looked healthy — but the status text I was reading beside it is the
statusMessagefield of the settings entry, rendered by the harness. It is emitted whether or not the script does anything, and this script's stdout was empty. So the signal that told me the event was covered was produced by configuration, not by the control. That is a stronger version of this issue's thesis: configured and effective are different properties, and the surface that looks like evidence of the second is actually rendering the first.My own detector could not fire. I had already built a missing-script notice precisely so this class could not hide. It lives inside the shim — so when the shim is not wired at all, the detector is not wired either. Every check I had was strictly downstream of the failure it existed to detect. That generalises into the question I would now ask of any control: which surface still reports when the control itself fails to load? Today, for hooks, the answer is none.
What actually worked, offered as the shape of a fix rather than a workaround: the only reliable check was a receipt written by the hook on every decision — including the decisions where it found nothing to do. Reading
settings.jsonwould have shown me the other repository's entry and looked correct. So "did it run" has to be answerable from something the run itself produced. That is your Expected #3, and I would now put it ahead of #2: a validation command tells me the config is sane, but only a receipt tells me the event was actually covered.One related observation I cannot promote to a finding, recorded in case it saves someone the experiment. Probing
type: "mcp_tool"handlers on 2026-08-03, I put three handlers in oneUserPromptSubmitarray: acommandcontrol, anmcp_toolnaming a real host-provided server, and anmcp_toolnaming a server that cannot exist. The control fired and its stdout reached the model verbatim; neithermcp_toolhandler produced anything visible — including the one whose server does not exist, where the docs promise a non-blocking error. I had no MCP server connected at the time, so "not surfaced", "not addressable" and "errored invisibly" are indistinguishable on my box and I am not claiming a bug. But it is the same shape as this issue: the failing and non-failing cases were byte-identical from inside the session. Anyone with a healthy MCP setup can separate all three in a single prompt with that three-handler array.Environment: Claude Code
2.1.219, Windows 11 Pro, hooks at user scope across 5 config dirs. n=1.Still relevant. A
PreToolUsehook used as a guardrail can be completely inert while the configuration is valid, accepted, and visible.Worth keeping the distinction from #75081 and #75071 sharp, because they look adjacent: there the config is rejected, which at least produces a signal. Here the config is accepted and the hook still never runs, and there is no signal anywhere. A missing script fails open, and a rule whose matcher was never registered is undetectable from inside the product.
#83952 is the companion request: a way to get a receipt for what actually executed, rather than a claim about what is configured.
Still open. Worth separating from #75081 and #75071 before anyone reads this as the same bug: there the configuration is rejected, here it is valid and accepted and the hook still never runs. Same family, different failure.
Path A is the one I would fix first. The hooks reference states that any exit code other than 0 or 2 is a non-blocking error and the tool call proceeds. For a script that has been deleted, renamed, or checked out from under a configured hook, that means the guardrail disappears and the call goes through, with nothing surfaced from stderr and nothing in
/doctor. For a safety hook that is the worst available default, because the only evidence of the control is its absence.The reason we install our hook outside every checkout is that a
git checkoutcan delete the script and leave every session ungated. That workaround exists because we reasoned about the failure mode, not because any signal reported it.