Windows: PreToolUse hook exit 2 doesn't block the tool call, PostToolUse exit-2 stderr doesn't reach the model
Environment
- OS: Windows 11 Pro 10.0.26200
- Claude Code: 2.1.217
- Node.js: v22.23.1 (via fnm)
- Shell used to launch the session: PowerShell 5.1 (reproduced identically launching from Git Bash — same result)
- Hooks configured in
.claude/settings.json,commandtype, matcherEdit|Write
Summary
A PreToolUse hook that correctly detects a real problem and exits 2 with a message on stderr does not block the tool call — the Edit goes through anyway. A PostToolUse hook that does the same never surfaces its stderr message back to the model in the same conversation. Both are confirmed against the documented contract (https://code.claude.com/docs/en/hooks.md): "Exit 2 blocks the tool call" for PreToolUse, and "stderr text is fed back to Claude as an error message" for PostToolUse.
This was verified with instrumented, non-simulated evidence: the hook launcher was temporarily modified to log its own argv, the exact stdin JSON received, and the child process's status/stdout/stderr to a file outside the repo, then triggered via real Edit tool calls from Claude Code itself (not a manual replication) in a live session.
Repro
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node .claude/hooks/block-test.mjs", "timeout": 3 }
]
}
]
}
}
.claude/hooks/block-test.mjs:
#!/usr/bin/env node
import { readFileSync } from "node:fs";
let input = "";
try { input = readFileSync(0, "utf8"); } catch {}
console.error("BLOCKED: this should stop the edit");
process.exit(2);
- Ask Claude Code to edit any file with the
Edittool.
Expected: the edit is blocked; the assistant sees "BLOCKED: this should stop the edit" and does not proceed.
Actual: the edit is applied. No blocking message reaches the assistant's context at any point in the same or a later turn.
(This exact repro — same settings.json snippet, same script — was run as-is in a fresh Claude Code session on the environment above immediately before filing this issue, with the hook entry already present in .claude/settings.local.json before that session started — ruling out the "hook registered mid-session, never loaded" false negative — to confirm it's faithful and not a stale description.)
What was confirmed NOT to be the cause (repo-side)
Instrumented the actual launcher (a thin Node wrapper spawning the hook script via child_process.spawnSync) and confirmed, against real Edit-triggered invocations:
- The launcher process is invoked with the correct
argvand the correct stdin JSON (tool_input.file_pathmatches the file being edited,hook_event_namematchesPreToolUse/PostToolUse). - The child script actually runs its real logic (ESLint, tsc, a secret-detection regex) and correctly detects the injected problem.
- The child process exits with status
2and the expected message on stderr. - The launcher propagates that exit code as its own (
process.exit(childResult.status), not hardcoded to 0). - The launcher routes the child's stderr to its own stderr only — verified by redirecting stdout and stderr to separate files (
cmd /c "... 1> out.txt 2> err.txt"): stdout was 0 bytes, stderr contained the full message, and the outer process's own exit code was 2. - Replicating the exact invocation Claude Code uses (
cmd.exe /c "node .claude/hooks/<launcher>.mjs <script>.sh" < stdin.json) outside of Claude Code, against the exact same file, reproduces the correct exit code (2) and message in under 1 second — well under the hook's configured timeout. - Control experiment: a separate
PostToolUsehook in the samehooksarray (code-review-graph update --skip-flows, a flat binary command with no arguments requiring quoting) fires reliably on everyEdit— confirmed by its on-disk state file's mtime changing immediately after each realEdittool call, repeatedly, across the whole session. Hooks as a mechanism do work; the failure is specific to acting on exit 2 / surfacing stderr, not to hook dispatch in general.
So: correct stdin reaches the hook, correct exit code and stderr leave the hook process, confirmed both via live Edit-triggered runs and an out-of-band replication of the identical command, while a sibling hook in the same config fires on every single edit without fail. The only remaining unaccounted-for step is what Claude Code itself does with the exit-2 signal once its child process (cmd.exe /c "...") returns it.
A secondary, possibly related gotcha found along the way
Earlier in this investigation, a hook command that used nested quoting (bash -c "bash \"${VAR:-.}/path\"") silently failed to run at all when triggered by a real Edit (no error, no block), while functionally identical manual replications via cmd /c worked fine. Switching to a flat, unquoted command (node .claude/hooks/launcher.mjs <script>) made the launcher itself run reliably (confirmed via the instrumentation above), but did not fix the exit-code/stderr propagation issue described here. Mentioning this in case it's the same underlying mechanism (something about how nested-quote commands vs. flat commands are parsed/dispatched on Windows) rather than two separate bugs.
Related issues (all closed + locked, can't comment — filing new to consolidate)
Searched before filing. Found several reports of the same broad class of bug ("a hook that should block doesn't block"), all closed and auto-locked (confirmed via locked: true on each), so this couldn't be added as a comment on any of them:
- #31250 — "PreToolUse hooks fail silently — model bypasses 'blocking' hooks without detection" (
platform:windows,area:hooks,bug; closed as stale/not-planned, not fixed). Closest match — another user's comment there reproduces the same symptom with a one-lineexit 2hook onBash, and suggests the same JSON-output workaround independently arrived at here. - #3514 — "PreToolUse hooks with preventContinuation:true are not blocking tool execution" (macOS, closed stale/not-planned). Same family of bug, cross-platform, predates this report by months.
- #13744 — "PreToolUse hooks with exit code 2 don't block Write/Edit operations" (macOS, closed as duplicate of #3514).
- #26923 — "PreToolUse hook exit code 2 does not block Task tool calls" (closed as completed — possibly fixed for the
Tasktool specifically, but not forEdit/Writeper this report). - #15932 — "PreToolUse hook stderr not propagated on exit code 2" (Windows, but self-resolved as a permissions misconfiguration, not this bug).
If a maintainer would rather this be consolidated as a comment on #31250 instead of a new issue, happy to have it merged there — it's simply locked from this side.
Ask
- Confirm whether
PreToolUse/PostToolUsehook exit-code handling has a known Windows-specific gap, and whether it's tracked internally beyond the closed/stale issues above. - If exit-code-based blocking is being phased out in favor of the JSON
hookSpecificOutput/continue/systemMessageschema, please make that a hard requirement (or emit a deprecation warning) rather than silently no-op on Windows while still working via exit codes on macOS/Linux, which is confusing to debug.