[Bug] Worktree isolation refuses Bash commands that interpolate an environment variable, with no path, redirect or git involved (2.1.222)

Status Closed — not planned
Reported on v2.1.222
Maintainer reply None cached
Activity 3 comments · opened Aug 6, 2026 · closed Aug 6, 2026
Refiled from #84135, which had identical content. Automated triage timed out on that issue (run 31000606713), leaving it with no labels, and the label pass only runs on the issues event — so it could not be re-triggered. #84135 has been closed in favour of this one.

Summary

Since 2.1.222, the worktree-isolation verifier refuses top-level Bash commands
that interpolate an environment variable — including commands that make no
network call, name no path, contain no redirect and touch no git state. The
refusal message describes a git/path concern the command does not have.

2.1.222 is what makes this reachable for ordinary sessions:

Fixed worktree-isolated sessions and their subagents running destructive git commands against main checkout; isolation now applies to file edits and Bash in every session type

Reproduction

One line, in any session started with claude -w <name>:

printf '%s\n' "$TERM"

Refused, verbatim:

This session is isolated in the worktree <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 session's git
operations must target its own worktree. Run the equivalent from
<worktree-path> without the redirect.

printf '%s\n' "literal" runs in the same session, so the session is not simply
unable to run Bash.

A/B across versions, from a clean slate

The script below gives each probe a fresh throwaway git repo, a fresh -w
worktree session, and an environment with every CLAUDE_* variable stripped, so
no existing session or checkout can influence the outcome. Each version is run
with its own control.

| | printf "%s\n" "$TERM" | printf "%s\n" "literal" |
|---|---|---|
| 2.1.221 | xterm-256color | literal |
| 2.1.222 | refused | literal |

Actual output:

--- 2.1.221 / env
xterm-256color

--- 2.1.222 / env
This session is isolated in the worktree /tmp/claude-iso-repro-XXXXXX/2.1.222-env/.claude/worktrees/isoprobe, but this command is too complex to verify that it stays inside the worktree; …
>>> 2.1.222 env: REFUSED by the isolation verifier

Further observations, offered without a proposed rule

These were reproduced in a 2.1.222 -w session. They do not add up to a
mechanism I can state, so they are listed as data rather than as an explanation:

| Command | Result |
|---|---|
| printf '%s\n' "$TERM" | refused |
| printf '%s\n' "$RANDOM" | refused |
| printf '%s\n' "literal" | runs |
| ls -la "$(readlink -f "$(command -v curl)")" | refused |
| curl -sS -o /dev/null -w '%{http_code}\n' "https://example.com/?probe=$TERM" | refused |
| curl -sS -o /dev/null -w '%{http_code}\n' "https://example.com/?probe=$RANDOM" | runs |
| curl -sS -o /dev/null -w '%{http_code}\n' -u "$USER:x" https://example.com/ | refused |
| export A=alice B=s3cret; curl … -u "$A:$B" https://example.com/ | runs |

The last two rows and the two $RANDOM rows are the awkward pairs: the same
variable is accepted in one argument position and refused in another, and an
in-command assignment is accepted where an inherited value is not.

<details>
<summary>repro.sh</summary>

#!/usr/bin/env bash
# Reproduce the worktree-isolation verifier refusing a command that interpolates
# an environment variable. Self-contained: every probe gets a fresh throwaway git
# repo, a fresh `-w` worktree session, and an environment with all CLAUDE_*
# variables stripped.
#
# The probe makes no network call, names no path and touches no git state — it
# prints one environment variable.
#
# Usage:
#   repro.sh                      # defaults: 2.1.221 2.1.222
#   repro.sh 2.1.222              # single version
#   repro.sh /path/to/claude ...  # explicit binaries (any install layout)
#
# Version names resolve under $CLAUDE_VERSIONS_DIR, defaulting to the native
# installer's layout. Arguments containing a `/` are used as-is.
set -uo pipefail

VERSIONS_DIR="${CLAUDE_VERSIONS_DIR:-$HOME/.local/share/claude/versions}"

probe_env='printf "%s\n" "$TERM"'
probe_plain='printf "%s\n" "literal"'

unset_args=()
while IFS= read -r name; do
  [[ -n "$name" ]] && unset_args+=(-u "$name")
done < <(env | sed -n 's/^\(CLAUDE[^=]*\)=.*/\1/p')

targets=("$@")
if [[ "${#targets[@]}" -eq 0 ]]; then
  targets=(2.1.221 2.1.222)
fi

workdir=$(mktemp -d -t claude-iso-repro-XXXXXX)
cleanup() { chmod -R u+w "$workdir" 2>/dev/null; rm -rf "$workdir"; }
trap cleanup EXIT

echo "clean slate: $workdir"
echo

run_probe() {
  local bin="$1" tag="$2" label="$3" command="$4"
  local repo="$workdir/$tag-$label"

  mkdir -p "$repo"
  git -C "$repo" init --quiet
  git -C "$repo" -c user.email=repro@example.com -c user.name=repro \
      commit --quiet --allow-empty -m root

  local prompt="Run exactly this bash command, unchanged, with the Bash tool:

$command

Then reply with only the raw tool result: its output if it ran, or the verbatim
error text if it was refused. No commentary."

  local out
  out=$( cd "$repo" && env "${unset_args[@]}" \
           "$bin" -w isoprobe -p "$prompt" --allowedTools Bash 2>&1 )

  echo "--- $tag / $label"
  printf '%s\n' "$out"

  if printf '%s' "$out" | grep -qF "too complex to verify"; then
    echo ">>> $tag $label: REFUSED by the isolation verifier"
  else
    echo ">>> $tag $label: not refused by the verifier (see output)"
  fi
  echo
}

for target in "${targets[@]}"; do
  if [[ "$target" == */* ]]; then bin="$target"; else bin="$VERSIONS_DIR/$target"; fi

  echo "=============================================================="
  echo "target: $target"
  echo "=============================================================="

  if [[ ! -x "$bin" ]]; then
    echo "SKIPPED: no executable at $bin"
    echo
    continue
  fi
  echo "binary reports: $("$bin" --version 2>&1 | head -1)"
  echo

  run_probe "$bin" "$target" env   "$probe_env"
  run_probe "$bin" "$target" plain "$probe_plain"
done

Note for anyone extending this: a probe that sends an environment variable to a
third-party host is caught by other guards — the auto-mode classifier on
2.1.221, and the agent's own refusal on 2.1.222 — which makes it useless for
isolating this behaviour. Hence the local printf.

</details>

Why it matters

  • Commands that must read a secret from the environment become inexpressible.

The suggested rewrite cannot help: splitting the command does not remove the
expansion the verifier objects to. What remains is inlining the secret into the
command text, or a wrapper script per call site.

  • The advice misleads. "Break it into plain, separate commands" and "without

the redirect" send the reader hunting for a path, redirect or git problem that
is not there — and the accepted/refused pairs above are invisible without
probing for them.

  • The blast radius grew silently. Previously confined to subagents declaring

isolation: "worktree", this now reaches ordinary interactive -w sessions
whose users never opted into isolation.

Ask

  1. Do not treat an interpolation as a potential worktree escape when the command

has no filesystem, git or redirect surface at all.

  1. Make the outcome consistent across argument positions, or document what

distinguishes the accepted cases from the refused ones.

  1. Make the refusal name the construct that triggered it, and drop the "redirect"

and "git operations" wording when neither is present.

Environment

Claude Code 2.1.221 and 2.1.222, Linux, native installer layout. Both versions
exercised by the script above from throwaway repos; no pre-existing worktree or
session involved.

Caveat: $HOME is the real one, so the user-level ~/.claude/settings.json still
applies. A temp $HOME would be a stricter clean slate but leaves the nested
sessions unauthenticated.

Related

#82966 reports the same
verifier and wording, but for path-bearing commands (sourcing tracked
scripts, "${VAR:-default}/path" execution) in subagents that explicitly declare
isolation: "worktree". This report is about commands with no path at all, in
sessions isolated implicitly as of 2.1.222.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗