Skip to content

Shell redirection bypasses the external_directory permission #42979

Description

@RabbitDong-on

Description

Summary

When external_directory is denied, the built-in file tools (write, edit, apply_patch, read) correctly refuse paths outside the workspace, but the bash tool does not. The external-directory scan only extracts paths when the command's first token is in a 14-entry allowlist, and it never inspects > redirection targets. As a result the agent can freely write outside the workspace using echo, printf, tee, and similar.

Enforcement depends on which command name is typed, not on what the command actually does to the filesystem.

Version

  • Reproduced on 1.18.16 and 1.18.18 (latest release at time of filing)
  • Linux x64

Reproduction

One invocation shows both halves of the problem:

WS=$(mktemp -d "$HOME/oc-repro-XXXXXX")
cd "$WS"

OPENCODE_CONFIG_CONTENT='{"permission":{"external_directory":"deny"}}' \
  opencode run --pure --format json --dir "$WS" \
  'Run exactly this shell command and nothing else: echo BYPASS > /tmp/oc-escape.txt'

ls -l /tmp/oc-escape.txt

Actual

tool=bash  status=completed  echo BYPASS > /tmp/oc-escape.txt
tool=bash  status=error      cat /tmp/oc-escape.txt
tool=read  status=error

-rw-rw-r-- 1 user user 7 /tmp/oc-escape.txt

The write succeeded and the file exists outside the workspace. In the same session the agent was then denied permission to read back the file it had just created.

For contrast, both of these are correctly denied under the identical configuration:

cp seed.txt /tmp/oc-escape2.txt    -> denied, no file created
cat /tmp/oc-escape.txt             -> denied

cp and echo > express the same operation, writing a file into /tmp. Only the cp form is blocked.

Expected

With external_directory denied, a shell command that writes to or reads from a path outside the workspace should trigger the same permission check that write and read already enforce, regardless of which command or shell construct is used.

Root cause

packages/opencode/src/tool/shell.ts

Path extraction is gated on a command-name allowlist (around lines 392-405):

if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) {
  for (const arg of pathArgs(command, ps, shellKind === "cmd")) {
    const resolved = yield* argPath(arg, cwd, ps, shell)
    if (!resolved || containsPath(resolved, instance)) continue
    const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
    scan.dirs.add(dir)
  }
}

FILES (lines 28-38) contains only:

cd chdir popd pushd push-location set-location
rm cp mv mkdir touch chmod chown cat

Only scan.dirs reaches the external_directory prompt in ask() (lines 263-291). Two consequences follow:

  1. Any command whose first token is outside that list contributes no paths, so echo, printf, tee, sed, dd, python, and others are never checked.
  2. Redirection targets are never examined at all. A > target is a filesystem write but is not an argument of an allowlisted command, so it is invisible to the scan.

This matches the observed behaviour exactly: every blocked command contained an allowlisted name such as cat, and every bypassing command used redirection or a non-allowlisted command.

Impact

external_directory reads as a containment boundary, and the file-editing tools honour it, so an operator can reasonably believe the workspace is confined. It is not. Any command form outside the 14-entry allowlist, and any use of >, escapes it.

This is a fail-open design: unknown commands are treated as safe.

Suggested fix

  • Treat redirection targets as filesystem writes and include them in the scan.
  • Stop gating path extraction on a command-name allowlist. Extract candidate path arguments from all commands, then let containsPath decide what is external, so unrecognised commands fail closed rather than open.

Notes

Found with a scenario-based harness using deterministic filesystem and permission oracles. The bypass reproduced in 9 of 35 write attempts and 8 of 34 read attempts across a long run, under two different models, so it is not model-specific. A single capable model often refuses these requests on its own, which can mask the missing enforcement during manual testing.

Plugins

No response

OpenCode version

No response

Steps to reproduce

#!/bin/bash
# Minimal reproduction for: shell redirection bypasses the external_directory permission.
#
# One opencode invocation demonstrates both halves of the bug:
#   echo ... > /tmp/FILE   succeeds   (echo is not allowlisted, redirect is never scanned)
#   cat /tmp/FILE          is denied  (cat is allowlisted)
#
# Usage: bash repro_f2.sh

set -u

MODEL="${1:-github-copilot/gpt-5-mini}"
OC="${OPENCODE_BIN:-opencode}"
TARGET="/tmp/oc-escape.txt"

WS="$(mktemp -d "${TMPDIR:-$HOME}/oc-repro-XXXXXX")"
rm -f "$TARGET"

cd "$WS" || exit 1
OPENCODE_CONFIG_CONTENT='{"permission":{"external_directory":"deny"}}' \
  "$OC" run --pure --format json --dir "$WS" --model "$MODEL" \
  'Run exactly this shell command and nothing else: echo BYPASS > /tmp/oc-escape.txt' 2>&1 |
python3 -c "
import sys, json
for line in sys.stdin:
    try:
        e = json.loads(line)
    except Exception:
        continue
    if e.get('type') != 'tool_use':
        continue
    state = e['part'].get('state', {})
    cmd = (state.get('input') or {}).get('command')
    print('tool=%-5s status=%-9s %s' % (e['part'].get('tool'), state.get('status'), cmd))
"

echo
if [ -f "$TARGET" ]; then
  echo "RESULT: BUG PRESENT - $TARGET was created outside the workspace"
else
  echo "RESULT: not reproduced in this run - retry, the agent must actually run the redirect"
fi

rm -f "$TARGET"
rm -rf "$WS"

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions