[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/...)

Status Open
Reported on v2.1.270
Maintainer reply None cached
Activity 4 comments · opened Sep 14, 2026

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:

  1. The guard fails closed: every file inside the project is reported as "outside the project".
  2. It also denies edits to .claude/settings.json itself, 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).
  3. 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_path is backslash-separated on Windows and mentions the $PWD trap 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 with tool_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

  1. Native Windows, Git Bash as hook shell.
  2. Put the hook above in <project>/.claude/settings.json.
  3. Ask Claude to edit any file in the project: denied ("outside the project").
  4. 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.

View original on GitHub ↗

3 Comments

swswswqwqw · 1 month ago

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 PreToolUse guard, but the rules live in a Node script instead of a Git Bash one-liner. The matching was still written against POSIX paths, while tool_input.file_path arrives as C:\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:

  1. The path vocabulary should be normalised where the JSON is parsed, not where the comparison is written. Moving the comparison into the runtime that receives the event (rather than a shell whose $PWD/$HOME come 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.
  2. Your consequence #2 — the guard denying .claude/settings.json itself — may survive the path fix. Separately from path format, we measured that a hook returning allow for 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 $PWD will 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.

Suyann · 27 days ago

Related, from the prevention side: the docs now say the path "arrives with backslash separators, even when your hook runs under Git Bash where $PWD looks 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 (Write with "file_path": "C:\\proj\\.env" piped into every Edit|Write handler, verdict BLOCKS / ALLOWS / FAIL-OPEN), so a guard that compares against $PWD shows up wrong in a dry-run instead of in a session. MIT: https://github.com/Suyann/claude-code-windows-hooks. The protect-paths hook there normalises with ${p//\\//} first, and converts the other way with cygpath when 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>

gmantova · 21 days ago

Thanks, both comments add something I had not isolated.

Consequence #2 (first comment): right in general. .claude/ writes are protected paths, and a PreToolUse allow does not skip that prompt: in 2.1.280 the safety check still runs after the hook's allow. It is the permission layer rather than the sandbox, and hooks.md doesn't list it among the allow exceptions.
Here, though, that prompt is wanted: this guard never returns allow, and Edit is under ask anyway. The lock-out comes from the hook's deny, 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 /hooks menu 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//\\//} gives D:/… while $PWD is /d/…, so the guard still fails closed. A dry-run would need a control Write inside 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_DIR is 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_DIR without cygpath, 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:

  1. document one path format per hook type, and use it for tool_input paths, cwd and CLAUDE_PROJECT_DIR alike (or add normalized fields next to the raw ones);
  2. expose the memory dir and the scratchpad the same way;
  3. the hook if field 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;
  4. a recovery path when a hook denies edits to the settings file that defines it.

Nothing in the 2.1.271–2.1.280 changelog changes hook path formats or CLAUDE_PROJECT_DIR, or adds a terminal recovery path.

Showing cached comments. Read the full discussion on GitHub ↗