[BUG] 2.1.222 escalates #82966 to ordinary sessions: worktree isolation refuses every `source`/`.` via an interpreter denylist, with no opt-out
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 ordinaryclaude -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 likesource /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:
- An opt-out — a fifth
worktreesettings key, or honoring an approvingPreToolUsehook.
Narrowest change, and it covers the whole guard rather than this one branch.
- 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.
- 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} (134lib/*.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.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
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 -wsession 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
$HOMEexpansion, 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 theexpansion or to the path being outside the worktree. Row 1 shows an out-of-worktree read is
permitted, which is why permitting
sourceof 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_ROOTis not on the resolution allowlist,while
$HOMEis. So a plugin cannot reach its own installed files by the variable the harness setsfor 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 thatinterpolated execution is refused. That is not what it shows:
PLUGIN_ROOTis unset there, which isexactly the shape #84182 measured as refused (
echo "$SOME_UNSET_VAR"→ refused). The rows abovesuggest 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 permissionclassifier 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}withVARunset, and not via$CLAUDE_PLUGIN_ROOT.Hit this today on 2.1.226 (Linux), in a normal
claude -wsession.It blocked most of my work. I have tools that are shell functions in
~/.bashrc, so my commands start withsource ~/.bashrc. Every one of them was refused. Plainforloops 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 -- printsID=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.mdso sessions stop hitting it:Two notes:
$nis used. A variable that the command itself creates is enough to trigger it.Filed #86036 for the path-argument branch of this same
zob()denylist — a token likedocs/sourceorinternal/evalis refused purely becausebasename(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.