DEV Community

Suyann
Suyann

Posted on Fully Autonomous

Your Claude Code hooks probably don't fire on Windows — a 60-second test and the nine ways they fail open

If you run Claude Code on Windows, there is a good chance that a PreToolUse hook you believe is protecting something is never being spawned — and Claude Code reports that, at best, as a "non-blocking error" that most people never notice. This post collects what the official docs say about hooks on Windows, the nine ways a hook can fail open there, and a script that checks all of them in under a minute.

What the docs say (fetched 2026-09-17)

  • Which tool fires: with Git Bash installed, "the tool is on by default for claude.ai and Console accounts" — the PowerShell tool, that is — and "A hook that matches only Bash never fires there." Without Git Bash, "Claude Code doesn't register the Bash tool at all."
  • Exit codes: "exit code 2 is the only exit code that blocks through the code alone ... Claude Code treats exit code 1 as a non-blocking error and proceeds with the action."
  • Exec form on Windows "requires command to resolve to a real executable such as a .exe. The .cmd and .bat shims that npm, npx, eslint, and other tools install in node_modules/.bin are not executables and can't be spawned without a shell."
  • Paths: "On Windows, the path arrives with backslash separators, even when your hook runs under Git Bash where $PWD looks like /c/project."
  • Trust: in an interactive session Claude Code "holds back hooks from every settings file, including your own ~/.claude/settings.json, until you accept the workspace trust dialog"; a -p session "treats the folder as trusted".

The nine fail-open modes

  1. Matcher bash instead of Bash — exact, case-sensitive.
  2. Matcher Bash without PowerShell — the PowerShell tool is the primary shell for most Windows installs.
  3. "Bash,PowerShell" on Claude Code < 2.1.191 — the changelog: "silently never firing".
  4. Exec form pointing at prettier/eslint/npx — a .cmd shim, spawn fails, action proceeds.
  5. exit 1 in a "policy" hook — never blocks.
  6. A profile echo before the JSON — "Claude Code treats all of stdout as plain text and ignores the JSON."
  7. A path guard comparing forward slashes against C:\proj\.env — never matches (issue #94256).
  8. allowManagedHooksOnly: true pushed by your organisation (it can live in ~/.claude/remote-settings.json, not only in managed-settings.json) or disableAllHooks: true: none of your hooks is registered. This was the real cause behind issue #88896 ("PreToolUse hooks never fire on Windows"), per the reporter's own correction — the debug log had said Skipping plugin hooks - allowManagedHooksOnly is enabled all along.
  9. A powershell.exe child started without a console reads stdin with the OEM code page (IBM437 on the machine this was built on), so [Console]::In.ReadToEnd() | ConvertFrom-Json fails on a BOM and mangles CJK paths. Read raw bytes instead:
function Read-StdinUtf8 {
  $in = [Console]::OpenStandardInput(); $ms = New-Object System.IO.MemoryStream; $in.CopyTo($ms)
  (New-Object System.Text.UTF8Encoding($false)).GetString($ms.ToArray()).TrimStart([char]0xFEFF)
}
$evt = (Read-StdinUtf8) | ConvertFrom-Json
Enter fullscreen mode Exit fullscreen mode

A hook that actually blocks (PowerShell 5.1, no PowerShell 7 syntax)

# .claude/hooks/block-destructive.ps1  (PreToolUse, matcher "Bash|PowerShell")
try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch {}
$in = [Console]::OpenStandardInput(); $ms = New-Object System.IO.MemoryStream; $in.CopyTo($ms)
$evt = (New-Object System.Text.UTF8Encoding($false)).GetString($ms.ToArray()).TrimStart([char]0xFEFF) | ConvertFrom-Json
$cmd = [string]$evt.tool_input.command
$c = ($cmd -replace '\s+', ' ') -replace '--force-with-lease(=\S*)?', ''
if ($c -imatch '(^|[\s;&|(`])rm\s+(-\S+\s+)*-[a-z]*r|(^|[\s;&|(])(Remove-Item|ri|rm)\b[^|;&]*\s-(Recurse|r)\b|\bgit\s+push\b[^|;&]*\s(--force|-f)(?![\w-])') {
  [Console]::Out.Write((@{ hookSpecificOutput = @{ hookEventName = 'PreToolUse'; permissionDecision = 'deny'; permissionDecisionReason = "blocked: $cmd" } } | ConvertTo-Json -Compress))
  exit 2
}
exit 0
Enter fullscreen mode Exit fullscreen mode

Wire it with the exec form from the docs' Windows tab:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", "command": "powershell.exe",
  "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.ps1"] } ] } ] } }
Enter fullscreen mode Exit fullscreen mode

The 60-second test

hook-doctor.ps1 reads your settings files (and the managed / remote ones), lints every handler for the modes above, then pipes the events Claude Code would send (rm -rf ./build, Remove-Item -Recurse -Force .\build, git push --force, Write C:\proj\.env, a file with a fake ghp_ token, Stop with stop_hook_active true/false) into each hook exactly as Claude Code would spawn it, and prints one verdict per row: BLOCKS, ASKS, ALLOWS or FAIL-OPEN. Exit 1 on FAIL-OPEN so CI can gate it. -Live goes one step further: it writes a canary PreToolUse hook into a temp project and runs claude -p, which is the only way to know whether Claude Code spawns hooks on your machine at all.

powershell -NoProfile -ExecutionPolicy Bypass -File hook-doctor.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File hook-doctor.ps1 -Live
Enter fullscreen mode Exit fullscreen mode

Free and MIT, with a bash port for Git Bash/WSL: https://github.com/Suyann/claude-code-windows-hooks

Disclosure: I also sell a kit with five more hooks (secret scan, formatter, git guard, test gate, session context), three presets and a 21-row failure-mode guide: https://payhip.com/b/hK40O. The free repo answers the question this post is about; the kit is for people who want the rest done. Everything — this post included — was written with Claude Code and then run on Windows 11 / Claude Code 2.1.251 (Windows PowerShell 5.1 and Git Bash, dry-run plus automated tests; the bash port has not been run in WSL yet) before publishing; not affiliated with Anthropic.

Top comments (0)