Windows: PreToolUse hook exit 2 doesn't block the tool call, PostToolUse exit-2 stderr doesn't reach the model

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

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, command type, matcher Edit|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

  1. .claude/settings.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "node .claude/hooks/block-test.mjs", "timeout": 3 }
        ]
      }
    ]
  }
}
  1. .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);
  1. Ask Claude Code to edit any file with the Edit tool.

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 argv and the correct stdin JSON (tool_input.file_path matches the file being edited, hook_event_name matches PreToolUse/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 2 and 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 PostToolUse hook in the same hooks array (code-review-graph update --skip-flows, a flat binary command with no arguments requiring quoting) fires reliably on every Edit — confirmed by its on-disk state file's mtime changing immediately after each real Edit tool 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-line exit 2 hook on Bash, 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 Task tool specifically, but not for Edit/Write per 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/PostToolUse hook 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/systemMessage schema, 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.

View original on GitHub ↗