PreToolUse hooks never fire on Windows (v2.1.240)
Summary
PreToolUse hooks configured in .claude/settings.json are never called. Every tool invocation (Bash, PowerShell, Edit, Write, etc.) runs unimpeded regardless of hook configuration. SessionStart, Stop, SessionEnd and SubagentStop hooks appear unaffected — only PreToolUse is broken.
Environment
claude --version → 2.1.240 (Claude Code)
OS → Windows 11 Enterprise 10.0.26100
Shell → PowerShell 7+ (pwsh)
Configuration
.claude/settings.json (abbreviated):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_autonomy.ps1\""
}
]
}
]
}
}
guard_autonomy.ps1 exits with code 2 for certain commands (e.g. git branch -D, gh pr merge). Exit code 2 is documented to block the tool call.
Reproduction steps
- Open a Claude Code session in a project with
PreToolUsehooks configured. - Ask Claude to run:
git branch -D __acn_hook_probe__
(This must be a Claude tool call, not prefixed with !.)
- Observe the output.
Expected: Hook fires → Claude sees hook output → tool call is blocked → no git output visible.
Actual: Git runs, returns error: branch '__acn_hook_probe__' not found. Hook was never invoked.
What was ruled out
| Hypothesis | Test | Result |
|---|---|---|
| Guard script logic is wrong | Fed a real PreToolUse JSON payload directly to guard_autonomy.ps1 via stdin | Script exits 2 correctly — script is not the problem |
| Path style wrong ($CLAUDE_PROJECT_DIR) | Tested: relative path, $CLAUDE_PROJECT_DIR (bash), $env:CLAUDE_PROJECT_DIR (PS), hardcoded absolute path | All four styles: hook never fired |
| Session type matters | Tested top-level session and a freshly dispatched subagent (foreground, main checkout) | Neither fires the hook |
| Matcher is too restrictive | Added an unconditional test hook (no matcher, no stdin read, exit 2 on every call) as the first entry in hooks.PreToolUse[0].hooks | echo hi ran through unblocked |
| disableAllHooks set somewhere | Searched entire machine | None found |
| CLAUDE_CODE_SIMPLE env var | Checked | Not set |
| hasTrustDialogAccepted | Checked in user settings | true |
| Settings not hot-reloaded | Changelog says hot-reload supported since v1.0.90; also tested with fresh session start | No change in either case |
The hook dispatch mechanism itself does not appear to execute hook commands for PreToolUse events, independently of configuration, path style, or session type.
Impact
This project uses PreToolUse hooks as a hard guardrail against autonomous merges, deploys, and writes to production file shares. With PreToolUse broken, these guardrails are not enforced mechanically — the automation is running on policy and in-model instructions only.
3 Comments
Saw you're on Windows 11 with the
PreToolUsehooks staying dead whileSessionStartfires fine. One thing worth checking first: Claude Code asks to "Always allow" hook execution on first encounter, and if that prompt was ever declined the hooks silently stop firing. That's stored separately fromhasTrustDialogAcceptedin~/.claude.json, so it's easy to miss. I'd also runclaude --debugand grep the hook-dispatch lines to see whether the event even reaches the spawner. There were Windows hook-firing regressions before, so if dispatch shows nothing, try the latest version. Oh, btw, I built a portable Hermes OTG rescue stick for exactly these "after-update things just stop working" Windows moments, check it, imo it's handy for diagnosing a broken install. https://github.com/MilkyWay008/Hermes-OTGFollow-up: still broken in v2.1.241, root cause narrowed down
Updated to v2.1.241 and restarted —
PreToolUsehooks still do not fire.New diagnostic data from
~/.claude.jsonmetricsThe
PreToolUsehooks are being dispatched (count = 108) but completing in ~0 ms — far too fast for a realpwshprocess (which takes 200–500 ms to start). Other hook types (SessionStart, Stop, SessionEnd) average 12 ms, consistent with actual process execution.Path expansion ruled out
Initial hypothesis:
$CLAUDE_PROJECT_DIRnot expanded → wrong file path →pwshexits 1 → hook runner allows tool call.Confirmed:
CLAUDE_PROJECT_DIRis indeed empty in this process:However, path expansion is not the root cause. Tested with an inline command that needs no file path at all:
Result:
git branch -D __acn_hook_probe__still ran unblocked. Git returnederror: branch '__acn_hook_probe__' not found. The hook did not intervene.Conclusion
The hook command is not being executed. The dispatch layer records the event (hence count = 108 and the ~0 ms timing) but does not spawn the process. Exit code 2 never reaches the runner, so nothing is blocked.
CLAUDE_PROJECT_DIRbeing empty is a secondary issue worth fixing but is not what causes the blocking to fail.Config for reference
.claude/settings.json:Environment
Counter-datapoint that may narrow the regression window and the variable: PreToolUse hooks DO fire and DO block on CLI 2.1.237, on Windows, with a configuration that differs from the reports above in two specific ways.
Environment (working)
Evidence that they execute, not just dispatch
A deny-hook on the in-app Browser pane tools, exit code 2 with the reason on stderr. Two probes:
mcp__Claude_Browser__preview_startis blocked, the hook's message is returned to the model, the tool call does not run.A synthetic probe of the same hook (piping a
{"tool_name": "mcp__Claude_Browser__preview_start"}payload to it directly) returns exit 2 with the expected message, and the negative control - removing the hook file - is detected. So the hook is genuinely being spawned, not short-circuited.Two variables that differ from the failing reports
Both failing configs above use project-level
.claude/settings.jsonwith pwsh commands. Mine is user-level~/.claude/settings.jsonwith python commands. Either could be the discriminator:.claude/settings.jsonare silently not registered", which is a very close description of dispatch-without-execution. Worth checking whether #88896 and #79480 are the same defect seen at different versions.pwshis resolved from PATH whilepythonhere is too, but the reported ~0.07 ms timing suggests no process is spawned at all, which would be interpreter-agnostic.If someone on 2.1.240+ can test a user-level hook with a non-pwsh command, that isolates it in one step.
Regression window
Working: 2.1.237. Reported broken: 2.1.240 and 2.1.241. That puts the regression in 2.1.238 / 2.1.239 / 2.1.240.
Note on the telemetry used above
The
pre_tool_hook_duration_ms_*keys are not present in~/.claude.jsonon this machine at all, so absence of those metrics should not be read as absence of hook activity. A behavioural probe is more reliable than the counters for confirming enforcement.Why this matters beyond one user
This install uses PreToolUse hooks as a safety layer: three fail-closed gates plus a tool-deny hook that exists specifically to work around the GPU crash in #81698. The failure mode described in this issue is silent - SessionStart and Stop keep working, nothing errors, and every guarded tool call simply proceeds. Anyone using hooks for compliance or safety (see also #80211) would have no signal that enforcement had stopped.
Mitigation others may want to copy: a canary that feeds one hook a synthetic payload, demands its documented verdict, and fails loudly if the verdict does not come. Run it after every CLI update, or on a schedule, and surface the result somewhere already read daily. It is about 40 lines and it turns a silent failure into a visible one.