Repository navigation
Description
Description
A shell permission that is allowed becomes an unprompted arbitrary-file-write primitive when it is chained into a compound command, because the scanner splits compound commands and omits the redirection target from the permission resources it evaluates.
Minimal config (permissions in global opencode.jsonc):
{ "action": "shell", "resource": "pwd", "effect": "allow" }A standalone command that redirects is correctly gated, and the full raw text is used as the resource:
permission requested: shell (pwd > /tmp/probe.txt); auto-rejecting
A compound command with the same redirect is not gated, and writes the file:
$ ls -la /tmp/probe.txt
-rw-rw-r-- 1 user user 5 ... /tmp/probe.txt
Server log for the compound case (no permission entry precedes it):
message="spawning process" command=/usr/bin/zsh args="[\"-c\",\"pwd && pwd > /tmp/probe.txt\"]"
Because the redirection target is dropped when the command is compound, every allowed shell rule is also a write primitive. pwd > /path/to/file and git status > /path/to/file are read-only commands on their own, but they overwrite arbitrary files with no approval.
This is not covered by the edit permission: that action governs the edit/write/patch tools, not writes performed by a shell redirection.
The same shape applies to project-scoped saved approvals created by "Allow always", since those are also allow rules for a shell command prefix.
Not yet tested: ; and | separators, $(...) command substitution, and whether an external_directory rule catches a redirection target outside the project tree.
Steps to reproduce
-
In the global
opencode.jsonc, allow a read-only shell command:{ "$schema": "https://opencode.ai/config.json", "permissions": [ { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "shell", "resource": "pwd", "effect": "allow" }, ], } -
Run a standalone redirected command. The permission check fires and the non-interactive client auto-rejects it:
opencode run "Run this exact shell command and report the result: pwd > /tmp/probe-a.txt" -
Run the same command chained with
&&:opencode run "Run this exact shell command and report the result: pwd && pwd > /tmp/probe-b.txt" -
Compare: no permission request is emitted, and the file is created.
ls -la /tmp/probe-a.txt /tmp/probe-b.txt
Consistently reproducible on every attempt.
OpenCode version
2.0.18
OS
Linux, kernel 7.0.0-31-generic, x86_64
Terminal
Zed (TERM_PROGRAM=zed, TERM=xterm-256color, COLORTERM=truecolor)
Plugins
@azumag/opencode-rate-limit-fallback@2.0.3
Additional Context
- Install channel:
latest. - The scanner's compound handling is otherwise behaving as documented for command splitting; the redirection is what disappears from the evaluated resources.
- A narrow
allowlist is what currently limits the blast radius of this, since no allow rule means no primitive. - Related: core: shell permission scanning on runtimes without tree-sitter WASM (workerd) #42184 (shell permission scanning), opencode not respecting permissions #8832 (permissions not respected, closed).
Activity
github-actions commentedon Sep 29, 2026
This issue might be a duplicate of existing issues. Please check:
external_directorychecks; related scanner gap, though it focuses on a different permission action.h0c0de commentedon Sep 29, 2026
Follow-up with more separator and target variants (2.0.18, same minimal config — only
shell: pwdis allowed).pwd ; pwd > /tmp/oc-probe-semi.txtpwd, pwd > /tmp/oc-probe-semi.txt, auto-rejectedpwd | pwd > /tmp/oc-probe-pipe.txtpwd && pwd > /tmp/oc-probe-external.txtTwo additions to the original report:
The bypass is separator-dependent.
&&and|drop the redirection from the evaluated resources, while;preserves it and is correctly gated. So it is not a universal compound-command bypass.external_directory: denyin the global config did not gate the write when the redirection target was outside the project tree (/tmp/...). A shell redirection target appears to be outside theexternal_directorycheck, soread/edit/external_directoryrules provide no coverage for it.Still untested:
$(...)command substitution and backticks.Local workaround applied on my side: removing every
allowrule forshell, so no allow-listed command exists to use as the write primitive.abelenko commentedon Sep 30, 2026
Adding a root-cause diagnosis and a sharper statement of the rule, measured directly against
ShellParse.scan(). Same defect as reported here — closing my duplicate (#52360) in favour of this one.The rule is narrower than "compound command"
;does not lose the redirection.&&,||and|do. And in a chain of three, only the first command keeps its own redirect:echo one > /tmp/a.txt["echo one > /tmp/a.txt"]echo one; echo two > /tmp/a.txt["echo one", "echo two > /tmp/a.txt"]echo one && echo two > /tmp/a.txt["echo one", "echo two"]echo one > /tmp/a.txt && echo two > /tmp/b.txt["echo one > /tmp/a.txt", "echo two"]echo one && echo two 2>&1["echo one", "echo two"]So the shape to test is not "is it compound" but "is the redirected command inside an
&&/||/|chain, and is it not the first command".Cause
packages/core/src/shell/parse.ts:195:It tests the immediate parent of the
commandnode. In an&&chain, tree-sitter interposes alist(orpipeline) node, so theredirected_statementis the grandparent. Read off the parse tree with the sameweb-tree-sittergrammar the scanner loads:Same tree, same line, two different answers, decided only by whether a
listnode sits in between. There is no branch for that case.With
;, each statement is its ownredirected_statement, so the immediate parent matches and it works.On a fix
The naive "walk up to the nearest
redirected_statement" is safe but degrades: in a multi-command chain that ancestor's text spans all of them, so every command would get the same resource string. Two shapes that keep per-command granularity:redirected_statementonly when the command is its redirected (last) descendant;text, which drops the dependence on node shape entirely.Either way these two should agree:
Untested
scanPortable(behindexperimental.portable_shell_scanner) was not verified. I make no claim about it.Environment:
@opencode/coreV2, ARM64, Linux, defaultscanLegacypath.liaochzh commentedon Oct 23, 2026
Following up on #52083 with the deny-side impact of the same scanner behaviour, a root-cause pointer, and a cross-tier reproduction on v2.1.2.
Summary
When a shell command is chained with
&&,||or|, the trailing redirection is dropped from the permission resources that are evaluated, so a deny rule that targets the redirection (*>*,* > *,*|*) never matches. A hardening deny family that works for a standalone command silently stops working as soon as the same command is chained — and an already-allowed command can then write to arbitrary writable paths.Environment
TERM=xterm-256color(TERM_PROGRAM/COLORTERMunset)/bin/zshlatest(binary at~/.opencode/bin/opencode)pluginkey in~/.config/opencode/opencode.jsonc, no~/.config/opencode/plugin/orplugins/directory)Reproduction
~/.config/opencode/opencode.jsonc:{ "action": "shell", "resource": "*>*", "effect": "deny" }cat /etc/hosts > /tmp/probe.txt&&— allowed, and the file is written:;as the separator — rejected again:||instead of&&— allowed, and the file is written:/path/to/worktree= your project root; a relative name such as.nonexistent-probe-dirworks as well.)||chain, but with a first segment that succeeds — still allowed, although the second segment never runs:Expected Behavior
Every segment of a compound command should be evaluated with the same rules as a standalone command — i.e. the redirection target of a trailing redirect should be part of the permission resources that are evaluated (per segment), so that
*>*/* > */*|*families keep denying inside compound commands.Actual Behavior
&&,||or|: allowed, and the file is written. With;as the separator: rejected again.ls /tmp && rm -rf /tmp/xis rejected. Only the redirection/pipeline-shaped deny families become unreachable.Root cause in v2.1.2:
packages/core/src/tool/plugin/shell.ts(thepermission.assertcall) evaluates permission withresources: parsed.commands.map((command) => command.resource)— the resources are the per-commandresourcestrings produced by the shell scanner.packages/core/src/shell/scan.tsstates: "Trailing redirects wrap a whole list/pipeline in the legacy grammar, not its last command." The scanner therefore strips the redirect words from the last command (redirectWordCount), so that command'sresourceno longer contains> target.packages/core/src/permission.tsdecides deny by matching that resource text (denied()→resources.some((resource) => evaluate(action, resource, rules).effect === "deny")).⇒ A trailing redirect belongs to the list, not to a command, so it never enters any command's resource and can never match a
*>*-style rule.⇒
&&and||are both AND-OR lists in that grammar, so both take the list-level path;;splits the line into separate commands, so the trailing redirect stays attached to the last one and is evaluated normally.Additional Context
latest) in two different agent tiers whose deny families use different shapes (*>*versus* > */* >> *) — identical outcome in both, so this is engine-level rather than config-level.||case was measured separately (v2.1.2, tier with the* > */* >> *deny family,echoas the payload): standalone redirect → rejected;;→ rejected;&&→ allowed and the file written;||→ allowed and the file written;||with a succeeding first segment → allowed, no file written. The sentinel files were 6 bytes (HELLO+ newline).touch <file>andcd /tmp && touch <file>are both rejected). It is "already-allowed commands can produce arbitrary side effects":cat/head/tail/grepare typically allowed, so they can overwrite arbitrary writable files once chained.(a) have the scanner emit an extra permission resource that includes the trailing redirect text for the last command;
(b) emit the list-level redirect as its own
shellpermission resource;(c) document that
shellpermission evaluation does not cover trailing redirects, and route write protection through theedit/writetool face.cat,head,tail,grep) out ofallowlists if you rely on them as write protection.;vs&&contrast, and (iv) cross-tier reproduction on v2.1.2, including a separately measured||case.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I could reproduce this on 2.0.18, and it still happens on the latest
dev(86ce19d).I used a simulated model with your config (
shell*→ ask,shellpwd→ allow):pwd > probe-a.txtshows "Permission required", as expected.pwd && pwd > probe-b.txtruns straight away with no prompt.Calling the shell scanner directly shows why. Both scanner paths (tree-sitter and portable) give the same permission resources:
pwd && pwd > p.txt→pwd,pwd(redirect dropped)pwd | cat > p.txt→pwd,cat(redirect dropped)pwd; pwd > p.txt→pwd,pwd > p.txt(redirect kept)So for the separators you hadn't tested yet:
|is affected the same way as&&, while;keeps the redirect. The recording and screenshot below show the compound command running without a prompt and the standalone one asking, and the script below reproduces it. Related: #42184 (earlier shell permission scanning work).recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.