Skip to content

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

Draft
RaghwendraSingh wants to merge 4 commits into
mainfrom
fix/bash-external-directory
Draft

RaghwendraSingh wants to merge 4 commits into
mainfrom
fix/bash-external-directory

Conversation

@RaghwendraSingh

@RaghwendraSingh RaghwendraSingh commented Nov 3, 2026 •

Copy link
Copy Markdown

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.

Written for commit f9c19c7. Summary will update on new commits.

View guided diff Turn on auto-fix

…and names

The bash tool only checked a fixed list of file commands (`cat`, `ls`, `cd`,
`rm`, ...) against `external_directory`, so under a configured deny a command
could still read, write or execute outside the project: `head /abs/file`,
`echo x > /tmp/x`, `/host/.venv/bin/python`, `cd /outside && ...`.

- When the effective `external_directory` rule for `*` is `deny`, also check
  absolute, `~` and `..` arguments, a command run by path, redirection targets
  and every directory a command runs in, using the existing tree-sitter walk
- A denied path refuses the call with an error naming the path; a path the
  config allows (or sets to `ask`) goes through the normal permission request
- Permitted out-of-project paths are recorded as `metadata.external_paths`
- Unset, the default `ask`, and `allow` keep today's behaviour unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@RaghwendraSingh

Copy link
Copy Markdown
Author

Mock-gateway capture: main vs this branch with external_directory: deny

Seven headless altimate-code runs per build against a local OpenAI-compatible mock (no real gateway), each in a throwaway project with a primary agent sandboxed (tools: {"*": false, "bash": true}, permission: {"*": "deny", "bash": "allow", "external_directory": "deny"}). The mock drives one bash call per run. <sibling> is a directory next to the project holding marker.txt; <venv>/bin/python is an executable outside the project that prints FAKE_VENV_PYTHON. main = 2d59604e66; branch = f9c19c74f9.

# command main this branch
1 echo inside > notes.md (inside the project) executed executed
2 cat /etc/hosts refused (old command-list check) refused, names /private/etc/hosts
3 ls ~ refused (old check) refused, names the home directory
4 echo x > /tmp/sandbox-evidence-leak.txt executed — file written outside the project refused, names /private/tmp/sandbox-evidence-leak.txt; file absent afterwards
5 cd <sibling> && cat marker.txt refused (old check) refused
6 <venv>/bin/python --version executed — printed FAKE_VENV_PYTHON refused, names the interpreter path
7 cat <sibling>/marker.txt refused (old check) refused

So main already blocks the file commands it knows about (cat, ls, cd), with a generic "rule prevents this tool call" error; what escaped were redirection targets and programs run by path, which is exactly what our sandboxed headless runs did (/tmp scratch files, the host checkout's .venv/bin/python). This branch refuses those too, with an error that names the offending path, and does not touch the in-project command.

@sahrizvi sahrizvi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The guard is a real improvement, but literal paths still escape and in-project commands get refused.

Checked at head f9c19c74f9:

  • The 20 new tests pass. On the base, 13 of the 17 tool tests fail and the 4 unchanged-behaviour tests pass, as the PR says.
  • test/session, test/tool and test/agent on a heavily loaded machine: no test fails on head that does not also fail on the base (54 on head, 55 on the base).
  • When the rule is unset, ask or allow, all new code sits behind guarded, so requests and metadata match main.
  • The redirection and program-by-path cases from the mock-gateway table are refused with the path named.
  • Also refused when run: quoted paths, 2>, &>, <, <>, heredoc redirects, process and command substitution, find -exec, literal xargs arguments, and the /private and /System/Volumes/Data aliases.
  • URLs, git remotes, heredoc bodies, the tool-output directory and allowed scratch directories still work.

Why changes are needed:

  • Writes outside the project still run through a redirect after cd, words after a redirection, option values and in-project symlinks.
  • The host interpreter still runs through an assignment.
  • Common commands such as cd a && head ../inside.txt are now refused.
  • The guard appears to do nothing on Windows.

Some of these forms overlap limits the PR lists. Each comment says where. Fixing them or narrowing the title and docs are both acceptable for those; the redirect cwd, cd false refusal, trailing-word and Windows findings need code changes.

None of the sibling PRs (#1631, #1632, #1639, #1641, #1643) touch bash.ts or the permission code, so I see no conflict. Docs and nits are in a separate PR comment.

Comment thread packages/opencode/src/tool/bash.ts Outdated
if (node.type === "file_redirect") {
const destination = node.childForFieldName("destination") ?? node.namedChildren.at(-1) ?? null
const target = literalWord(destination)
if (target && namesPath(target)) candidates.add(path.resolve(cwd, target))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: redirection targets are resolved against the initial cwd, so .. targets are wrong in both directions.

Run at head under external_directory: deny:

  • Workdir <project>/a/b: cd ../.. && echo pwn > ../marker executed and wrote next to the project. The target was judged as <project>/a/marker.
  • cd a && echo ok > ../r.txt lands inside the project but is refused, naming the project's parent directory.

Fix: resolve each file_redirect against the cwds of the command that owns it, for example by collecting redirects inside the shellCommands walk instead of a separate stack walk.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: redirects are collected in the guard's own walk and resolved in the owning command's directory (the parser attaches a && b > f to the list, so it goes to the last command); both of your cases are tests.

Comment thread packages/opencode/src/tool/bash.ts Outdated
const arg = literalWord(word)
// Option values (`--out=/x`) and URLs are not read as paths.
if (!arg || arg.startsWith("-") || !namesPath(arg)) continue
for (const commandCwd of cwds) candidates.add(path.resolve(commandCwd, arg))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: .. arguments after a cd inside the project are refused, a regression under deny.

afterCd keeps the previous cwd because cd may fail, and each candidate cwd is checked here. That only added aliases before; it is now a hard refusal for every command.

Run at head, all refused although they stay in the project, and head ran on main:

  • cd a && head -c 3 ../inside.txt
  • cd a && sh ../inside.txt
  • (cd a && head ../inside.txt)
  • cd a && ../nope.sh

cd src && python ../script.py is an ordinary form for a sandboxed run. Fix: after cd x && the earlier cwd is unreachable, so drop it for this check. Add a test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: after cd x && only x is judged (;/|| keep the earlier directory); your four forms plus cd a && echo ok > ../r.txt are allowed-case tests.

Comment thread packages/opencode/src/tool/bash.ts Outdated
while (stack.length) {
const node = stack.pop()!
if (node.type === "file_redirect") {
const destination = node.childForFieldName("destination") ?? node.namedChildren.at(-1) ?? null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: words after a redirection are never checked, so a literal outside path gets through.

file_redirect.destination is multiple: true in the tree-sitter-bash grammar, and childForFieldName returns only the first word. Later words are real arguments at run time.

Run at head:

  • cat > stolen.txt /outside/s.txt ran and copied the outside file into the project.
  • head > /dev/null /outside/s.txt ran.

This ordering is a small rewrite away from a form the guard does refuse. Fix: treat every destination child as a candidate, and add the case to the tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: every file_redirect destination word is a candidate; cat > stolen.txt /outside/s.txt and head > /dev/null /outside/s.txt are refusal tests.

Comment thread packages/opencode/src/tool/bash.ts Outdated
const candidates = new Set<string>()
for (const { node, cwds } of shellCommands(root, [cwd])) {
for (const commandCwd of cwds) candidates.add(commandCwd)
const words = shellWords(node, true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: assignment values are not scanned, so the host interpreter still runs.

Run at head:

  • PY=/outside/py; $PY executed the outside program.
  • export PATH=/outside:$PATH; py executed it too.
  • export FOO=/outside/s.txt; head "$FOO" read the file.

The PR lists OUT=/tmp/x cmd as a limit, so this is a known gap. I still rate it major: the path is literal in the command text, and the outcome is the one issue #1645 sets out to stop (running a host checkout's .venv/bin/python). Fix: scan variable_assignment values, including those under declaration_command, with the same namesPath check. Split PATH-style values on :.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: variable_assignment values (prefix, standalone, export/declare) are checked, split on :; your three forms plus OUT=/x sh -c … are tests.

Comment thread packages/opencode/src/tool/bash.ts Outdated
for (const word of words.slice(1)) {
const arg = literalWord(word)
// Option values (`--out=/x`) and URLs are not read as paths.
if (!arg || arg.startsWith("-") || !namesPath(arg)) continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: option values skip the check, so writes and reads outside the project still run.

Run at head:

  • sort --output=/outside/w inside.txt wrote outside.
  • sort -o/outside/w inside.txt wrote outside.
  • dd if=inside.txt of=/outside/w wrote outside.
  • dd if=/outside/s.txt read outside.

The separate form sort -o /outside/w is refused, so the outcome depends on spelling. The PR lists --out=/x as skipped, but not -o/x or of=/x, and the title says every path. The test at echo --out=/etc/hosts also locks in the skip. Fix: check the value after = in --x=v and key=v, and the path part of -oPATH. The first-segment check already filters non-paths.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: values of --x=v, key=v and attached -oPATH are checked; your four forms are tests, and the echo --out=/etc/hosts allow case is now a refusal.

Comment thread packages/opencode/src/tool/bash.ts Outdated
}
}
for (const word of words.slice(1)) {
const arg = literalWord(word)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, not a blocker (edge input): two literal spellings are not recognised.

Run at head:

  • head $'/outside/s.txt' read the file, and $'/outside/py' executed it. shellWords has no ansi_c_string type.
  • head ~root/x was not checked, because literalWord returns nothing for ~user.

Fix: handle ansi_c_string when it has no escapes, and refuse or expand ~user.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: $'…' without escapes is read as literal; ~user for another user is refused (the current user's is expanded); tests for both.

for (const target of commandPathCandidates(root, cwd)) {
const real = await realpathThroughParents(target)
if (!real || isDevice(real)) continue
// Text whose first segment does not exist (an awk or sed program such as `/x/ {print}`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, not a blocker (edge input, fails closed and names the path): a regex starting with an existing top-level directory is refused.

Run at head, all refused:

  • awk '/var/ {print}' f
  • sed -n '/usr/p' f
  • grep -c /etc/ f

These run on main under deny. The first-segment check only covers programs like /x/, and the test only uses /inside/. Fix: do not treat the program operand of awk or sed, or words holding spaces and {, as paths. Add a /var/ test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: the program/pattern operand (and -e value) of awk/sed/grep/rg is not a path; your three forms plus grep -e /usr/ are allowed-case tests. Words with spaces stay checked so quoted outside paths are refused.

Comment thread packages/opencode/src/tool/bash.ts Outdated
const named = await externalCommandPaths(tree.rootNode, cwd)
// A directory the operand check found is listed only when no named path inside it is.
const found = [
...Array.from(directories)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, not a blocker (pre-existing on main under deny): the device exemption does not reach the old operand list.

Run at head, all refused: cat /dev/null, cat /dev/null > f, ls /dev/null. The operand check adds /dev to directories, and this filter keeps it because the device was already removed from named.

The PR says standard devices are exempt, and cat /dev/null > f is a common way to truncate a file. Fix: leave device paths out of directories when guarded.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: under deny the guard replaces the operand check's directories, so devices are never requested; cat /dev/null, cat /dev/null > f and ls /dev/null are allowed-case tests (ask mode still requests /dev/* as on main, also tested).

Comment thread packages/opencode/src/tool/bash.ts Outdated
if (name?.includes("/")) {
for (const commandCwd of cwds) {
const target = path.resolve(commandCwd, name)
if (!SYSTEM_BIN_DIRS.has(path.dirname(target))) candidates.add(target)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, not a blocker (edge input, fails closed): the system-bin exemption applies only in program position.

Run at head, all refused: nohup /usr/bin/true, /usr/bin/env /usr/bin/true, command -v /usr/bin/true. /usr/bin/env true runs.

Fix: skip a system-bin path in the argument loop too when the program is a known wrapper such as env, nohup, command, time or xargs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dd20cd: wrappers (env, nohup, command, time, xargs, …) are followed to the program, and a system-bin argument is skipped for them; your three forms are tests.

["home directory listing", () => `ls ~`, undefined],
["home-relative read", () => `cat ~/.profile-that-may-not-exist`, undefined],
["cd outside the project", (o: string) => `cd ${o}/sibling && echo hi`, /sibling/],
["redirection outside the project", (o: string) => `echo x > ${o}/written.txt`, /written\.txt/],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, not a blocker (test-only): the refusal tests miss most forms this guard is meant to cover.

There is no refusal case for 2>, &>, <, <>, a quoted path with spaces, an assignment, --flag=, a symlink inside the project, or a redirect after cd, and nothing covers an in-project .. after cd.

The home-directory cases match only the home basename. In test/agent/bash-external-directory-gate.test.ts:63, the test named "allow or ask" never exercises allow: its config is *: ask plus a /srv deny. Please add these along with the fixes above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 1dd20cd: refusal tests for 2>, &>, <, <>, a quoted path with spaces, assignments, --flag=, symlinks and redirect after cd, an allowed in-project .. after cd, and a real allow case in the agent gate test.

@sahrizvi

sahrizvi commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Findings that do not fit a diff line, plus nits, from the review of this PR at f9c19c74f9.

Minor, docs (not a blocker on its own, safe in a follow-up once behaviour settles)

  • docs/docs/reference/security-faq.md:307 still says the bash tool detects file operations (rm, cp, mv, etc.) and prompts. It does not describe the broader check under deny.
  • docs/docs/configure/permissions.md:202 shows external_directory: "deny" with no statement of scope or limits, such as literal text only and "guard rail, not a sandbox".
  • This PR changes no docs. Wording that agent configuration can block writes outside the repository is not yet accurate: redirects after cd, words after a redirection, assignments, option values and in-project symlinks all still get out. Under a user deny, skill and reference directories and the CLI temp directory also lose their default allow, and only tool output is re-added. Bash now shares that with the file tools, so bundled skill scripts are refused; the docs should say so.

Nits

  • Cost is linear and terminates, but has no cap. With 20k path words the call took 151 s with the guard and 70 s without on a heavily loaded machine. The realpath and stat calls run one at a time, ctx.abort is not checked, and the timeout parameter does not cover this phase. A candidate cap that refuses beyond it would bound this.
  • The PR body lists git -C as not seen, but git -C /outside status is refused because the path is a literal argument. The limit text is stale.
  • ls <allowed-dir> when that directory does not exist yet evaluates the parent's rule and is refused. I saw this with the tool-output directory before it was created.
  • bash.ts:557 repeats the logic of the new directoryGlob; reuse it.

RaghwendraSingh and others added 3 commits November 5, 2026 15:27
…, every literal form

Address review on the bash path guard. Under `external_directory: deny`
the guard now walks the script itself:

- redirections resolve in the directory of the command that owns them
  (`a && b > f` applies to `b`), and every word of a `file_redirect`
  destination is checked (`cat > out /outside/x`)
- `cd x && y` runs `y` only in `x`, so in-project `..` after `cd` is no
  longer refused; `cd x; y` / `||` keep the earlier directory possible;
  `pushd`/`popd` move the tracked directory like `cd`
- assignment values (prefix, standalone, `export`/`declare`), split on
  `:` for `PATH`-style values
- option values: `--x=v`, `key=v` (`dd of=`), attached `-oPATH`
- literal `sh|bash|zsh|dash|ksh -c` strings and `eval` are parsed with the
  same parser (depth cap 3; deeper is refused)
- relative words are resolved through symlinks, so an in-project link to
  outside is judged by where it points
- Windows: the first-segment check takes the segment after the drive
  root (`firstSegmentPath`, unit-tested with `path.win32`)
- `for` values, `[[ ]]` words, `case` words, `$'...'` without escapes;
  `~user` for another user is refused
- awk/sed/grep program or pattern operands are not paths; devices are
  left out of the request; a wrapper's system-binary argument
  (`nohup /usr/bin/true`) is not an escape
- a not-yet-created allowed directory is matched as itself
- at most 2000 candidates per command, and `ctx.abort` is honoured
- the external_directory request reuses `directoryGlob`

Unset, `ask` and `allow` keep the main-branch behaviour: all new code
stays behind `guarded`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `security-faq.md`: what the deny check covers, that skill/temp
  directories lose their default allow under a `*` deny, and the limits
  (literal text only; guard rail, not a sandbox)
- `permissions.md`: scope and limits next to the strict example

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@RaghwendraSingh RaghwendraSingh changed the title fix(tool): apply external_directory: deny to every path a bash command names fix(tool): apply external_directory: deny to every literal path a bash command names Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@RaghwendraSingh

Copy link
Copy Markdown
Author

Addressed review in 1dd20cd (guard v2) and 1cd6c84 (docs): redirect cwd, .. after cd, words after a redirection, assignment values, option values, sh -c/eval strings, in-project symlinks, Windows first segment (all majors); pushd/popd, for/[[ ]]/case words, $'…' and ~user, awk/sed/grep pattern operands, devices, wrapper system-bin arguments, missing allowed directory, candidate cap + abort, directoryGlob reuse, the stale git -C limit, the review test list and a real allow gate test (minors and nits); security-faq.md and permissions.md now describe the deny check, the lost default allows and its limits. Suite (fail) lists before/after: none/none; details in the PR body.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bash tool ignores external_directory: deny for most commands, redirections and binaries run by path

2 participants