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

Status Open
Reported on v2.1.217
Maintainer reply None cached
Activity 2 comments · opened Jul 29, 2026

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:

  1. Register any PreToolUse hook that denies something, and confirm that it denies.
  2. Rename or delete the script it points at. Change nothing else.
  3. Retry the same tool call. It succeeds. No warning, no error, nothing in /doctor; /hooks still lists the hook as configured.

B — unreachable rule:

  1. Write a hook script that denies for tool Foo.
  2. Register it with "matcher": "Bash" only.
  3. Nothing reports that the Foo branch is unreachable. /hooks shows 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.

  1. 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 unrunnable PreToolUse hook 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.
  1. 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.
  1. 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 PreToolUse deny 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-*).

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗