[BUG] 2.1.222 escalates #82966 to ordinary sessions: worktree isolation refuses every `source`/`.` via an interpreter denylist, with no opt-out

Status Open
Reported on v2.1.223
Maintainer reply None cached
Activity 8 comments · opened Aug 6, 2026

Preflight Checklist

  • [x] I have searched existing issues — this extends #82966; see the first paragraph below
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code (2.1.223)

What's Wrong?

#82966 already reported that sourcing is refused per se under worktree isolation. This is that
same guard branch, with two things that report does not have: it now fires in ordinary sessions,
not only isolated subagents, and the mechanism turns out to be an interpreter denylist rather than
anything about the path. #82966 was filed 2026-07-31 against isolation: worktree subagents and
describes itself as non-blocking. 2.1.222 widened the guard from isolated agents to the isolated
session (bisected in #84182), so the same refusal now applies to every command an ordinary
claude -w session runs — where it is blocking.

The refused set is not "commands naming a path outside the worktree" and not "commands interpolating
an env var" (that is the separate branch in #84182 / #84452). It is a denylist of shell
interpreters — source, ., eval and 12 more — checked with no regard to path, expansion, or
whether git appears at all. source ./file-inside-the-worktree.sh is refused exactly like
source /some/absolute/path.sh.

The consequence is that any Claude Code plugin that loads shell helpers cannot load them in a
worktree session.
A plugin's lib/*.sh must be sourced for its skills to call the functions
inside; ., eval, and builtin source are all caught by the same check. The only workaround is
structural — convert every sourced helper into an executed script with a stdout contract, which is
what #82966's project did across ~61 scripts.

I could not find an opt-out. The worktree settings object has four keys —
symlinkDirectories, sparsePaths, baseRef, bgIsolation — and I found no settings read in the
guard function. Per #84258 an approving PreToolUse hook does not override a sibling branch of the
same guard either.

What Should Happen?

Any one of these would unblock it, in rough order of how much they'd help:

  1. An opt-out — a fifth worktree settings key, or honoring an approving PreToolUse hook.

Narrowest change, and it covers the whole guard rather than this one branch.

  1. Permit source/. when the argument resolves to a readable file and the command does nothing

else the guard objects to. Reading a file outside the worktree is already permitted (see the
head -1 row below), so sourcing one grants no new filesystem reach.

  1. Permit paths under ${CLAUDE_PLUGIN_ROOT}, which the harness installed itself. Narrower than

(2), but it would leave plugins sourcing anything else still broken.

Separately: the refusal message names a git and redirect concern the command need not have, which
sent both me and #84182's reporter looking for a redirect that did not exist.

Error Messages/Logs

This session is isolated in the worktree <path>, but this command runs a string through source,
which can't be verified to stay inside the worktree; run the command directly instead. Refusing to
run it — a worktree-isolated session's git operations must target its own worktree. Run the
equivalent from <path> without the redirect.

The command contained no redirect and no git operation.

Steps to Reproduce

No plugin needed; the sourced file is created inside the worktree by the repro itself.

1. git init /tmp/repro && cd /tmp/repro && git commit --allow-empty -m root
2. claude -w probe
3. Ask Claude to run:  printf 'echo ok\n' > probe.sh
4. Ask Claude to run:  source ./probe.sh

Step 4 is refused, with the message above. The path is literal, relative, and statically inside the
worktree.

Provenance of each claim, since it matters here. Step 4's outcome is what #82966 observed
directly (their table's first row, a literal-path . of a repo-tracked script). I could not
reproduce step 4 myself — my probe session's own permission classifier blocked source before the
worktree guard was reached — so for the in-worktree case I am relying on #82966's observation plus
the code below, not my own.

What I did observe, on 2.1.223 / macOS, in a claude -w session:

| Command | Result |
|---|---|
| printf '%s\n' "$HOME" | PASS — the variable resolves fine |
| head -1 <absolute path outside the worktree> | PASS — an out-of-worktree read is permitted |
| source "$HOME/<same file as above>" | REFUSED by the worktree guard |
| printf '%s\n' "$TERM" | REFUSED — the separate #84182 branch, for contrast |

Rows 1–3 are the useful triple: the variable resolves, reading that exact file is allowed, and
sourcing that same file is refused. So neither the expansion nor the path is the operative cause.

Claude Model

Not sure / Multiple models — the refusal is emitted by the Bash-tool guard before any model output,
so it should be model-independent.

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.221

Claude Code Version

2.1.223 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS) — zsh login shell

Additional Information

The mechanism, from ~/.local/share/claude/versions/2.1.223. Sets first:

vdr = new Set(["eval","source",".","exec","nocorrect","fc","coproc","trap","enable",
               "mapfile","readarray","hash","bind","complete","compgen","alias","let"])
cfy = new Set(["exec","nocorrect"])
qCd = new Set([...vdr].filter((e) => !cfy.has(e)))     // 15 enforced, incl. "source" and "."

Then the check, quoted in full:

function dfy(e){if(e.length===0)return null;
  let t=e.some((o)=>gFs.test(IQ.basename(o))), r=(o)=>e.some((i)=>IQ.basename(i).toLowerCase()===o),
      n=e.find((o,i)=>{let s=IQ.basename(o).toLowerCase();return s==="."?i===0:qCd.has(s)});
  if(t&&e.some((o)=>lfy.has(IQ.basename(o).toLowerCase())))return"feeds git its arguments from stdin at runtime (xargs/parallel), so the repository it targets cannot be verified";
  if(t&&r("find")&&e.some((o)=>ufy.has(o)))return"changes directory per match (find -execdir/-okdir) before running git, so its repository cannot be verified";
  if(n!==void 0){if(e.filter((i)=>i!==n).length>0)return`runs a string through ${IQ.basename(n)}, which can't be verified to stay inside the worktree; run the command directly instead`}
  return null}

The first two branches are git-gated (t &&, where t is "argv names git"). The third — the
denylist branch — is not.
And its call site inside the per-command loop is unguarded:

let b=dfy(g.argv); if(b!==null) return i(b);

with only the earlier if(e.kind!=="simple") return i(…) ahead of it. So a source with no git
anywhere trips it. The condition is "argv contains a denylisted token (. only at argv[0], source
at any index) and argv has at least one other token" — which every real source X satisfies.

Impact, as a class. Any project or plugin whose shell helpers are sourced rather than executed
cannot run them in a worktree session. Two independent instances so far: #82966's project converted
~61 sourced scripts to executed scripts with a stdout contract to absorb it, and eaisel, a pipeline
plugin, calls EnterWorktree and then sources helper libraries from ${CLAUDE_PLUGIN_ROOT} (134
lib/*.sh in the repo, dozens on a given stage's path) — every load refused, so no stage runs. Its
workaround is to detect the refusal, ExitWorktree(keep), and have the operator relaunch a session
with cwd already inside the worktree, which is never pinned. That is not available headlessly, since
a relaunch needs someone at the prompt.

On the regression fields above. The guard and its string predate 2.1.222; what 2.1.222 changed is
scope, from isolated agents to the isolated session (#84182 extracted the before/after strings from
both binaries, and the observed message says "This session is isolated", wording 2.1.221 cannot
produce). #82966 is evidence the denylist itself refused sourcing for isolated subagents before
that. So 2.1.221 is the last version in which an ordinary session was unaffected, which is the
regression being reported here.

View original on GitHub ↗

3 Comments

brady-eaiti · 24 days ago

A follow-up measurement that isolates this branch from the interpolation branch in #84182, using one
file and one expansion so nothing else varies. All rows measured in a claude -w session on 2.1.223,
macOS:

| Command | Result |
|---|---|
| head -1 /Users/me/.claude/plugins/cache/<plugin>/lib/x.sh | PASS |
| head -1 "$HOME/.claude/plugins/cache/<plugin>/lib/x.sh" | PASS |
| head -1 "$(printf '%s' "$HOME")/.claude/plugins/cache/<plugin>/lib/x.sh" | PASS |
| source "$HOME/.claude/plugins/cache/<plugin>/lib/x.sh" | REFUSED |
| printf '%s\n' "$CLAUDE_PLUGIN_ROOT" | REFUSED (the #84182 branch) |

Rows 2 and 4 are the same path with the same $HOME expansion, differing only in the command. So
$HOME-rooted and $(…)-resolved interpolation both pass the guard for a non-denylisted command,
and the refusal in row 4 is attributable to dfy's denylist branch alone rather than to the
expansion or to the path being outside the worktree. Row 1 shows an out-of-worktree read is
permitted, which is why permitting source of a readable file (option 2 in the original report)
grants no filesystem reach the session does not already have.

Row 5 is a separate finding worth stating: $CLAUDE_PLUGIN_ROOT is not on the resolution allowlist,
while $HOME is. So a plugin cannot reach its own installed files by the variable the harness sets
for that purpose, even for commands this branch permits.

A correction to my own reading of #82966. I cited its
bash "${PLUGIN_ROOT:-plugins/my-plugin}/skills/x/scripts/step.sh" row as evidence that
interpolated execution is refused. That is not what it shows: PLUGIN_ROOT is unset there, which is
exactly the shape #84182 measured as refused (echo "$SOME_UNSET_VAR" → refused). The rows above
suggest execution with an allowlisted or command-substituted root would pass. I could not confirm
that directly — every bash <script> attempt was stopped by my probing session's own permission
classifier before the worktree guard was reached — so I am flagging it as untested rather than
asserting it.

That matters for anyone working around this rather than waiting on it: converting sourced helpers to
executed scripts (what #82966's project did across ~61 scripts) appears to be a viable workaround,
but only with a root spelled literally, via $HOME, or via $(…) — not via ${VAR:-default} with
VAR unset, and not via $CLAUDE_PLUGIN_ROOT.

z-image · 21 days ago

Hit this today on 2.1.226 (Linux), in a normal claude -w session.

It blocked most of my work. I have tools that are shell functions in ~/.bashrc, so my commands start with source ~/.bashrc. Every one of them was refused. Plain for loops were refused too.

What works for me: wrap the command in bash -lc '...'.

| Command | Result |
|---|---|
| for n in 1 2; do echo hello; done | pass |
| for n in 1 2; do echo "hello $n"; done | refused |
| source /etc/os-release; echo "$ID" | refused |
| bash -lc 'source /etc/os-release; echo "ID=$ID"' | pass -- prints ID=ubuntu |

The last row does both refused things at once, and it runs fine. So the guard only reads the text of the command. It does not look at what the shell will actually do.

I added this to my ~/.claude/CLAUDE.md so sessions stop hitting it:

Worktree isolation refuses source and shell variables -- broke in 2.1.222, still broken in 2.1.226. In a claude -w session, any command that sources a file or uses a shell variable (including for loops) is refused. The error message talks about git and paths, which is misleading. Wrap the command in bash -lc '...'.

Two notes:

  • You can get the same access by wrapping, so the check does not actually prevent anything. It only adds friction. (Same point as row 1 in the comment above.)
  • Rows 1 and 2 are a small control for #84182: same loop, same variable, the only difference is whether $n is used. A variable that the command itself creates is enough to trigger it.
chopetdee · 18 days ago

Filed #86036 for the path-argument branch of this same zob() denylist — a token like docs/source or internal/eval is refused purely because basename(token) matches an entry, which is a different false positive from the sourcing behaviour this issue and #82966 are about, and is fixable without touching that policy question.

Showing cached comments. Read the full discussion on GitHub ↗