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

Status Open
Reported on v2.1.217
Maintainer reply None cached
Activity 4 comments · opened Jul 29, 2026

Preflight

  • [x] Searched existing issues. This re-files #10252, which was closed not_planned by 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:

  1. 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.

  1. **Read/Edit rules 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 via git -C, --git-dir, or GIT_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

  1. 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.

  1. 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.
  1. 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.

View original on GitHub ↗

3 Comments

wshallwshall · 1 month ago

Still relevant, and the history around it is worth stating plainly.

This re-files #10252, which the inactivity bot closed as not_planned after several users said it was still needed, and which is now locked. The bot's own closing message asks people to open a new issue with updated information, so this is that issue, and it is on the same path as the one it replaces.

The underlying gap has not moved: deny rules cannot carry exceptions, and worktrees live nested inside the very path you would have to deny to protect the main checkout. The related items (#69026, #76590, #79234) are bugs in how sessions land in the wrong tree. This one is the missing policy that would make those bugs non-events.

wshallwshall · 1 month ago

Still open, and still inexpressible. The request is one sentence: a way to say "agents may not write to this directory, except the worktrees inside it."

Two documented behaviours combine to block it, and both would have to move for any workaround to exist. Deny rules cannot carry allowlist exceptions, per the permissions doc's own wording about a broad deny beating a narrower allow. And Read/Edit rules use gitignore pattern syntax without gitignore semantics, so there is no ! negation form to write the exception on the deny side either. That leaves the rule unwritable from both directions.

This is the missing policy, not a bug. #69026, #76590 and #79234 are the bugs that a working rule of this shape would have contained. I refiled because #10252 was closed by the inactivity bot after several people said it was still needed, and it is now locked, so the bot's own instruction to open a new issue was the only route left.

wshallwshall · 21 days ago

Still open. #79759, filed by another user, asks for a specific allow rule to be able to override a broad deny. That is one of the two routes that would make this request expressible.

If #79759 lands, the rule becomes writable as "deny the main checkout, allow .claude/worktrees/", and no negation syntax would be needed. So the two issues are complementary: that one is the mechanism, and this one is the concrete case it would solve.

Showing cached comments. Read the full discussion on GitHub ↗