[Bug] Worktree isolation guard rejects non-git commands due to early AST complexity check

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

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 PreToolUse hook 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

[]

View original on GitHub ↗

3 Comments

oconnorjoseph · 23 days ago

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 /diagnose
sub-agent, one via an Agent spawned with isolation: "worktree". Same refusal text, same
shape: no git token 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:

<lint-command> <file> 2>&1 | tail -20; echo "exit: ${PIPESTATUS[0]}"
awk '/pattern/,/pattern/' <path>/script.sh | sed '$d' > "$SCRATCH/out.sh"

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 reports 0 for a
config-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 the
worktree 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

  • Heredoc-fed interpreters. "Break it into plain, separate commands" has no meaning for

python3 - <<'PY' … PY; there is nothing to split. The actual escape (write the script to a
file, invoke it by path) isn't mentioned in the message.

  • Paths outside the worktree by construction. A for loop reading ~/Library/Logs/*.log

or ~/Library/LaunchAgents/*.plist can never be relocated into a worktree — a system log
directory 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.

watsonix · 18 days ago

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, no git token anywhere. Only the body varies.

| heredoc body | result |
|---|---|
| # test plus 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.
  • On the "heredoc-fed interpreters" point in the comment above: python3 - <<'PY' … PY was allowed in this session, twice, with bodies containing both double quotes and braces, for example json.dumps(d.get("hooks", {}), indent=2) and print(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 a git invocation. 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.

martindmtrv · 17 days ago

+1 am having the same issue with go builds that use the shared go pkg cache ~/go

Repro steps

  1. Start a Claude Code session whose cwd is a git worktree (not the primary checkout) — i.e. a "worktree-isolated" session.
  2. Ask the agent to run any of the following via the Bash tool:

``
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, cd chains) and doesn't invoke git, 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:

This session is isolated in the worktree /path/to/.claude/worktrees/<name>, 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 /path/to/.claude/worktrees/<name> without the redirect.

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