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:
- Any command whose first token is outside that list contributes no paths, so
echo, printf, tee, sed, dd, python, and others are never checked.
- 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
Description
Summary
When
external_directoryis denied, the built-in file tools (write,edit,apply_patch,read) correctly refuse paths outside the workspace, but thebashtool 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 usingecho,printf,tee, and similar.Enforcement depends on which command name is typed, not on what the command actually does to the filesystem.
Version
1.18.16and1.18.18(latest release at time of filing)Reproduction
One invocation shows both halves of the problem:
Actual
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:
cpandecho >express the same operation, writing a file into/tmp. Only thecpform is blocked.Expected
With
external_directorydenied, a shell command that writes to or reads from a path outside the workspace should trigger the same permission check thatwriteandreadalready enforce, regardless of which command or shell construct is used.Root cause
packages/opencode/src/tool/shell.tsPath extraction is gated on a command-name allowlist (around lines 392-405):
FILES(lines 28-38) contains only:Only
scan.dirsreaches theexternal_directoryprompt inask()(lines 263-291). Two consequences follow:echo,printf,tee,sed,dd,python, and others are never checked.>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_directoryreads 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
containsPathdecide 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
Screenshot and/or share link
No response
Operating System
No response
Terminal
No response