[BUG] Worktree isolation classifies git `-C`/`--git-dir`/`--work-tree` paths by leading character: refuses `.` and `~` inside the worktree, allows `$VAR` that escapes it
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [ ] I am using the latest version of Claude Code
Measured on 2.1.228. Current is 2.1.238, which I have not re-measured on, so I can't tick that box honestly.
What's Wrong?
In a worktree-isolated session, the static check on git's repository-redirect flags (-C, --git-dir, --work-tree) classifies the path by its first character instead of by where it resolves. It is wrong in both directions: literal paths inside the worktree are refused as "computed at runtime", and $VAR paths that genuinely are computed at runtime are allowed through, including when they resolve outside the worktree.
Measured on 2.1.228, in a session isolated to /home/user/repo/.claude/worktrees/example:
command verdict target
---------------------------------- --------- ----------------------------
git -C . refused the worktree itself
git -C ./tmp refused inside the worktree
git -C ./tmp/some-clone refused inside the worktree
git -C ~/.claude refused outside the worktree
git --git-dir=./.git refused inside the worktree
git --work-tree=. refused the worktree itself
git -C tmp allowed inside the worktree
git -C tmp/some-clone allowed inside the worktree
git --git-dir=.git allowed inside the worktree
git -C /home/user/repo/.claude/... allowed inside the worktree
git -C "$HOME/.claude" allowed outside the worktree, and it ran
git --git-dir="$HOME/.claude/.git" allowed outside the worktree, and it ran
The rule the verdicts actually follow: refuse if the path's first character is . or ~, allow otherwise. All three flags share it, and the escape below reproduces on --git-dir as well as -C.
Scope, since the error text invites a wider guess. It is git-aware rather than a generic match on -C, and it does not touch pathspecs:
make -C ./tmp --version allowed non-git -C is not matched
tar -C ./tmp --version allowed non-git -C is not matched
git log -1 --oneline -- ./tmp allowed pathspec, not a redirect
cd tmp && git status --short allowed
Two separate problems fall out of the first-character rule.
Literal paths are refused as runtime computations. ., ./tmp, and ~/.claude involve nothing deferred to runtime. . and ~ are statically resolvable, and the check already resolves tmp and absolute paths. The sharpest case is git -C ., refused inside the very worktree the error message says git operations must target.
The one path that is computed at runtime is the one that gets through, and it escapes the worktree. git -C "$HOME/.claude" ran and returned local, which is the branch name of a repository outside the worktree. The worktree's own branch is worktree-example, so the return value confirms git operated outside the isolation boundary rather than silently falling back to it. git --git-dir="$HOME/.claude/.git" returns the same thing, so this is a property of the classifier and not of one flag. $HOME is the only spelling in that table requiring expansion, and it is the only unverifiable one the check accepts.
To be clear about severity: worktree isolation reads as a guardrail against an agent operating on the shared checkout by accident, not as a boundary against a hostile actor, and I am not reporting this as a security issue. But a check whose stated purpose is keeping git inside the worktree currently refuses the worktree itself and permits an unverified path out of it, which is the opposite of both halves of its job.
It hard-refuses rather than prompting. The refusal comes back as a tool error. No permission dialog is raised, so there is no way to approve a case the classifier got wrong. Given that PreToolUse hooks in the same position can return "ask", the absence of any prompt here is worth a look on its own.
Not a duplicate of #84258. That one is about a -C deliberately aimed at the main checkout, and produces a different message ("redirects git to the shared checkout via -C"). Neither #84258 nor #84720 contains the string "computed at runtime". Here the refused targets are inside the worktree and the message is the "computed at runtime" variant.
Not one of my hooks. Four PreToolUse guard plugins are installed on this host (workspace-guard 1.9.0, branch-guard 1.4.2, prod-guard 2.5.1, foreground-guard 0.5.0). Executing each directly against the PreToolUse payload for git -C . branch --show-current returns an allow from all four: three silently, one with an explicit permissionDecision: "allow". Independent of versions, grep -rlc "isolated in the worktree" ~/.claude/plugins/ matches zero files, while the string appears twice in the 2.1.228 binary.
Related, not filed here. Nearly the same message text also wraps a second static-analysis bail-out, which says "too complex to verify that it stays inside the worktree" and then closes with the identical git-flavored sentence. That branch is not git-specific. Every heredoc trips it, including cat <<EOF with a one-word body, and so does command substitution (readlink -f "$(which claude)"), neither containing git. Plain multi-line commands and multi-stage pipelines are fine. Bailing out on command substitution is defensible; refusing a heredoc whose body is hello world is harder to justify, and in both cases the closing sentence names a git redirect that is not in the command. Already reported as #87959, so I have left it out of this report.
The two branches are worth telling apart. This one is git-aware and over-fires on three flags. That one ignores git entirely. Characterizing either by the shared closing sentence will get its scope wrong.
What Should Happen?
Resolve the flag's argument against the session's working directory and compare the result to the worktree root, rather than switching on the leading character. ., .., and ~ all resolve statically, so git -C . and git -C ./x should get the same verdict git -C x gets today, and the same for --git-dir and --work-tree.
For an argument containing $VAR or $(...), either refuse it (which would at least match what the current message claims) or resolve it at exec time. Allowing it unchecked is the one outcome that contradicts the message.
Non-blocking, and I'd understand deferring it: surfacing this as a permission prompt instead of a tool error would let a user approve the cases the classifier gets wrong, which is how the hook API already behaves in the same position.
Error Messages/Logs
This session is isolated in the worktree /home/user/repo/.claude/worktrees/example, but this command points git at a directory computed at runtime (-C .), which can't be verified before it runs. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Run the equivalent from /home/user/repo/.claude/worktrees/example without the redirect.
Same message with (-C ~/.claude) substituted for the tilde case.
--git-dir and --work-tree produce a second variant, saying "repository" rather than "directory" and rendering the flag space-separated even when it was written with =:
This session is isolated in the worktree /home/user/repo/.claude/worktrees/example, but this command points git at a repository computed at runtime (--git-dir ./.git), which can't be verified before it runs. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Run the equivalent from /home/user/repo/.claude/worktrees/example without the redirect.
Steps to Reproduce
- Start a worktree-isolated session, via the
EnterWorktreetool orclaude --worktree example.
- From the worktree root, target the worktree itself:
````
git -C . branch --show-current
Refused.
- Create a subdirectory inside the worktree and clone into it:
````
mkdir -p tmp
git clone https://github.com/octocat/Hello-World.git tmp/some-clone
- Target that clone with a leading
./:
```
git -C ./tmp/some-clone branch --show-current
(-C ./tmp/some-clone)`.
Refused, same message with
- Drop the two leading characters:
````
git -C tmp/some-clone branch --show-current
Allowed. Same directory, same command, opposite verdict.
- Now target a git repository outside the worktree, spelled with
$HOME:
```
git -C "$HOME/some-other-repo" branch --show-current
~` is refused.
Allowed, and it prints that repository's branch. Spelling the same path with
- Confirm it is the classifier and not the
-Cflag:
```
git --git-dir=./.git branch --show-current
git --git-dir=.git branch --show-current
git --git-dir="$HOME/some-other-repo/.git" branch --show-current
-C`, with the "repository" message variant on the refusal.
Refused, allowed, allowed. Same three verdicts as
- Confirm it is git-aware rather than a generic
-Cmatch:
````
make -C ./tmp --version
tar -C ./tmp --version
Both allowed.
Steps 2 and 6 are the pair worth reading together: the refused target is the worktree, and the allowed one is not.
Claude Model
Opus
Is this a regression?
I don't know
Claude Code Version
2.1.228 (Claude Code)
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
WSL (Windows Subsystem for Linux)
Additional Information
Bisect window. The string isolated in the worktree appears 6 times in the 2.1.207 binary and the feature works there, but points git at a directory computed at runtime appears 0 times in 2.1.207 and 2.1.197, and twice in 2.1.228. That suggests this branch landed between 2.1.207 and 2.1.228. I have not run 2.1.207 to confirm the behavior differs, and a reworded message would produce the same string counts, so treat it as a starting point rather than a bisect result.
Workaround, and what it costs. For the refusals: spell relative paths without the ./ prefix, or use an absolute path, on any of the three flags. Note that ~/x is refused as well, so "use a relative path" is not the whole rule. $VAR paths are accepted today, but that is the false negative described above, so it is not advice I would follow. For the false negative itself there is no user-side workaround, because it fails open and silently.
Because the thing hitting this is an agent rather than a person at a prompt, the practical mitigation is a standing instruction in the global CLAUDE.md: permanent context budget, in every session, spent routing around a classifier bug. Here is mine, if it saves anyone the reverse-engineering:
````markdown
Worktree-isolated sessions: two refusal shapes
Trigger: a refusal beginning "This session is isolated in the worktree …".
It comes from Claude Code itself, not from a hook or plugin, so no hook
setting or override affects it.
- Spell git redirect paths without a leading
.or~.-C,--git-dir
and --work-tree are classified by the path's first character rather than
by where it resolves. ., ./x and ~/x are refused as "computed at
runtime" even inside the worktree, while a bare relative name (tmp/x), an
absolute path, and $HOME/x are allowed. Use tmp/x or the absolute path.
See anthropics/claude-code#88379.
- Use the Write tool instead of a heredoc, and avoid command substitution.
A second branch refuses whatever it cannot statically parse, as "too complex
to verify". Every heredoc trips it, including cat <<EOF with a one-word
body; $(...) and backticks trip it with no git in the command. Plain
multi-line commands and multi-stage pipelines are fine.
See anthropics/claude-code#87959.
Both refusals close by telling you to re-run "without the redirect", naming
git and a redirect that need not be anywhere in your command. Read the shape
of the command, not the text of the refusal.
````
That mitigation is also unreliable, because it encodes a guess at an undocumented rule, and mine has now been wrong three times. I first wrote it as "a -C path starting with a dot", then found ~ refused as well and $HOME allowed and escaping. I then found --git-dir and --work-tree behave identically, so the entry named one flag out of three. And I had recorded the second branch as firing on heredocs whose body holds JSON, when it fires on every heredoc regardless of body. Three deliberate attempts to write this rule down, three wrong, which is the best argument I have for fixing the classifier rather than asking every user to document around it.
A comparable implementation, and I have an interest to disclose. I maintain karlkfi/claude-workspace-guard, a PreToolUse hook plugin that does the same kind of thing: static classification of a path argument against a root directory, refusing or asking when it lands outside. Its scope is file commands rather than git, but the path-token handling is the same problem, so the two are directly comparable. Same tokens through both:
token workspace-guard 1.9.0 Claude Code 2.1.228
---------------------------- ---------------------------- --------------------
~/<path inside root> allow (expands ~) refuse
~/<path outside root> ask, naming the resolution refuse
$HOME/<path inside root> ask, with fix instructions allow, unchecked
unverifiable input ask (user may approve) refuse, no dialog
On every token I could compare, the two disagree, and the plugin's answer is the one matching what the Claude Code message says it is trying to do. The outside-root tilde row is what shows the expansion is real rather than a default-allow: the plugin's reason string is Outside-workspace path(s): ~/.claude/CLAUDE.md -> /home/kisenber/.claude/CLAUDE.md, naming both sides of the resolution.
I am not claiming the plugin is better in general. The scopes differ, and I have not exhausted the input space. The narrow claim is that both halves of this are tractable.
Statically expanding ~ is the first half. Bash resolves ~ and ~/ deterministically, so only ~user, ~+, ~-, and tokens containing $ actually have to be deferred: expand_tilde at v1.9.0, 20 lines including the docstring. Asking rather than hard-refusing on genuinely unverifiable input is the second. Happy to test a fix against the same matrix.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗