fix(tool): apply external_directory: deny to every literal path a bash command names

Draft
RA
2h ago
+1000-8

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, xargs are 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 : for PATH-style values
  • for values, [[ ]] words, case words, $'…' without escapes; ~user for another user is refused
  • literal sh|bash|zsh|dash|ksh -c '…' strings and eval "…", parsed with the same parser (depth cap 3; deeper is refused)
  • the directories commands run in (cd/pushd targets, 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, with external_directory is denied: this command touches a path outside the project (<root>): <path>. Use paths inside the project instead.
  • an allow or ask rule for that directory: added to the existing external_directory request, so ask prompts 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.abort is checked between realpath calls.

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 bare python that an inherited PATH resolves to an outside virtualenv. (git -C /outside is 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; … or cd 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.win32 values; 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 an ask stub that mirrors Permission.ask:
    • refused under deny (and nothing written / host interpreter never ran) — v1 cases plus every review reproduction: redirect after cd from 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-project bash -c, URLs; an allowed-but-missing directory; firstSegmentPath with path.win32 (drive, UNC) and POSIX values
    • unset / default ask / allow: a fixed set of commands (now also cat /dev/null and sort -o<outside>/w.txt) produces the same outputs, external_directory requests and metadata keys as main
  • test/agent/bash-external-directory-gate.test.ts (4 tests): no built-in agent that can run bash turns the guard on; deny turns it on for builder; allow leaves it off; *: ask with a narrower deny leaves it off.
  • Failing test first: with the new tests on the previous head (f9c19c74f9 merged with main, bash.ts unchanged), 30 failed: the 27 new refusal cases other than 2>/&>/</<>/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.
  • 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 typecheck clean; script/check-tracker-leaks.ts and script/upstream/analyze.ts --markers --strict pass.

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, and cd targets; option values and URLs are skipped.
  • Denied calls fail with an error naming the path; allowed and ask paths go through the normal external_directory permission request as before.
  • Permitted out-of-project paths are recorded as metadata.external_paths.
  • Only engages when the effective rule for * is deny; unset, default ask, and allow behavior 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.