[BUG] Windows: path-guard hook shared with Linux denies every Edit/Write, even on its own settings file (tool_input.file_path is D:\... but hook shell $PWD/$HOME are POSIX /d/...)
Environment
- Claude Code 2.1.270, native Windows 10 Pro (10.0.19045); hook commands run in Git Bash
- The same project is also used on Ubuntu. The folder is synced between the machines, so
.claude/settings.json(permissions + hooks) is shared.
What's wrong
The project settings contain a PreToolUse guard for Edit|Write|NotebookEdit that only allows files inside the project, the memory dir and the scratchpad (deny message abridged):
"command": "jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' | { read -r f; case \"$f\" in \"$PWD\"/*|\"$HOME\"/.claude/*|/tmp/claude-*) exit 0;; *) printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"BLOCKED: file outside the project\"}}';; esac; }"
It works on Linux. On Windows it denies every Edit/Write, because the two sides of the comparison use different path formats:
| value | on Windows |
|---|---|
| tool_input.file_path | D:\Dropbox\project\doc.tex |
| $PWD in the hook shell (Git Bash) | /d/Dropbox/project |
| memory file vs $HOME | C:\Users\<user>\.claude\projects\... vs /c/Users/<user> |
| scratchpad | C:\Users\<USER~1>\AppData\Local\Temp\claude\... (Git Bash sees /tmp/claude/..., not /tmp/claude-*) |
Consequences:
- The guard fails closed: every file inside the project is reported as "outside the project".
- It also denies edits to
.claude/settings.jsonitself, so the session cannot repair it. Claude is locked out, and the user has to edit the JSON by hand (or explicitly authorize a shell write that bypasses the Edit/Write diff prompt). - The only feedback is the hook's own deny message; nothing points to a path-format mismatch, so it looks like a permissions failure.
Because the settings are shared across machines, path rules can only be portable if they are relative to the project, and a hook is the natural place to express that. On Windows, however, the hook sees paths in a different format than its own shell.
Related
- #83877 (closed, docs): documents that
file_pathis backslash-separated on Windows and mentions the$PWDtrap as a fail-open case. The mismatch itself remains; with allow-list guards it fails closed and self-locks. - #76490, #64432: permission rules not matching Windows paths, i.e. the same missing path normalization on Windows.
What should happen
A settings file that works on Linux/macOS should not silently change meaning on Windows. For example:
- pass hooks paths in the format of the shell that runs them (POSIX under Git Bash), or add normalized fields next to the raw ones (e.g.
file_path_posix,cwd_posix); - expose the project and scratchpad dirs (e.g.
CLAUDE_PROJECT_DIR) in a form directly comparable withtool_input.file_path; - offer a recovery path (e.g. from
/hooks) when a hook blocks edits to the settings file that defines it.
Steps to reproduce
- Native Windows, Git Bash as hook shell.
- Put the hook above in
<project>/.claude/settings.json. - Ask Claude to edit any file in the project: denied ("outside the project").
- Ask Claude to fix
.claude/settings.json: denied as well.
Workaround
Normalize both sides with cygpath (on Linux it does not exist and the path is used as is):
f=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')
u() { cygpath -u "$1" 2>/dev/null || printf '%s' "$1"; }
f=$(u "$f"); p=$(u "${CLAUDE_PROJECT_DIR:-$PWD}")
case "$f" in "$p"/*|"$HOME"/.claude/*|/tmp/claude-*|/tmp/claude/*|/c/Users/*/AppData/Local/Temp/claude/*) exit 0;; *) printf '%s' '<deny JSON>';; esac
Checked on Windows: project, memory and scratchpad paths are allowed; D:\Dropbox\other\x.tex and D:\Dropbox\project-other\x.tex are denied; POSIX-style paths behave the same.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Same root cause, opposite direction — and the opposite direction is the one that doesn't announce itself.
Your guard fails closed on Windows, so you find out immediately. Ours failed open and stayed that way silently. Same project, also native Windows, also a
PreToolUseguard, but the rules live in a Node script instead of a Git Bash one-liner. The matching was still written against POSIX paths, whiletool_input.file_patharrives asC:\Users\...\.env. Measured before the fix:| input | decision |
|---|---|
|
/home/user/ClaudeCode/.env|deny||
C:\Users\PC_User\ClaudeCode\.env| (no decision) |Every deny rule and every allow rule missed. Nothing errored, nothing was logged, and the hook kept exiting 0 — so from outside, a guard that matched nothing was indistinguishable from a guard that passed everything legitimately. It only mattered where the unattended runs happen to be POSIX; the Windows path was one scheduled job away from being unguarded.
Two things that might be worth separating in the fix here:
$PWD/$HOMEcome from a different namespace) removes the mismatch instead of translating it. In our case the whole fix was normalising separators once, at the point the field is read..claude/settings.jsonitself — may survive the path fix. Separately from path format, we measured that a hook returningallowfor a path under.claude/does not beat the sandbox's own sensitive-file prompt: the hook fired, the prompt appeared anyway, and an unattended session sat on it for 13h24m because nobody was there to answer. If that layer is also in play for you, correcting$PWDwill stop the mass denials but still leave the settings file hard to edit from inside the session.A hook that is misconfigured and a hook that is satisfied both look like exit 0. That seems worth a distinct signal.
Related, from the prevention side: the docs now say the path "arrives with backslash separators, even when your hook runs under Git Bash where
$PWDlooks like/c/project", and I wanted a way to catch a Linux-written guard before it bites. I added exactly this case as a synthetic event to a small hook checker (Writewith"file_path": "C:\\proj\\.env"piped into everyEdit|Writehandler, verdict BLOCKS / ALLOWS / FAIL-OPEN), so a guard that compares against$PWDshows up wrong in a dry-run instead of in a session. MIT: https://github.com/Suyann/claude-code-windows-hooks. Theprotect-pathshook there normalises with${p//\\//}first, and converts the other way withcygpathwhen it exists, if you want a drop-in. Caveat: I have only run the bash version under Git Bash, not WSL.<sub>Written with Claude Code; the checks above were run on Windows 11 / Claude Code 2.1.251 (Windows PowerShell 5.1 and Git Bash) by dry-run plus automated tests, not in a long-running real session.</sub>
Thanks, both comments add something I had not isolated.
Consequence #2 (first comment): right in general.
.claude/writes are protected paths, and a PreToolUseallowdoes not skip that prompt: in 2.1.280 the safety check still runs after the hook'sallow. It is the permission layer rather than the sandbox, and hooks.md doesn't list it among theallowexceptions.Here, though, that prompt is wanted: this guard never returns
allow, andEditis underaskanyway. The lock-out comes from the hook'sdeny, which leaves no prompt to answer. The documented way out is restarting with--settings '{"disableAllHooks": true}', which drops every hook for that run; the terminal/hooksmenu is read-only.Docs quote (second comment): confirmed, hooks.md → PreToolUse input. Normalizing separators is enough for a deny-list, not for an allow-list anchored on the project root:
${f//\\//}givesD:/…while$PWDis/d/…, so the guard still fails closed. A dry-run would need a controlWriteinside the project, expected to pass, to tell a working allow-list from a fail-closed one.Data point (2.1.280 bundle): in shell-form hooks under Git Bash,
CLAUDE_PROJECT_DIRis the native path with\replaced by/; exec-form and PowerShell hooks get it unchanged. One hook sees the same directory three ways:| source | spelling |
|---|---|
|
tool_input.file_path|D:\…||
$CLAUDE_PROJECT_DIR|D:/…||
$PWD|/d/…|So after
${f//\\//}the project-root test can use$CLAUDE_PROJECT_DIRwithoutcygpath, as long as the drive-letter case matches. The memory dir and the scratchpad have no such variable, and none of this is documented.Scope of the ask. Tools inside a hook have to use the OS's own paths, so this is not a request to translate paths in general. But Claude Code chooses the hook shell and builds the JSON, so it is the one party that knows both spellings. Narrowly:
tool_inputpaths,cwdandCLAUDE_PROJECT_DIRalike (or add normalized fields next to the raw ones);iffield already takes permission-rule syntax, which is portable, but it holds a single rule with no negation or list, so an allow-list like "project, memory dir, scratchpad" cannot be expressed; negation plus a list would let a guard like this be written with no shell path code at all;Nothing in the 2.1.271–2.1.280 changelog changes hook path formats or
CLAUDE_PROJECT_DIR, or adds a terminal recovery path.