Worktree-isolation Bash guard refuses every compound command, even with no git usage at all
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"; donecd 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.
3 Comments
Reproduced on 2.1.238 / Linux with a 29-probe matrix run from an isolated worktree session (positive control
git -C <main> log --oneline -1refused 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
gittoken, no.gitpath, no path outside the worktree:for f in a b; do wc -l "$f"; donefor s in a:1 b:2; do f=${s%:*}; sed -n "${s##*:}p" "$f"; doneecho "x=$VAR"; ls dir | head -2a=1; b=2; echo $((a+b))Allowed (so the predicate is not "any compound"):
;/&&chains of simple commands, pipelines,python3 - <<'EOF' … EOFheredocs,(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 logand 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 -cstring bodies the wayevalis already treated, so the one escape closes. Happy to share the full per-probe table if useful.Field data from a 12,623-session fleet: 2,628 refusals, 73% with no
gittokenCorroborating #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/**/*.jsonltranscript (12,623 files), matched eachtool_resultcarrying the refusal string back to its originating
tool_useBash command viatool_use_id, andclassified 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
gittoken, no path escaping the worktree". Against the recoveredcommands:
| Class | Count | Share |
| --- | --- | --- |
| Refused commands with no
gittoken at all | 828 | 73.2% || Refused with neither
gitnorgh| 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 andoutside the repo. The single most common refused shape in our corpus is the agent complying with
that instruction:
Refused with:
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
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:
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
worktreesin 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:
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.
#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":
tPt's reason, so the agent cannot name the construct.bash -c 'git -C <main> log --oneline -1'was allowed and ran against the main checkout.eval,GIT_DIR=,git --git-dir=, andcd <main> && git logwere 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"; doneandecho x; ls | head -2must run.git -C <main> log --oneline -1must refuse.bash -c 'git -C <main> log --oneline -1'must refuse the same wayevalalready does.for, pipeline, unexpanded~,bash -c) instead of attributing a git-free command to "git operations".I did not rerun the matrix. The FN is the one that can still mutate the shared checkout.