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)

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

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 git invocation 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

  1. Resolve same-command literal assignments (D=/literal; … $D/…) before the shape check, as the 2.1.257 note suggests was intended.
  2. Do not refuse a heredoc whose redirect target is an absolute path inside the worktree because an earlier && exists in the call.
  3. Never report "names git" when the command contains no git token.
  4. 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.

View original on GitHub ↗

3 Comments

joe-aivc · 17 days ago

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, $VAR and 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>). The gh calls pass once separated from the ls, and the jq one passes without -q.

Impact beyond the retry. Because gh issue create is refused whenever the title mentions git, agents file through gh api repos/<o>/<r>/issues --input instead. That silently bypasses any tooling keyed on gh issue create.

Expected: a git substring 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 is git.

sortakool · 12 days ago

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:

claude -p --worktree cr-<arm> --safe-mode --setting-sources "" --strict-mcp-config --no-chrome \
  --permission-mode bypassPermissions --output-format stream-json --verbose "<run this command verbatim>"

In every session init reports cwd = <repo>/.claude/worktrees/cr-<arm>, with no MCP servers and no user/project/local settings. In the stream-json, each tool_use command matches 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:

This session is isolated in the worktree <repo>/.claude/worktrees/cr-A, but this command names git in a form too complex to verify that it stays inside the worktree. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Split it into plain, separate commands and run them from <repo>/.claude/worktrees/cr-A.

Arm C, verbatim:

This session is isolated in the worktree <repo>/.claude/worktrees/cr-C, but this command runs sed with a value computed at runtime (the variable P) where an option may stand (a value that is not double-quoted, or whose first character is matched or computed rather than spelled out, may begin with -; put -- before it) in a plain command, so what it runs cannot be shown not to be git. Refusing to run it — a worktree-isolated session's git operations must target its own worktree. Run the plain command from <repo>/.claude/worktrees/cr-C.

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 substring git (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 ("$VAR reads … heredocs that never touch git") and the 2.1.259 fix ("common Bash loops …") don't appear to hold for these shapes.

JJ213owl · 3 days ago

Still reproducible on 2.1.296 (macOS). Additional cases from one session that was isolated via EnterWorktree:

  1. Plain text inside a heredoc body is treated as a git invocation. The command runs no git at all:

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

  1. $(...) that contains no git: echo "$(printf 'git-free')" — refused (names git in a form too complex to verify).
  2. 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".
  3. Launching a nested headless session from the isolated one (claude -p ... "<prompt>") — refused because the prompt text contains the word Bash.

Behaviour by how the session was started (same three commands: heredoc with the word git, a for loop, $(...)):

| How the session started | Result |
|---|---|
| claude started with cwd inside an existing worktree | all ran |
| claude started in the repo root, then cd <worktree> in Bash | all ran, including git status |
| claude --worktree <name> | $(...) refused |
| EnterWorktree mid-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.

Showing cached comments. Read the full discussion on GitHub ↗