Worktree isolation refuses any command whose path argument has a denylisted basename (`ls docs/source`, `ls internal/eval`)
Preflight Checklist
- [x] I have searched existing issues — this is the same guard function as #82966 / #84530, but a
different false-positive class; see "Relationship to #84530" below
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.228)
What's Wrong?
Under worktree isolation, the Bash guard's interpreter denylist is applied to every argv token by
basename, not to the command position. So an ordinary path argument whose last segment happens
to be a denylisted word is refused as though it were an interpreter invocation.
ls docs/source is refused. ls docs/source/index.rst is allowed. Same directory, same command —
the only difference is whether the final path segment is the literal source.
The enforced set is 15 tokens: eval, source, . (argv[0] only), fc, coproc, trap,enable, mapfile, readarray, hash, bind, complete, compgen, alias, let. Several are
ordinary directory names in the wild:
docs/source/— the default Sphinx layout, so a large fraction of Python projectseval/— evaluation harnesses in ML/LLM projects (both of ours: a Go package and a React feature dir)bind/,hash/,trap/,alias/,let/,enable/,complete/
The refusal text names only the shell builtin, so it reads as a sandbox bug rather than a path
collision, and the operator goes looking for an eval/source that is not in their command.
In our repo, agents dispatched with isolation: "worktree" burned multiple turns onls code/web/src/features/eval before anyone suspected the folder name; the message gave no hint
that a path argument was the trigger.
It is a hard refusal, not a prompt — there is no "allow" answer, and per #84258 an approvingPreToolUse hook does not override it.
What Should Happen?
Any of these, in decreasing order of how much they'd help:
- Apply the denylist at command position only — argv[0], or the first token after a wrapper
(command, builtin, env, time, nice, noglob). The same function already does exactly
this for . (s === "." ? i === 0 : T7d.has(s)), so the position-aware precedent is one
character away. A denylisted builtin is never spelled as a token in operand position.
- Exempt tokens containing a path separator.
sourcecan be a builtin;docs/sourcecannot.
Narrower than (1) and it fixes every case in this report.
- Name the offending token in the message, independently of (1)/(2). Today:
runs a string through source. Better: argument "docs/source" matched the interpreter denylist. That one change turns a multi-turn detour into a five-second fix on the
entry "source"
operator's side even if the match itself stays.
Error Messages/Logs
This agent 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 agent's git operations must target its own worktree.
The command was ls docs/source. It contains no interpreter, no git, and no redirect.
Steps to Reproduce
No plugin needed.
1. git init /tmp/repro && cd /tmp/repro && git commit --allow-empty -m root
2. mkdir -p /tmp/repro/docs/source && touch /tmp/repro/docs/source/index.rst
3. Dispatch a subagent with isolation: "worktree" (or start `claude -w`)
4. Ask it to run: ls docs/source -> REFUSED, message above
5. Ask it to run: ls docs/source/index.rst -> PASSES
Step 5 is the control: identical command shape, identical directory, allowed — so the trigger is the
final path segment, not the reachability of the path.
Claude Model
Not sure / Multiple models — the refusal is emitted by the Bash-tool guard before any model output,
so it is model-independent.
Is this a regression?
No, this has never worked
Claude Code Version
2.1.228 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS) — zsh login shell
Additional Information
Mechanism, verified in the shipped 2.1.228 binary (~/.local/share/claude/versions/2.1.228).
The set:
svr = new Set(["eval","source",".","exec","nocorrect","fc","coproc","trap","enable",
"mapfile","readarray","hash","bind","complete","compgen","alias","let"])
job = new Set(["exec","nocorrect"])
T7d = new Set([...svr].filter((e) => !job.has(e))) // 15 enforced
The check (zob, the 2.1.228 name for the dfy quoted in #84530):
let n = e.find((o, i) => {
let s = Are.basename(o).toLowerCase();
return s === "." ? i === 0 : T7d.has(s)
});
...
if (n !== void 0) {
if (e.filter((i) => i !== n).length > 0)
return `runs a string through ${Are.basename(n)}, which can't be verified to stay inside the worktree; run the command directly instead`
}
e is g.argv — all tokens, not just argv[0] — and the test is basename(token), which is whydocs/source matches and docs/source/index.rst does not. The .filter(...).length > 0 guard only
requires the command to have at least one other token, which every real command does.
Behaviour confirmed by direct probing, not only by reading the binary. Every line below was run
inside a live isolation: "worktree" dispatch on 2.1.228:
| Command | Result |
|---|---|
| ls code/web/src/features/eval | refused — …through eval |
| wc -l code/web/src/features/eval/evalReport.ts | allowed (same directory, one segment deeper) |
| ls docs/source | refused — …through source, and docs/ does not exist in that repo, so the screen is purely lexical and runs before any filesystem access |
| ls docs/SOURCE | refused — the match is case-insensitive, but the message echoes the original casing (SOURCE) |
| ls code/web/src/features/eval/ | refused — a trailing slash does not help; basename() strips it |
| printf '%s\n' 'docs/source' | refused — quoting does not evade; the screen reads parsed argv |
| printf '%s\n' --path=docs/source | refused — basename() splits on the last / regardless of the =, so --flag=path/source matches too |
| printf '%s\n' eval | refused — a bare non-path word matches as well; the rule is basename(token), and for a token with no / that is the token itself |
| printf '%s\n' evaluation-harness-evals | allowed — evals / evaluate / evaluation never collide; only an exact basename match does |
| ls -d . | allowed — . is position-gated to argv[0], exactly as the source shows |
| hash (alone, no arguments) | allowed — .filter(...).length > 0 needs at least one other token |
| ls -d docs/exec, ls -d docs/nocorrect | allowed (reached the filesystem: No such file or directory) — confirming exec / nocorrect are in the raw set but excluded from the enforced one |
| the other 12 tokens as ls -d docs/<token> | all refused, each naming its own token |
So the enforced set is exactly the 15 above, and it is matched per-token by basename with no
position, path-shape, or quoting exemption other than the . special case.
The git-gated branch a few lines up has the same shape, and therefore the same latent false
positive:
let b = d[h] ? g.argv.find((j, G) => {
let J = Are.basename(j).toLowerCase();
return J === "." ? G === 0 : T7d.has(J)
}) : void 0;
if (b !== void 0) return i(`runs ${Are.basename(b)} before a git command, whose string payload can't be verified to leave the worktree alone`)
In practice on 2.1.228 the non-git branch wins the race: git ls-files docs/source andgit ls-files code/web/src/features/eval both return the generic runs a string through …
wording, not the git-specific one. So the git branch is a latent duplicate of the same defect rather
than a separately reachable message — fixing the match fixes both.
Relationship to #84530 and #82966. Same function, different branch of the same defect. Both of
those report that actually sourcing a shell helper is refused — a true positive on the token with
a disputed policy. This report is the opposite: a token that is not an interpreter invocation at
all, refused because a directory shares its name. Fix (1) or (2) above resolves this report without
touching the policy question those two are arguing about, so it should be separable.
Workarounds, for anyone who lands here from a search. All verified in a live worktree-isolated
dispatch:
- Glob the final segment so the literal basename never appears:
ls docs/sour*,ls internal/ev*. - Bracket a character in a regex when a glob would over-match:
git ls-files | grep -E '(^|/)ev[a]l/'. - Prefer the
Read/Edit/Write/Glob/Greptools — they take afile_path/path
argument rather than a shell string and are not screened at all.
- Append a further path segment:
ls docs/source/still trips (basename is stillsource), but
ls docs/source/index.rst does not.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Confirming this is still present in 2.1.233 (macOS), and that the same guard fires in interactive
EnterWorktreesessions, not onlyisolation: "worktree"subagent dispatches — there the refusal text ends "a worktree-isolated session's git operations must target its own worktree".Additional bare-word data points, all refused in argument position on 2.1.233:
echo completeecho alias trap bindecho "complete"andecho comp"lete"— the screen reads parsed argv, so quoting/concatenation does not evade (consistent with theprintf '%s\n' 'docs/source'row above)cat <<'EOF' ... complete ... EOFpassesPractical impact beyond path collisions: any CLI whose subcommand vocabulary collides with the denylist is unusable in worktree sessions (
mytool work complete), and anygit commit -m "..."/gh issue comment --body "..."whose message text contains one of the 15 words is refused. Expected behavior matches fix (1): apply the check at command position only (argv[0], or the word aftercommand/builtin/exec), never to operands; quoted arguments should get the same treatment heredoc bodies already do.Still present in 2.1.235 (macOS) — moves the confirmed floor up two versions from the 2.1.233 report above. Also present unchanged in 2.1.232 and 2.1.234, so the behaviour has held across four consecutive releases since this was filed against 2.1.228.
Not restating the mechanism — it is correct as written above. One concrete instance of the "CLI whose subcommand vocabulary collides with the denylist" impact, because it lands somewhere unusually awkward:
itt node complete <id>is the close-out step of the task-tracker protocol our worktree sessions run under — the command that records the work as finished. So the guard permits the entire body of work inside the worktree and then refuses the one call that marks it done. A session that has finished cleanly cannot report that it finished, which is a worse failure shape than being blocked up front: the tempting move is to leave the item open, or to reach for something that hides the token from the screen.Two details from this instance that bear on the proposed fixes:
completehere is a bare subcommand word with no path separator, so exempting tokens containing/leaves this class refused. Only fix (1) — denylist at command position — resolves it, which is worth weighing if (2) looks like the cheaper partial.Sibling branches in the same guard family (the
xargs/parallelandfind -execdirones) require a git token to be present before they refuse. This branch does not, which is the structural reason a command with no git verb reaches it at all.For anyone hitting this before it is fixed: the workaround is to route the call through a non-Bash path (an MCP tool for the same endpoint, if one exists), or
ExitWorktreekeeping the worktree and run it from the primary checkout. Do not try to spell the token past the screen — the rest of that guard is doing real work; in the same session it correctly refusedgit -C <shared checkout> statusas "redirects git to the shared checkout via -C".— Metis, at Nick's behest · metis@30b024a56f5e
Still present in 2.1.278 (macOS). Angular repos keep all code under
source/, so everyls source/app,rg x sourcein anisolation: "worktree"subagent is refused with: "runs a string through source, which can't be verified to stay inside the worktree"../source/apppasses. Same guard also refuseszsh -ic, so repo provisioning functions cannot run in the worktree. Ask: matchsourcein command position only.