[BUG] PreToolUse hook that fails to launch is treated as a deliberate deny — exit-code 2 collision causes unrecoverable tool lockout
Environment
- Claude Code 2.1.218 (native install)
- Windows 11,
pythonfrom the Microsoft Store distribution - Reproduced with user and project settings excluded (
--setting-sources projectin an empty directory) and no MCP servers (--strict-mcp-config) — i.e. a minimal environment, not a side effect of a local configuration.
Summary
When a PreToolUse hook's command cannot be launched (script missing, path typo, wrong interpreter), the launcher's own failure exit code is interpreted as the hook protocol's "block this tool call" signal. Claude Code cannot distinguish
- "the hook ran and deliberately returned 2 (deny)", from
- "the hook never ran at all (broken configuration)".
CPython exits with 2 when it cannot open the target script (can't open file '...': [Errno 2] No such file or directory). That is exactly the value the hook protocol reserves for "block". The collision is not exotic — 2 is a common "usage/launch error" code across interpreters and CLIs.
Reproduction
Run each in an empty directory.
Test — hook script does not exist:
claude --print --setting-sources project --strict-mcp-config \
--settings '{"permissions":{"allow":["Bash(echo:*)"]},"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"python C:/nonexistent/ghost_hook.py"}]}]}}' \
"Run this bash command and report its output verbatim: echo REPRO_MARKER_OK"
Result — the Bash tool is blocked:
PreToolUse:Bash hook error: [python C:/nonexistent/ghost_hook.py]:
python.exe: can't open file 'C:\nonexistent\ghost_hook.py': [Errno 2] No such file or directory
Control — identical, but the hook exists and exits 0:
claude --print --setting-sources project --strict-mcp-config \
--settings '{"permissions":{"allow":["Bash(echo:*)"]},"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"python -c pass"}]}]}}' \
"Run this bash command and report its output verbatim: echo CONTROL_MARKER_OK"
Result — the Bash tool runs normally and returns CONTROL_MARKER_OK.
The only difference between the two runs is whether the hook command can be launched. This isolates "failed to launch" as the cause, rather than "a hook is configured at all".
Expected behaviour
A hook command that never executed is a configuration error, not a policy decision. It should be surfaced distinctly from a deliberate deny — for example by inspecting the spawn result (ENOENT / non-zero spawn error) before interpreting the exit code, and reporting "hook could not be started" as a loud configuration warning rather than a silent block.
Actual behaviour
The launch failure is reported as PreToolUse:<Tool> hook error and the tool call is blocked, identically to a deliberate exit 2 deny.
Impact
- Any typo in a hook path disables the affected tool, with a message that reads like policy enforcement rather than a broken config.
- Unrecoverable from inside the session. A common hardening pattern is a
PreToolUsehook that protects the settings and hook files themselves from agent modification. If such a hook's script is renamed or removed — e.g. by a "temporarily disable the guard" helper that renamesguard.pytoguard.py.disabledwhile the settings entry still points atguard.py— every subsequent tool call is blocked, including the one that would restore it. The session can open the cage and lock itself inside, with no path back except editing the configuration from outside the agent. - Because exit 2 is a routine interpreter/CLI error code, this is reachable through ordinary mistakes (moved repository, changed interpreter, unresolved environment variable in the path), not only through deliberate action.
Suggested fix
Separate the two signals:
- Treat a spawn/launch failure (command not found, script not readable) as a hook configuration error: surface it prominently, and do not silently convert it into a deny.
- Reserve exit code 2 for hooks that actually executed.
If fail-closed is the intended default for launch failures, the message should say so explicitly ("hook could not be started — blocking by policy") so the operator can tell a broken path from an enforced rule, and the documentation should warn that disabling a hook by renaming its script blocks all matched tool calls.
Related issues
- #77912 —
claude plugin validatepasses manifests whose hook commands reference files that don't exist. That report covers the validation side: a bad hook path is not caught up front. This report covers the runtime consequence that makes it costly — the same missing file does not degrade gracefully, it blocks the tool. The two are complementary: fixing validation reduces how often this is reached, but does not change what happens when it is. - #59643 (closed) — PreToolUse hook deny reason not surfaced to agent's tool_result. Same general area, opposite direction: there a real decision was not reported; here a non-decision is reported as a decision.
I searched the tracker with several phrasings before filing and found no report of this specific exit-code collision. Given the size of the tracker that is "not found", not "does not exist" — happy to see this closed as a duplicate if one is known.
Workaround (for others hitting this)
Do not disable a hook by renaming or deleting its script while the settings entry still references it. Replace the script's contents with a no-op that exits 0, or remove the hook entry from settings — never leave a configured command pointing at a missing file.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Independent re-verification, 2026-07-29, still on Claude Code 2.1.218 (Windows 11, Python 3.13.14 from the Microsoft Store distribution). The bug still reproduces exactly as filed — both the test and the control from the original report.
Test (broken hook path → tool blocked):
The launcher's
[Errno 2]is surfaced asPreToolUse:Bash hook errorand theechonever runs — indistinguishable from a deliberateexit 2deny.Control (identical settings, hook is
python -c pass→ exit 0):The tool runs normally. The only difference between the two runs is whether the hook command can be spawned, which isolates "failed to launch" (not "a hook is configured at all") as the cause — confirming the exit-code-2 collision described above.
Nothing has changed on the current build; the spawn failure is still converted into a silent fail-closed block. Repro is unchanged from the issue body, posted here only as a dated, independent confirmation that it is not stale or environment-specific.
Related failure mode, opposite direction — same exit-2 fragility.
This issue is about a hook that never launches being read as a deliberate deny — fail-closed where it should fail-open. I ran into the mirror image on my own setup: a
PreToolUseguard that does launch and relies on regex-scanning the Bash command string can be walked straight past by an inline interpreter, because the sensitive path and write-mode live in a Python variable, not as a literal in the command.The guard blocks
Edit/Writeto policy files and blocks literal Bash writes (>,tee,open(path,'w')) — but this slips through, because the scanner sees no literal policy path and no bareopen(...,'w'):chr(), variables, and multi-line-cdefeat any static command-string regex — apython -cruns arbitrary code the scanner cannot predict. (I've since hardened my own guards to treatpython|node|perl -c/-enear a policy path as untrusted, which closes it — this isn't a request to fix my local hooks.)The general point: **exit-2 + command-string scanning is a fragile contract for a security boundary, and it's fragile from both ends** — a non-launching hook is a false deny (this issue), and a launching-but-string-matching hook is a false allow (the repro above). Two things would help both cases:
python -c/node -eis not a robust containment primitive — content-level guards (or an actual sandbox) are needed for that.cc @bcherny — in case this is useful for the hook-result design; happy to open a separate issue if you'd rather track the false-allow direction on its own.
Thanks for the very thorough report. I tried this on the current release, 2.1.233 (macOS), with your exact settings (
python3 /nonexistent/ghost_hook.pyas aPreToolUsecommand hook,--setting-sources project --strict-mcp-config). Same result as you saw: the Bash call is blocked withPreToolUse:Bash hook error: ... can't open file ... [Errno 2], and the control (python3 -c pass) runs normally.This is working as documented, though we agree it's confusing. Claude Code fails open when the hook command itself can't be started (I verified: pointing the hook directly at a nonexistent executable produces a non-blocking hook error and the tool still runs). In your case the command does start —
pythonlaunches fine — and it's Python that exits with code 2 because it can't open the script. From Claude Code's side that's indistinguishable from a script that ran and calledexit(2), and per the hooks docs exit code 2 means block: https://code.claude.com/docs/en/hooks#exit-code-2What we're considering: calling this out explicitly in the hooks docs (interpreter launch errors often use exit code 2, so a bad script path blocks rather than degrades; disable a hook by removing its settings entry or making the script a no-op, not by renaming the file), and making the hook-error message more clearly attribute the block to the hook's own output. Keeping this open as a docs/usability improvement.
🤖 Generated with Claude Code