[BUG] onFailure "block" does not block when a Windows hook exits 0xFFFD0000 (powershell.exe -File <missing script> counted as success)
Summary
On Windows, a PreToolUse command hook configured as powershell.exe ... -File <script> with "onFailure": "block" does not block the tool call when the script file is missing. Windows PowerShell 5.1 exits with 0xFFFD0000 (4294770688 unsigned / -196608 signed) in that case, and Claude Code logs the hook run as success, so the action proceeds. The same hook blocks correctly when the executable is missing, when it times out, or when the script exits 1.
This is exactly the case the docs say onFailure: "block" is for ("a policy hook with a wrong path or a crashing script lets everything through"), and the docs list "Can't start ... because the script or executable doesn't exist" and "Exit code other than 0 or 2" as failures.
Environment
- Claude Code 2.1.295 (headless
claude -p, also with--debug hooks) - Windows 11 (26200), Windows PowerShell 5.1.26100, Node 26.5.1 for the spawn comparison
- Culture tr-TR, OEM code page 857 (should not matter)
Repro
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/missing-script.ps1"],
"onFailure": "block",
"timeout": 30
}
]
}
]
}
}
Leave missing-script.ps1 absent, then run a Bash command (headless: claude -p "Run: echo q9-a2" with Bash allowed).
Observed: echo q9-a2 runs. --debug hooks shows:
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] "Hook PreToolUse:Bash (PreToolUse) success:\nstdout:\nWindows PowerShell\r\nCopyright (C) Microsoft Corporation. All rights reserved.\nstderr:\nThe argument '...\\.claude/hooks/missing-script.ps1' to the -File parameter does not exist. Provide the path to an existing '.ps1' file as an argument to the -File parameter."
Expected: the call is blocked with failed; blocking because onFailure is "block", as happens for a missing executable ("command": "nonexistent.exe"), a timeout, and a script that exits 1 (logged as error: status code 1 + blocking (onFailure: "block")).
Likely cause
powershell.exe -File <nonexistent> exits with 0xFFFD0000. Spawning the same command from Node (child_process.spawnSync('powershell.exe', [...])) gives status = 4294770688. Its low 16 bits are zero, so if the exit code is truncated to 8 or 16 bits (or compared after a & 0xFF-style mask) it reads as 0 and the run counts as success. Treating any non-zero 32-bit status (or status !== 0) as a failure would fix it.
Impact
Policy hooks on Windows that reference a wrong or deleted script path silently pass the action even with onFailure: "block" set, which is the one case the option exists to catch. A parse error or an uncaught exception in the script (exit 1) is blocked correctly, so only the missing-file path is affected.
Workaround
Start the hook through a small -Command launcher that checks [IO.File]::Exists($path) and exits 1 itself before invoking the script (note: a plain -Command "& '<path>'; exit $LASTEXITCODE" also exits 0 for a missing script, so the explicit existence check is needed).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗