Cannot protect the main checkout from agent writes: deny rules can't carry exceptions, and worktrees are nested inside the path you'd need to deny
Preflight
- [x] Searched existing issues. This re-files #10252, which was closed
not_plannedby the inactivity bot after several users said it was still needed; the bot's closing message says "please open a new issue with updated information", and the issue is now locked. Related but distinct: #69026 (edits landing in the parent checkout — accidental path confusion), #76590 / #79234 (Windows: session start flips the parent repo's HEAD — those are bugs; this is the missing policy that would have contained them). - [x] Single request.
- [x] Claude Code 2.1.217.
What's missing
There is no way to express "agents may not write to this directory, except the worktrees inside it."
Two documented behaviours combine to make it inexpressible:
- Deny beats everything, with no exceptions. From Configure permissions:
> A broad deny rule like Bash(aws *) blocks every matching call, including calls that also match a narrower allow rule like Bash(aws s3 ls), so a deny rule can't carry allowlist exceptions.
- **
Read/Editrules use gitignore pattern syntax but not gitignore semantics. The docs list four pattern types —//path,~/path,/path,path— and none of them is negation**. There is no!form, so the exception cannot be written on the deny side either.
So for a repository at C:\dev\myrepo:
{ "permissions": { "deny": ["Edit(//C:/dev/myrepo/**)"] } }
denies the main checkout and every worktree, because claude --worktree, desktop parallel sessions, and isolation: worktree subagents all create worktrees at <repo>/.claude/worktrees/<name>/ — inside the denied path.
The one layout Claude Code creates by default is the one the permission system cannot describe.
This is #10252's analysis (deny > ask > allow, no exception syntax) with a sharper instance. There, the workspace happened to sit beneath a denied parent. Here, Claude Code itself places the thing that must stay writable inside the thing that must not be.
Why the documented alternative doesn't fit
The docs point at a hook:
A blocking hook also takes precedence over allow rules. […] To run all Bash commands without prompts except for a few you want blocked, add "Bash" to your allow list and register a PreToolUse hook that rejects those specific commands.
That works well for commands. For filesystem isolation it means reimplementing path containment inside a hook, and the cost is not theoretical. Ours is now 609 lines of PowerShell with 183 tests, and building it surfaced all of the following — each one measured against the shipped hook, each one a real bypass or false positive:
| Failure | Why |
|---|---|
| Path canonicalisation | ..\repo-x\..\repo\file walks past a prefix check without GetFullPath |
| Relative resolution | cd ../../.. && git reset --hard — a relative path resolves against the hook process's cwd, not the session's |
| Case sensitivity | -match is case-insensitive in PowerShell, so git's lowercase -c name=value was captured as if it were -C <path>, and shadowed a real -C later in the command |
| Quoted code vs quoted text | blanking quoted spans (to stop a commit message supplying a verb) also blanked pwsh -Command "git reset --hard", which executes |
| --work-tree ≠ the repository | GIT_DIR still resolves from the cwd, so redirecting the work tree doesn't stop the shared repo being mutated |
| Windows trailing dot | cd <repo>. resolves to <repo>, because Windows strips a trailing dot from a path component |
Claude Code hit the same class inside its own isolation and fixed it at a level a hook cannot reach — from the 2.1.216 changelog:
Fixed worktree-isolated subagents redirecting git into the shared checkout viagit -C,--git-dir, orGIT_DIR/GIT_WORK_TREE
That is the argument in one line: you have already solved this correctly, below the string-parsing layer, for your own worktrees. Every team running parallel sessions is currently re-solving it badly in a hook.
And a PreToolUse hook is structurally a guardrail rather than a boundary: it inspects tool arguments, so a write from Set-Content, python -c, or a shell redirect is invisible to it. That gap cannot be closed at this layer by anyone.
What would fix it — any one of these
- Negation in path rules (smallest change). Honour gitignore's
!, so
Edit(//C:/dev/myrepo/**) plus !Edit(//C:/dev/myrepo/.claude/worktrees/**)
expresses the intent directly. This also resolves #10252's workspace-only case.
- A worktree-required policy (best fit). A setting such as
worktree.requireForEdits: true, meaning: in a repository that has worktrees, deny agent writes to the main checkout and steer to a worktree. Claude Code already provisions, locks, sweeps, and base-branches worktrees — this is the one missing verb.
- A "protected root" primitive.
permissions.protectedRoots: ["//C:/dev/myrepo"], exempting.claude/worktrees/by construction and enforced where the 2.1.216 fix lives, so it covers shell writes too.
Option 1 is the most general. Option 2 is the one we would adopt on day one.
What we did not observe
We are on Windows 11 with three nested worktrees and five siblings, so #76590 / #79234 are plausible here. We checked before writing: all three nested worktrees have proper .git link files, and the primary's reflog shows no branch flips. We have no fresh reproduction of those bugs and are not claiming one. This request is about the policy gap that would have contained them, not the bugs themselves.
Environment
Claude Code 2.1.217 · Windows 11 Pro 26200 · desktop app plus VS Code launchers (5 config dirs) · one repository, 8 worktrees, many concurrent sessions.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗