Worktree-isolation guard refuses any non-simple Bash command, including commands containing no git at all

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 1 comment · opened Aug 22, 2026

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":

  • cEdif (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 matcher
  • Gk_(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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗