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.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗