fix(tool): apply external_directory: deny to every literal path a bash command names
Issue for this PR
Closes #1645
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
The bash tool applied external_directory only to a fixed list of file commands (cd, rm, cp, mv, mkdir, touch, chmod, chown, cat, ls) and to workdir. Under permission: { external_directory: "deny" } any other command could still read, write or execute outside the project (head /abs/file, echo x > /tmp/x, /host/.venv/bin/python, …).
Code changes are in src/tool/bash.ts, inside altimate_change markers, and reuse the existing tree-sitter parser, literalWord and realpathThroughParents.
When it engages. Only when the effective external_directory rule for * in the call's ruleset (ctx.permissions) is deny. Unset, the default ask and allow skip the new code entirely: same permission requests, same outputs, same metadata keys as main. Among the built-in agents the guard is on only for agents that already deny bash (analyst, data-explorer, compaction, summary, title). A test checks this against the real agent definitions.
What it checks (v2, after review). The guard walks the script itself and tracks the directory each command runs in: cd x && y runs y only in x; cd x; y and cd x || y keep the earlier directory possible; pushd/popd move it like cd; subshells, pipeline stages and substitutions keep changes local. Every literal path the command text names is resolved in its command's directory and through symlinks:
- arguments, including values of
--opt=v,key=v(dd of=) and attached-oPATH, and relative words (so an in-project symlink pointing outside is caught) - a program run by path (wrappers such as
env,nohup,command,time,xargsare followed), except standard system bin dirs - redirection targets, every word of the destination (
cat > out /x), resolved in the directory of the command that owns them - assignment values (prefix, standalone,
export/declare), split on:forPATH-style values forvalues,[[ ]]words,casewords,$'…'without escapes;~userfor another user is refused- literal
sh|bash|zsh|dash|ksh -c '…'strings andeval "…", parsed with the same parser (depth cap 3; deeper is refused) - the directories commands run in (
cd/pushdtargets,workdir)
Not treated as paths: the program or pattern operand of awk/sed/grep/rg (and -e values), standard devices (/dev/null, …), a wrapper's system-binary argument, text whose first path segment does not exist. The decision uses the same rules the file tools use (PermissionEvaluate.evaluateResolved on <dir>/*), so configured allow-lists such as the tool-output directory or an allowed scratch dir keep working; a not-yet-created directory is also matched as itself.
Decision.
deny: refused before anything runs, withexternal_directory is denied: this command touches a path outside the project (<root>): <path>. Use paths inside the project instead.- an
alloworaskrule for that directory: added to the existingexternal_directoryrequest, soaskprompts through the normal permission path. - Permitted out-of-project paths are recorded as
metadata.external_paths. - At most 2000 path candidates per command (more is refused, asking to split the command);
ctx.abortis checked betweenrealpathcalls.
Docs. docs/docs/reference/security-faq.md and docs/docs/configure/permissions.md now describe the deny check, that a * deny also removes the default allow for skill/reference directories and the CLI temp directory (so bundled skill scripts there are refused), and the limits below.
Known limits (guard rail, not an OS sandbox)
- Only literal command text is checked. Paths built at run time are not seen:
$HOME/x,$(pwd)/../x,cd "$DIR", globs, variables set outside the command. - Paths opened by the program itself are not seen:
python -c "open('/etc/hosts')", a script that writes to/tmp, config files that point outside, a barepythonthat an inheritedPATHresolves to an outside virtualenv. (git -C /outsideis refused: the path is a literal argument.) - Heredoc bodies are not scanned as paths (commands substituted inside them are).
- Directory tracking is conservative: after
cd x; y,if cd x; …orcd x || y, both directories are judged, so an in-project..there can be refused. - A word holding spaces is still read as a path (so quoted outside paths are refused); an argument such as
echo "/etc is a dir"may be refused. Fails closed, names the path. external_directory: { "*": "ask" }(the default) keeps today's coverage. Only a*deny turns the guard on; a deny on a narrower pattern alone does not.- The Windows first-segment fix is unit-tested with
path.win32values; not run on Windows.
How did you verify your code works?
test/tool/bash-external-directory.test.ts(now 52 tests), with a ruleset shaped like the agent's and anaskstub that mirrorsPermission.ask:- refused under deny (and nothing written / host interpreter never ran) — v1 cases plus every review reproduction: redirect after
cdfrom a subdirectory workdir, words after a redirection (cat > stolen.txt /o/s.txt,head > /dev/null /o/s.txt),PY=/o/py; $PY,export PATH=/o:$PATH; py,export FOO=/o/s.txt; head "$FOO",OUT=/o/w sh -c …,sort --output=,sort -o/o/w,dd of=,dd if=,echo --out=/etc/hosts,bash -c,sh -c,eval,env bash -lc,head lnk/s.txt,head ./lnk/s.txt,echo x > lnk/w,pushd ../.. && cp …,for,[[ -f ]],[ -f ],case,$'…'argument and program,~root/x,2>,&>,<,<>, a quoted path with spaces - allowed under deny:
cd a && head -c 3 ../inside.txt,cd a && sh ../run.sh,(cd a && head ../inside.txt),cd a && ../run.sh,cd a && echo ok > ../r.txt,pushd a && head ../inside.txt,awk '/var/ {print}',sed -n '/usr/p',grep -c /etc/,grep -e /usr/,cat /dev/null,cat /dev/null > f,ls /dev/null,nohup /usr/bin/true,/usr/bin/env /usr/bin/true,command -v /usr/bin/true, an in-projectbash -c, URLs; an allowed-but-missing directory;firstSegmentPathwithpath.win32(drive, UNC) and POSIX values - unset / default
ask/allow: a fixed set of commands (now alsocat /dev/nullandsort -o<outside>/w.txt) produces the same outputs,external_directoryrequests and metadata keys asmain
- refused under deny (and nothing written / host interpreter never ran) — v1 cases plus every review reproduction: redirect after
test/agent/bash-external-directory-gate.test.ts(4 tests): no built-in agent that can run bash turns the guard on;denyturns it on forbuilder;allowleaves it off;*: askwith a narrower deny leaves it off.- Failing test first: with the new tests on the previous head (
f9c19c74f9merged withmain,bash.tsunchanged), 30 failed: the 27 new refusal cases other than2>/&>/</<>/quoted-space (those were already refused), the allowed-in-project case, the allowed-missing-directory case and the Windows first-segment case. After the fix: 0 fail. - No-regression protocol,
bun test test/tool test/permission test/session test/agent test/permission-task.test.ts(packages/opencode):- before (branch head merged with
main,e0bb5d2f18): 2370 tests, 0 fail.(fail)list: none. - after (
1dd20cd720): 2406 tests, 0 fail.(fail)list: none. - Difference in
(fail)sets: none.
- before (branch head merged with
test/cli/run/run-mode.test.ts,test/release-validation/question-937*,test/skill/release-v0.12.1-adversarial.test.ts: 78 pass, 0 fail.bun run typecheckclean;script/check-tracker-leaks.tsandscript/upstream/analyze.ts --markers --strictpass.
Cost side effects
None. No model calls, queries or resources. Under a configured deny the tool does one realpath and up to two stat calls per literal word (capped at 2000 candidates); otherwise no extra work.
Screenshots / recordings
N/A
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
- I checked for cost side effects (model calls, queries or cloud resources that grow with user data) and described them, or wrote "none"
🤖 Generated with Claude Code
Summary by cubic
The bash tool only applied external_directory permission checks to a fixed list of file commands (cd, rm, cp, cat, ls, …), so under a configured external_directory: deny any other command could still read, write, or execute outside the project. The tool now checks every literal out-of-project path a command names and refuses the call before anything runs.
- Checks absolute,
~, and..arguments, a command run by path, redirection targets, andcdtargets; option values and URLs are skipped. - Denied calls fail with an error naming the path; allowed and
askpaths go through the normalexternal_directorypermission request as before. - Permitted out-of-project paths are recorded as
metadata.external_paths. - Only engages when the effective rule for
*isdeny; unset, defaultask, andallowbehavior is unchanged. - Guard covers only literal command text; runtime-built paths (
$HOME/x,$(pwd)/../x, globs) and paths opened by the program itself are not seen.
Closes #1645.