[Bug] Worktree isolation guard rejects non-git commands due to early AST complexity check
Bug Description
Worktree isolation guard rejects non-git commands: the "too complex" AST pre-check runs before any git check
Version: 2.1.224 (also present in 2.1.222 and 2.1.223)
Platform: Linux 6.12.101+deb13-amd64, bash
Summary
In a worktree-isolated session (EnterWorktree, or Agent with isolation: "worktree"), the Bash tool refuses any command whose bash AST is not classified kind: "simple" — regardless of whether the command touches git at all. The refusal message talks exclusively about git:
This session is isolated in the worktree /…/.claude/worktrees/dev+disk-cache, but 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. Run the equivalent from /…/dev+disk-cache without the redirect.
Cause
In the guard function, the complexity pre-check is the very first statement, ahead of the
entire git analysis that makes up the rest of the body:
if (e.kind !== "simple")
return refuse("is too complex to verify that it stays inside the worktree; break it into plain, separate commands");
// …everything below this line inspects git: `git -C`, GIT_DIR/GIT_WORK_TREE,
// cd-into-shared-checkout, --git-dir pins, env -C, etc.
So the parser's inability to statically model a for loop or a heredoc is treated as
"might redirect git", even when no argv element matches /^git(\.exe|\.real|-[a-z][\w-]*)?$/
anywhere in the command.
Impact (measured on one repo's transcripts)
Across this project's worktree-isolated sessions: 47 refusals, 24 unique commands — 22 of
them contained no git token at all. Only 2 were genuine redirects into the shared
checkout (cd <shared> && git log, git -C <shared> status), i.e. true positives.
Representative false positives:
# plain parameter expansion with a default
echo "${CLAUDE_PROJECT_DIR:-<unset>}"
# a read-only source survey
for d in Cache Challenge Rules; do [ -d "$d" ] || continue; \
n=$(find "$d" -name '*.cs' | wc -l); echo "--- $d ($n files)"; done
# kubectl rollout check
for d in reverseproxy sentinel logcollector; do kubectl -n stage get deploy "$d" -o jsonpath=…; done
# writing a scratch file
cat > /tmp/…/scratchpad/probe.cs <<'EOF'
…
EOF
# az CLI with a file-backed description
az repos pr create … --description "$(cat /tmp/…/pr-body.md)"
# curl probe over several origins
for O in https://a https://b; do curl -sS -D - -H "Origin: $O" "$U" | grep -i x-cache; done
None of these can reach git. The practical effect is that a worktree-isolated session is
substantially worse to work in than the main checkout: no loops, no heredocs, no $(…)
in arguments, no ${VAR:-default}.
Expected behaviour
Gate the complexity refusal on the command plausibly involving git. A cheap, sound
approximation: if the raw command string contains no git token (and no GIT_*
assignment, and no shell-wrapper that could hide one), let it run — the guard has nothing
to protect. Only when a git token is present should an unparseable AST fail closed.
Alternatively, keep failing closed but say so accurately: the current message tells the
user to remove a "redirect" that does not exist in their command, which is actively
misleading.
Notes
- There appears to be no setting or environment variable to relax or disable this; the
guard arms purely on isolationRoot being set.
- A
PreToolUsehook cannot work around it — the guard runs inside the Bash tool's exec
path, after hooks.
- Workarounds that do pass, for reference:
bash /path/script.sh(bash/sh are not in the
rejected shell-wrapper set, which is eval/source/./exec/trap/let/…), and
python3 script.py.
Environment Info
- Platform: linux
- Terminal: kitty
- Version: 2.1.224
- Feedback ID: a26e6aa2-c682-466f-a74a-b9614769d61a
Errors
[]
3 Comments
Corroborating this on macOS (the report above is Linux), across three occurrences in one
day on a separate codebase, all in worktree-isolated sessions — one via a
/diagnosesub-agent, one via an
Agentspawned withisolation: "worktree". Same refusal text, sameshape: no
gittoken anywhere in the refused command.Adding two datapoints that aren't in the original report.
1. The guard refuses the standard "capture the exit status before the pipe" idioms
This is the one that seems worth escalating past a wording problem, because it makes the guard
select against correct shell practice rather than merely inconvenience it. Both of these were
refused:
Our engineering doctrine mandates exactly these two spellings, for a well-known reason: a piped
exit code is the pager's, not the tool's, so
cmd | tail; echo $?silently reports0for aconfig-load crash. The prescribed fixes are
cmd > out.txt 2>&1; rc=$?or a${PIPESTATUS[0]}read — and the guard refuses both the moment they appear in one Bash call.
The practical effect in a worktree-isolated session is inverted incentives: an agent that
follows the correct practice is blocked, while an agent that pipes bare and misreads the pager's
status runs fine. The remedy text makes it worse here — *"Run the equivalent … without the
redirect"* is, in this case, an instruction to reintroduce the exact bug the redirect exists to
prevent.
Whichever way the guard ends up scoped, it'd be worth explicitly allowing
<cmd> > <path> 2>&1; rc=$?and${PIPESTATUS[0]}reads when<path>resolves inside theworktree or the session scratch directory. They're the shapes any careful agent will keep
emitting.
2. The stated remedy is inapplicable for two of the three refused shapes
python3 - <<'PY' … PY; there is nothing to split. The actual escape (write the script to afile, invoke it by path) isn't mentioned in the message.
forloop reading~/Library/Logs/*.logor
~/Library/LaunchAgents/*.plistcan never be relocated into a worktree — a system logdirectory isn't relocatable. Unrolling the loop was the only escape, at one tool call per
iteration.
Measured cost of that last one: 4 extra Bash round-trips to unroll a single loop, in an
unattended pipeline stage where each round-trip is a full agent turn.
Both suggested directions in the original report look right to us — gating the complexity
refusal on the command plausibly involving git is the sound fix, and failing closed with an
accurate message would at least be actionable in the meantime. As written, the message names
a git operation the command doesn't perform and a redirect it doesn't contain, so a reader who
takes it at face value has no applicable remedy to apply.
Another data point on the heredoc case: the refusal is content-sensitive inside the heredoc body, so it is not simply "a heredoc is present, therefore not
kind: simple".Same command shape throughout.
<script> <path> << 'EOF' … EOF: quoted delimiter, no redirect, no pipeline, nogittoken anywhere. Only the body varies.| heredoc body | result |
|---|---|
|
# testplus a plain markdown line | allowed ||
{ braces only }| allowed ||
{a: b}| allowed ||
"session": "s1", no braces here| allowed ||
{"a"}| refused ||
{ "a": "b" }| refused ||
{"session": "s1"}| refused |The trigger is a brace group whose contents include a double-quoted token. Braces alone pass, quotes alone pass, a quote inside braces does not. Every JSON object matches that shape, so in practice a heredoc carrying JSON is refused while a heredoc carrying markdown is not. The cleanest illustration is a pair from one session, same script, same target directory, seconds apart: the markdown file was written, the JSON file was refused.
Two observations that may narrow it further:
cat > <path> <<'EOF'with a markdown body (a table, in this case) was allowed here, so the presence of a redirect is not what decides it.python3 - <<'PY' … PYwas allowed in this session, twice, with bodies containing both double quotes and braces, for examplejson.dumps(d.get("hooks", {}), indent=2)andprint(f" {k}:", …). In neither case does a quote sit inside a brace group. That is consistent with a body-content trigger rather than with heredoc-fed interpreters being refused as a class, though it could also be a version difference between our runs.Why the shape matters: the delimiter is quoted.
<< 'EOF'performs no expansion of any kind, so the body is inert data by definition and cannot contribute a path, a redirect, or agitinvocation. Descending into it is a false positive by construction, not a conservative approximation.One impact worth adding to the thread: it fails silently under automation. An unattended idle-save routine here writes a markdown state file (allowed), then a JSON metadata file (refused), then touches a completion sentinel. The sentinel is deliberately unconditional so that a partial write still lets the session close cleanly. The result is a session whose state file is present and whose JSON metadata is missing, with no error surfaced anywhere. To a caller that is not watching, the refusal is indistinguishable from ordinary failure.
Narrowest fix that would cover this: do not descend into the body of a heredoc whose delimiter is quoted (
<< 'EOF',<< "EOF",<<\EOF). Unquoted heredocs do expand and are worth inspecting.Claude Code 2.1.226, macOS 15.5 (arm64), zsh.
+1 am having the same issue with go builds that use the shared go pkg cache ~/go
Repro steps
``
``go build ./pkg
go build ./pkg/
go build ./pkg/...
go build ./...
Expected behavior
The command runs normally — it's a single simple command (no
&&,;,|,>, subshells,cdchains) and doesn't invokegit, so it shouldn't be in scope for a git-worktree-boundary guard at all.Actual behavior
Every variant above is refused before execution, with:
Version bisection (for reference)
It seems this was added in 2.1.216
Confirmed via
npm pack @anthropic-ai/claude-code-darwin-arm64@<version>+strings <binary> | grep "too complex to verify":| Version | Published (UTC) | Guard string present |
|---|---|---|
| 2.1.150 | — | No |
| 2.1.200 | 2026-07-... | No |
| 2.1.210 | 2026-07-14 | No |
| 2.1.215 | 2026-07-19 | No |
| 2.1.216 | 2026-07-20 | Yes (first appearance) |
| 2.1.217–2.1.226 | through 2026-08-08 | Yes