Worktree-isolation guard refuses any non-simple Bash command, including commands containing no git at all
Version: 2.1.220 (repro), confirmed still present in 2.1.224
Platform: macOS 15 (darwin arm64)
Summary
When a subagent runs with isolation: "worktree", the git-redirection guard refuses every Bash command whose bash AST does not classify as simple — before checking whether the command involves git at all. A pipeline, a for loop, or a command substitution is enough. The result is that ordinary shell one-liners with no git in them are blocked with a message about git operations.
Repro
From a subagent spawned with isolation: "worktree", run any of:
bin/lint.sh 2>&1 | tail -15; echo "EXIT=${PIPESTATUS[0]}"
for n in 91 89 68; do gh pr view $n --json number,title; done
T=$(mktemp -d); cd "$T"; git init -q; git commit -qam base
Expected: commands 1 and 2 run (no git anywhere in them); command 3 runs (the repo is a throwaway under TMPDIR, not the protected checkout).
Actual: all three refused with
This agent is isolated in the worktree \<path\>, 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 agent's git operations must target its own worktree. Run the equivalent from \<path\> without the redirect.
Cause
In the guard (Ted in 2.1.220, VHd in 2.1.224), the too-complex bail is the first branch, so the ~120 lines of actual git analysis below it — -C, --git-dir, --work-tree, GIT_DIR and friends — never run for these commands:
if (e.kind !== "simple")
return n("is too complex to verify that it stays inside the worktree; break it into plain, separate commands");
The classifier (tPt) marks anything that is not a flat list of command nodes as too-complex: pipelines, loops, subshells, command substitution, non-trivial redirects.
Why this looks like an inconsistency rather than a design choice
Two other consumers of the same tPt output in the same bundle treat too-complex as "analysis unavailable, fall through" rather than "refuse":
cEd→if (o.kind === "too-complex") return { behavior: "passthrough", ... }sW_(permission rule matching) → same, falls back to the ask path
Only the worktree guard escalates an unparseable command to a hard refusal.
Separately, tPt returns a reason field (e.g. "Parser did not consume trailing input") that the guard discards, so the refusal is undiagnosable — the agent cannot tell which construct tripped it and resorts to trial and error.
Suggested fix
Short-circuit before the too-complex bail when the command cannot touch git. Both predicates already exist in the same module:
aCs = /^git(?:\.exe|\.real|-[a-z][\w-]*)?$/i— git-binary matcherGk_(name)— GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY, GIT_INDEX_FILE, GIT_SHALLOW_FILE, GIT_CONFIG*, HOME, CDPATH, XDG_CONFIG_HOME
If no token in the raw command text matches aCs and no assignment matches Gk_, return null instead of refusing. A command with no git invocation and no git-redirecting env var cannot redirect git into the shared checkout. Being text-level rather than AST-level, this holds precisely in the case that currently fails — where the AST could not be parsed.
Secondarily: surface tPt's reason in the refusal message so the offending construct is identifiable.
Impact
Measured 9 refusals in a single session across 4 worktree-isolated agents. Each one costs a round trip, and the workaround is to write the logic to a script file and invoke it — which the guard permits, so the isolation property is unchanged. The guard is currently taxing agents without denying anything it was built to deny.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗