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-*).
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗