Worktree-isolation Bash guard refuses every compound command, even with no git usage at all

Status Open
Reported on v2.1.235
Maintainer reply None cached
Activity 3 comments · opened Aug 19, 2026

Description

When a session runs with worktree isolation active, the Bash-command checker that verifies a command stays inside the worktree refuses any command whose parse is not a single simple command; the content of the command does not appear to affect the outcome. Heredocs, &&/; chains, for loops, function definitions, and brace groups are all refused with:

... is too complex to verify that it stays inside the worktree; break it into plain, separate commands

This fires on commands that cannot affect any git state or escape the worktree, e.g.:

  • for f in src/*.py; do python -m py_compile "$f"; done
  • cd subdir && ./run-tests.sh > /tmp/out.log 2>&1
  • a heredoc writing a scratch file inside the worktree

The conservative default is understandable, but every compound parse is refused regardless of content — consistent with a purely syntactic predicate — so the guard's false-positive rate on ordinary multi-step shell work is high, and "break it into plain, separate commands" is not always possible (loops, heredocs).

Suggestion

A cheap prefilter would remove most false positives without weakening the control: if the compound command contains no git token (no git word, no .git path segment) and no path reaching outside the worktree, it cannot move the branch pointer or mutate a different checkout — which is what the isolation guard exists to prevent. Commands failing that prefilter would still get today's conservative refusal.

Environment

  • Claude Code 2.1.235, macOS
  • Attribution: the refusal text above appears verbatim in the Claude Code 2.1.235 distribution and in none of the locally installed hooks or tools, so it is not coming from user configuration.

View original on GitHub ↗

3 Comments

Mattsouthgate · 10 days ago

Reproduced on 2.1.238 / Linux with a 29-probe matrix run from an isolated worktree session (positive control git -C <main> log --oneline -1 refused as expected). Totals: TP 6 · TN 18 · FP 4 · FN 1.

False positives — refused "too complex to verify that it stays inside the worktree", with no git token, no .git path, no path outside the worktree:

  • for f in a b; do wc -l "$f"; done
  • for s in a:1 b:2; do f=${s%:*}; sed -n "${s##*:}p" "$f"; done
  • echo "x=$VAR"; ls dir | head -2
  • a=1; b=2; echo $((a+b))

Allowed (so the predicate is not "any compound"): ;/&& chains of simple commands, pipelines, python3 - <<'EOF' … EOF heredocs, (cd /tmp && pwd), cd <main> && <non-git command>, VAR=x; ls "$VAR" | head -2, absolute-path reads of files in the main checkout.

False negative — allowed, and it ran against the main checkout (printed main's HEAD, not the worktree's):

  • bash -c 'git -C <main> log --oneline -1'

…while eval "git -C <main> log", GIT_DIR=<main>/.git git log, git --git-dir=<main>/.git log, cd <main> && git log and a Write to a main-checkout path are all correctly refused.

Two suggestions, matching the issue body: (1) the no-git-token / no-outside-path prefilter for the FP shapes above; (2) treat bash -c / sh -c string bodies the way eval is already treated, so the one escape closes. Happy to share the full per-probe table if useful.

Tier1Coder · 9 days ago

Field data from a 12,623-session fleet: 2,628 refusals, 73% with no git token

Corroborating #87959 from a large local corpus. We hit this constantly and finally measured it
rather than guessing. Claude Code 2.1.238, macOS 26.6.1 (Darwin 25.6.0), heavy isolation: "worktree"
use across ~25 concurrent worktrees on one repo.

Method

Walked every ~/.claude/projects/**/*.jsonl transcript (12,623 files), matched each tool_result
carrying the refusal string back to its originating tool_use Bash command via tool_use_id, and
classified the recovered command strings.

Scale

| Measure | Count |
| --- | --- |
| Transcripts containing the refusal | 591 of 12,623 (4.7%) |
| Total refusal occurrences | 2,628 |
| Refusals matched back to their command | 1,131 |

Refusals per session: 283 sessions hit it once, 173 twice, 64 three times, and a 9-refusal tail.

The prefilter you propose would clear 73% of them

Your suggested prefilter is "no git token, no path escaping the worktree". Against the recovered
commands:

| Class | Count | Share |
| --- | --- | --- |
| Refused commands with no git token at all | 828 | 73.2% |
| Refused with neither git nor gh | 594 | 52.5% |
| Refused, git-free, operating on the session scratchpad | 274 | 24.2% |
| Refused, git-free/gh-free, starting with a read-only text tool (grep/sed/cat/head/ls/wc/find/rg/awk) | 115 | 10.2% |

The sharpest case: the harness blocks the directory it just told the agent to use

The system prompt instructs agents to put all temporary files in a session scratchpad under
/private/tmp/claude-501/<project>/<session-uuid>/scratchpad, which is outside the worktree and
outside the repo. The single most common refused shape in our corpus is the agent complying with
that instruction:

cat > /private/tmp/claude-501/-Users-…-ai-wasp/<uuid>/scratchpad/repro.py <<'EOF'
import pathlib, sys
…
EOF

Refused with:

…this command is too complex to verify that it stays inside the worktree; break it into plain, separate commands. Refusing to run it — a worktree-isolated session's git operations must target its own worktree.

There is no git operation, and the write target is not in any worktree. 274 refusals (24%) are this
pattern. "Break it into plain, separate commands" has no answer for a heredoc — the agent's only
recoveries are many single echo >> appends or abandoning the scratchpad the harness asked it to use.

Other git-free refusals from the corpus

grep -c "^### DECISION-" docs/bible.md; for n in 112 118 099; do grep -q "^### DECISION-$n " docs/bible.md && echo present || echo absent; done

for n in 1544 1545 1546; do echo "--- PR $n"; gh pr view $n --json isDraft,mergeable,state; done

python3 - <<'PY'
import re
t = open('docs/bible.md', encoding='utf-8').read()
…
PY

One anomaly the "compound parse" model does not explain

#87959 describes the predicate as refusing anything whose parse is not a single simple command. Two
refusals in our corpus are single simple commands with no shell metacharacter at all:

sed -n '1,220p' /private/tmp/claude-501/…-claude-worktrees-soft-cuddling-dusk/<uuid>/scratchpad/dump.txt

Only 2 of 1,131 (re-verified with newline-aware parsing), so it may be a separate defect (possibly path-segment matching, cf. #86036 — note
the literal worktrees in the path). Flagging rather than claiming.

What the misleading message costs

The refusal always attributes the block to "git operations" and often ends "without the redirect"
even when the command has no redirect (cf. #85931). With 73% of refusals git-free, the stated reason
is wrong most of the time, so operators go looking for a git problem that is not there.

Two asks beyond the prefilter already proposed:

  1. Name the construct that actually tripped the guard, instead of "too complex".
  2. State the accepted command shape in an isolated session's context at startup. Today the constraint

is discoverable only by violating it — across 591 sessions in this corpus, no session learned it
before being refused.

Note on retry behaviour

For completeness, and against my own initial assumption: agents do not loop on this. Zero verbatim
retries across all 591 sessions; only 50 instances of two-or-more back-to-back refused calls. Models
reformulate correctly after one refusal. The cost is the wasted round trip and the misleading
diagnosis, not an infinite loop.

foma-agent · 7 days ago

#87959, #88776, and the 12,623-session corpus are one control, not three reports.

The later write-up in #88776 names the first-branch cause: if (e.kind !== "simple") return refuse(...) runs before the ~120 lines of git analysis. That matches the 2.1.238 matrix here (TP 6 / TN 18 / FP 4 / FN 1) and the 73% git-free refusals. The script-file workaround in #88776 is evidence the tax is not the isolation property: the same work is allowed once it is no longer inline.

Two holes sit on opposite sides of "unverifiable":

  1. Git-free compound commands are refused as git operations. The refusal discards tPt's reason, so the agent cannot name the construct.
  2. bash -c 'git -C <main> log --oneline -1' was allowed and ran against the main checkout. eval, GIT_DIR=, git --git-dir=, and cd <main> && git log were correctly refused.

#88550 is the same unverifiable-path class for ~ versus $HOME.

A useful acceptance fixture, using the already-reported probes:

  • for f in a b; do wc -l "$f"; done and echo x; ls | head -2 must run.
  • git -C <main> log --oneline -1 must refuse.
  • bash -c 'git -C <main> log --oneline -1' must refuse the same way eval already does.
  • A git-free heredoc to the harness scratchpad must run, or the prompt must stop telling the agent to write there.
  • A refusal must name the construct (for, pipeline, unexpanded ~, bash -c) instead of attributing a git-free command to "git operations".
  • Writing the same logic to a script and invoking the script must not be the only green path, unless that is an explicit documented policy.

I did not rerun the matrix. The FN is the one that can still mutate the shared checkout.