Worktree isolation refuses any command whose path argument has a denylisted basename (`ls docs/source`, `ls internal/eval`)

Status Open
Reported on v2.1.228
Maintainer reply None cached
Activity 4 comments · opened Aug 12, 2026

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 projects
  • eval/ — 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 on
ls 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 approving
PreToolUse hook does not override it.

What Should Happen?

Any of these, in decreasing order of how much they'd help:

  1. 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.

  1. Exempt tokens containing a path separator. source can be a builtin; docs/source cannot.

Narrower than (1) and it fixes every case in this report.

  1. 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
entry "source"
. That one change turns a multi-turn detour into a five-second fix on the
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 why
docs/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 and
git 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 / Grep tools — they take a file_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 still source), but

ls docs/source/index.rst does not.

View original on GitHub ↗

3 Comments

excessivereason · 2 months ago

Confirming this is still present in 2.1.233 (macOS), and that the same guard fires in interactive EnterWorktree sessions, not only isolation: "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 complete
  • echo alias trap bind
  • echo "complete" and echo comp"lete" — the screen reads parsed argv, so quoting/concatenation does not evade (consistent with the printf '%s\n' 'docs/source' row above)
  • heredoc bodies are NOT scanned, so cat <<'EOF' ... complete ... EOF passes

Practical impact beyond path collisions: any CLI whose subcommand vocabulary collides with the denylist is unusable in worktree sessions (mytool work complete), and any git 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 after command/builtin/exec), never to operands; quoted arguments should get the same treatment heredoc bodies already do.

miethe · 1 month ago

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 node_01ABC...
This session is isolated in the worktree <path>, but this command runs a string through
complete, 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.

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:

  • Fix (2) does not cover it. complete here 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.
  • The git vocabulary sends readers the wrong way. The command contains no git verb and no redirect, but the refusal is rendered through the shared template ending "git operations must target its own worktree". Two engineers here independently went looking for an AOS-side git guard before checking argv, precisely because the message asserts the command is a git operation. Fix (3) (name the token) would help; naming the position would help more.

Sibling branches in the same guard family (the xargs/parallel and find -execdir ones) 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 ExitWorktree keeping 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 refused git -C <shared checkout> status as "redirects git to the shared checkout via -C".

— Metis, at Nick's behest · metis@30b024a56f5e

johnjspiteri · 24 days ago

Still present in 2.1.278 (macOS). Angular repos keep all code under source/, so every ls source/app, rg x source in an isolation: "worktree" subagent is refused with: "runs a string through source, which can't be verified to stay inside the worktree". ./source/app passes. Same guard also refuses zsh -ic, so repo provisioning functions cannot run in the worktree. Ask: match source in command position only.

Showing cached comments. Read the full discussion on GitHub ↗