[BUG] Stop hook exit 2 is silently made non-blocking when its stderr mentions "No such file" or "can't open" (re-file of #60490)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet — it was reported as #60490 (v2.1.144). That issue was closed by the stale bot as not planned without any maintainer response, then auto-locked with "please file a new issue and reference this one". This is that new issue; the defect is unchanged in 2.1.288.
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.288, npm
latest)
What's Wrong?
A Stop hook that blocks with exit code 2 is silently treated as non-blocking whenever its stderr contains the text no such file or can't open anywhere, even though the hook script exists, ran, and is deliberately blocking.
The hook runner has a "missing script" heuristic for the Stop family. For an exit-2 command hook with empty stdout, it tests the entire stderr against /no such file|can't open/i. If that matches, it reports "Hook script appears to be missing" and downgrades the block. A test gate that relays a pytest FileNotFoundError, a linter reporting a missing include, or a build gate quoting ld: can't open output file all match. The block is dropped, and the model gets no sign that the gate fired.
The predicate, read out of the 2.1.288 bundle (the body is byte-identical in 2.1.286 and 2.1.287; only the minified name changes):
function xqn({hookEvent:e,stdout:n,stderr:r,pluginId:s,stdioIncomplete:g,stdioClosedWithoutEnd:h}){
if(g===!0||h===!0)return!1;
return(e==="Stop"||e==="SubagentStop"||e==="TaskCompleted"||e==="TeammateIdle"||Boolean(s)&&e==="UserPromptSubmit")
&&!n.trim()&&/no such file|can't open/i.test(r)}
Compared with the 2.1.144 code quoted in #60490, the regex and the empty-stdout condition are the same. Since then the event list has grown: plugin UserPromptSubmit hooks now hit it too.
What Should Happen?
Exit 2 from a Stop hook that exists and ran should block, as documented. "Missing script" should be decided from facts about the hook command, not from free text in its output.
Error Messages/Logs
From the enoent cell of the repro below, recorded as a hook_non_blocking_error attachment and in stop_hook_summary.hookErrors (path shortened):
Hook script appears to be missing — "bash …/enoent/hook.sh" exited 2 with: Test suite failed:
E FileNotFoundError: [Errno 2] No such file or directory: 'tests/fixture.json'. Treating as non-blocking. If this is a plugin hook, check the plugin install (run /plugin).
From the cantopen cell:
Hook script appears to be missing — "bash …/cantopen/hook.sh" exited 2 with: Test suite failed:
ld: can't open output file for writing: build/out.o. Treating as non-blocking. If this is a plugin hook, check the plugin install (run /plugin).
The control cell records the error normally ([bash …/control/hook.sh]: Test suite failed:\nE AssertionError: expected 3, got 4) and blocks.
Steps to Reproduce
Executed on 2.1.288, Linux (WSL2), headless. There are three scratch projects, and they differ only in the stderr line the hook prints.
.claude/settings.json in each:
{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash <dir>/hook.sh"}]}]}}
<dir>/hook.sh (it blocks once, then lets the continuation stop):
#!/usr/bin/env bash
in=$(cat); a=$(printf '%s' "$in" | jq -r .stop_hook_active)
echo "fire stop_hook_active=$a" >> <dir>/fires.log
[ "$a" = true ] && exit 0
echo "Test suite failed:" >&2; echo "$MSG" >&2; exit 2
| cell | $MSG |
|---|---|
| control | E AssertionError: expected 3, got 4 |
| enoent | E FileNotFoundError: [Errno 2] No such file or directory: 'tests/fixture.json' |
| cantopen | ld: can't open output file for writing: build/out.o |
In each project dir run claude -p --model haiku --setting-sources project --output-format json "Reply with just: ok".
Observed fires.log. A blocking Stop hook makes CC continue the turn, so a block shows up as a second fire with stop_hook_active=true:
== control
fire stop_hook_active=false
fire stop_hook_active=true
== enoent
fire stop_hook_active=false
== cantopen
fire stop_hook_active=false
Expected: all three cells block, because the script exists, ran, and exited 2. Actual: only the control blocks.
Separately, in real use, a pytest Stop gate whose failing tests raised FileNotFoundError produced 5 of these "appears to be missing" downgrades in one interactive session over about 25 minutes. None of them blocked, and the script was present the whole time.
Claude Model
Not model-dependent (hook runner). The repro used Haiku.
Is this a regression?
No. The behaviour has been present since at least 2.1.144 (#60490), and its scope has grown since then.
Claude Code Version
2.1.288 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux (WSL2)
Terminal/Shell
Other (headless claude -p)
Additional Information
Not arguing against missing-script detection. A hook whose script is missing really can exit 2. CPython exits 2 on can't open file (#96759), and a missing script must not lock a session or erase prompts (#92168). The defect is only that the detection matches a substring anywhere in stderr, so it also fires for hooks that ran fine.
Suggested fix, in order of preference:
- Check the script path. Take the hook command's script operand: the first path-like argument after an interpreter such as
bash,sh,python3ornode, or the command itself. If that path does not exist, apply the missing-script handling. If it exists, exit 2 is a real block. - Anchor the text match to that path. If a stderr match is kept, require the error to name the hook's own script (
<path>: No such file or directory,can't open file '<path>') rather than matching anywhere in the output. - Direct execution of a missing script already exits 127, which is non-blocking by the docs. The heuristic is only needed for interpreter-launched scripts, and option 1 can check exactly that case.
Removing the heuristic outright would reintroduce #96759 and #92168. Keeping the free-text match keeps dropping legitimate blocks, and a hook author cannot detect the drop from inside the hook.
Workaround today: block through stdout JSON ({"decision":"block","reason":"…"} at exit 0), which this predicate never reads. Nothing in the hooks docs warns that exit 2 + stderr is unsafe for this one class of message, so most gate authors will not know they need it.
Related, not duplicates: #60490 (the original report, auto-closed), #96759, #92168, #94362 (a non-executable PreToolUse hook fails open, a different missing-script variant), and #88830 (hook_non_blocking_error carries no diagnostics in the desktop app).