Worktree isolation refuses git-free Bash on 2.1.272/2.1.273: literal-assigned variables, $(…) args, loops, heredocs after && (75 refusals in 3 days)
Summary
In a worktree-isolated session (isolation: "worktree" / EnterWorktree), Claude Code 2.1.272 and 2.1.273 refuse Bash calls that contain no git at all and reference only paths inside the worktree. The 2.1.257 changelog says loops, $VAR reads, "$(…)" and heredocs that never touch git were fixed; the shapes below are still refused on 2.1.272/273.
Across six worktree sessions on 2026-09-15..17 this produced 75 refusals and roughly 115 wasted tool calls (the refusal plus the retry), out of 249 tool errors of all kinds in the same window.
Related: #87959, #84182, #93193.
Environment
- Claude Code 2.1.272 and 2.1.273 (CLI, Linux/WSL2 Ubuntu), sessions started inside a linked worktree under
<repo>/.claude/worktrees/<name> - Models: Opus 5 / Fable 5.1
- Bash tool; no
gitinvocation in any of the refused commands below
Reproducers (inside the worktree, no git anywhere in the command)
| Command | Refusal text (verbatim fragment) |
|---|---|
| D=/path/to/worktree/.agent/tasks/x/raw; mkdir -p "$D"; python3 $D/summary.py | runs python with a program computed at runtime (the variable D) inside a construct too complex to verify, so what it runs cannot be shown not to be git |
| S=/path/to/worktree/.agent/scratch/cov.py; python3 /path/to/worktree/.agent/scratch/run.py $S | runs python with a value computed at runtime (the variable S) (a computed argument goes after the script or --) |
| W=/tmp/scratch/wiki; ls "$W/Incidents" | runs ls with a value computed at runtime (the variable W) … |
| for f in a.md b.md; do sed -n 2,14p "$f"; done | runs sed with a value computed at runtime (the variable f) … |
| grep -n 'def redact' x.py; sed -n "$(grep -n 'def redact' x.py \| cut -d: -f1),+5p" x.py | runs sed with a value computed at runtime (command output) where an option may stand … |
| mkdir -p out && cat > /path/to/worktree/projects/x/REPORT.md <<'EOF' … EOF | this command is too complex to verify that it stays inside the worktree |
| cd /path/to/worktree && for f in run_etc_backup.sh run_db_backup.sh; do sha256sum "$f"; done | names git in a form too complex to verify that it stays inside the worktree (there is no git token in the command) |
| ls runs \| head -1 \| xargs -I{} sh -c 'cat runs/{}/stdout.log' | runs sh from a find -exec or xargs slot … |
Every path is a literal inside the worktree or under /tmp; every variable is assigned a literal in the same command.
Expected
Per the worktrees documentation ("How Claude Code enforces isolation"), the command-shape check should refuse only commands whose git invocation cannot be located. A variable assigned a literal path in the same command, a for over literal words, or a $(grep …) argument to sed -n cannot run git. The wording "names git" is wrong when the command contains no git token.
Actual
Each call is refused before it runs; the model retries with a rewritten shape (typically writing the logic to a file and running python3 /literal/path.py), so every refusal costs at least two tool calls.
Requests
- Resolve same-command literal assignments (
D=/literal; … $D/…) before the shape check, as the 2.1.257 note suggests was intended. - Do not refuse a heredoc whose redirect target is an absolute path inside the worktree because an earlier
&&exists in the call. - Never report "names git" when the command contains no
gittoken. - Offer a setting or environment variable to downgrade the shape check to a warning for sessions that enforce git isolation by other means (e.g. their own PreToolUse hooks), since the docs state the check cannot be turned off.
I can share the full list of 75 refused commands with the preceding and following tool calls on request.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
More shapes from 2.1.280 (macOS, background session in a worktree), all read-only with no git invocation. In each case the only "git" is data: a path segment, a projects-folder name, a title word, or a jq string. That's a separate trigger from the loop,
$VARand heredoc cases above.Repositories on this machine live under
~/git/, so almost every absolute path contains the substring.| Command (paths shortened) | Refusal (fragment) |
|---|---|
|
python3 - <<'EOF'rewriting one file at~/git/<repo>/.worktrees/<wt>/docs/plan.md|feeds python text naming git in a plain command, which cannot be shown to stay inside the worktree||
d=~/.claude/projects/-Users-<me>-git-<repo>/memory; ls -la "$d"; cat "$d/x.md"|names git in a form too complex to verify that it stays inside the worktree||
ls ~/git/<repo>/.worktrees/; gh pr view 1; gh issue list| same ||
gh issue create --repo <o>/<r> --title "gate refuses a path segment git …" --body-file f.md --label bug|runs gh with the text <the title> in a plain command, so what it runs cannot be shown not to be git||
gh pr view 1 --repo <o>/<r> --json state,mergedAt,url -q '.state + " " + (.mergedAt // "unmerged") + " " + .url'|runs gh with the text .state + … inside a construct too complex to verify|It's matching the substring, not a git invocation. The second and third commands pass unchanged once the path is spelled with a glob that expands to the identical directory (
~/g*/<repo>/…,-Users-<me>-g*-<repo>). Theghcalls pass once separated from thels, and the jq one passes without-q.Impact beyond the retry. Because
gh issue createis refused whenever the title mentions git, agents file throughgh api repos/<o>/<r>/issues --inputinstead. That silently bypasses any tooling keyed ongh issue create.Expected: a
gitsubstring inside a path, a quoted argument, or a jq/heredoc string is data. It shouldn't make the command's git usage "unverifiable" when no command word isgit.Still reproduces on 2.1.288 (macOS arm64, CLI). This time I ran a clean-room repro with paired controls.
Setup: I made a throwaway repo with one commit and used one fresh headless session per arm, so one refusal couldn't affect the next:
In every session
initreportscwd=<repo>/.claude/worktrees/cr-<arm>, with no MCP servers and no user/project/local settings. In the stream-json, each tool_usecommandmatches the requested command byte for byte.| Arm | Command | Result |
|---|---|---|
| isolation control |
git -C <main checkout> status --short| refused: "…this command redirects git to the shared checkout via -C." (so isolation is active) || A |
curl -sS -o /tmp/cr-a.toml https://raw.githubusercontent.com/astral-sh/uv/main/pyproject.toml; echo rc=$?| refused (file not written) || B (control) |
curl -sS -o /tmp/cr-b.toml https://raw.githubusercontent.com/astral-sh/uv/main/pyproject.toml| ran; 5287-byte file written || C |
P=README.md; sed -n 1,5p $P| refused || D (control) |
sed -n 1,5p README.md| ran; printed 5 lines |Arm A, verbatim:
Arm C, verbatim:
A and B differ only by
; echo rc=$?. A has no git, no variable assignment, no loop, no heredoc and no$(…). The URL contains the substringgit(raw.githubusercontent.com), but B has the same URL and runs, so the substring alone isn't the trigger. Adding; echo rc=$?is enough to get the "names git in a form too complex to verify" refusal. C is the literal-assigned-variable shape from this issue, still refused on 2.1.288 even though the variable is assigned in the same command to a plain literal filename.From our own (non-clean-room) transcripts on 2.1.287 and 2.1.288, there were about 160 refusals of this family in worktree-isolated background sessions and
isolation: "worktree"subagents between 2026-09-23 and 2026-10-03. Most were git-free commands, e.g.gh api … > /tmp/x; echo rc=$?; wc -l /tmp/x. The 2.1.257 changelog fix ("$VARreads … heredocs that never touch git") and the 2.1.259 fix ("common Bash loops …") don't appear to hold for these shapes.Still reproducible on 2.1.296 (macOS). Additional cases from one session that was isolated via
EnterWorktree:``
``gh issue comment 123 --body-file - <<'EOF'
...raw outputs are kept outside Git...
EOF
Refused with:
this command feeds gh text naming git in a plain command, which cannot be shown to stay inside the worktree.$(...)that contains no git:echo "$(printf 'git-free')"— refused (names git in a form too complex to verify).for f in a b; do echo "$f"; done,awk '{print $2}',env -u SOME_VAR uv run python -c "..."— refused as "cannot be shown not to be git".claude -p ... "<prompt>") — refused because the prompt text contains the wordBash.Behaviour by how the session was started (same three commands: heredoc with the word git, a
forloop,$(...)):| How the session started | Result |
|---|---|
|
claudestarted with cwd inside an existing worktree | all ran ||
claudestarted in the repo root, thencd <worktree>in Bash | all ran, includinggit status||
claude --worktree <name>|$(...)refused ||
EnterWorktreemid-session | all refused |Since the check can't be turned off (per the worktrees docs), the only way we found to avoid it is to stop using isolated sessions entirely, which also drops the protection the check is meant to give. It would help if the command-shape check only looked at commands that actually execute
git(argv[0] of each simple command, including inside$(...)), and treated heredoc bodies, quoted arguments to other programs, and prompt text as data.