You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
shell: bare redirect bypasses the permission check (scanner returns no commands) #49948
A shell command that the scanner finds no commands in skips the permission check entirely and runs unchecked. A bare redirect is the clearest case: > file is valid POSIX, truncates or creates the file, and parses to zero commands.
packages/core/src/tool/plugin/shell.ts only asserts when the scan produced something:
With resources: [], effects is [], so neither branch matches and the result is allow — even under { action: "*", resource: "*", effect: "deny" }. I added an assertion for exactly that in the existing permission test harness and it passes, so the fail-open is real rather than theoretical.
The commands.length > 0 guard means the second condition is never reached from this path today, but it is the reason the guard is load-bearing.
Then the same command through the shell tool with permission.shell "*": "deny" — it is not denied.
Expected behavior
A command the scanner cannot decompose should still be evaluated, using the raw invocation as the resource, so a deny rule applies and an explicit allow can still permit it. Failing closed seems clearly right for a permission boundary.
Relates to PR #48638. On the branch head (7063bdd9ea), redirect targets are now scanned regardless of command count (packages/opencode/src/tool/shell.ts:1939-1949 plus addPath/ask at :1671-1686, :1487-1506), so a bare redirect to an external path (> /etc/x) does raise the external_directory prompt. However, a bare redirect whose target is inside the project still yields zero scan patterns, so the shell-tool permission (shell.ts:1506) is not evaluated and a "*": deny rule does not block the write — the in-project repro in this issue is not fully closed. Wave commits 9c5eb44d12 … 7063bdd9ea. No claim of full resolution.
Same shape as what you found: authorizeExternal runs unconditionally, so an external redirect target still raises the external-directory prompt, while the shell permission itself is gated on the scan having produced commands. So the in-project case you describe as not fully closed is exactly the one that remains open on v2 too, and it's what #49954 addresses — it drops the count gate and falls back to the raw invocation, so "*": deny applies to > in-project-file.
The two are complementary rather than overlapping: yours covers the path-authorization side, this covers the permission-rule side on the other branch. Worth someone porting the dev redirect-target scanning across as well, since v2 gets the same benefit for external targets only through directories.
Another trigger of the same zero-commands path, on Windows with pwsh: a bare .NET expression statement.
opencode server 2.0.32, Windows 11 10.0.22631. The shell tool runs PowerShell. Subagent rules: { action: "*", resource: "*", effect: deny }, then a shell allowlist (git *, Get-Content *, …), then pattern denies such as '*?:\*'.
Reproduced in 3 of 3 runs. One run had experimental.portable_shell_scanner: true; I did not confirm that the server reloaded the setting first. The statement has no command word, so I assume the scanner returns zero commands, as with a bare redirect. I have not called ShellParse.scan directly. As with -- in #54393, a deny-by-default shell policy still lets the agent read any file the user can read.
The model runs cat victim.txt, which is rejected with Permission denied: shell.
The model then runs > victim.txt. It runs with no prompt or denial, and reading the file afterwards shows 0 lines.
The recording and screenshot below show this, and the script below reproduces it.
One detail: with only "*": "deny", the shell tool is removed from the model's tool list entirely, so the call never reaches the scanner. The bypass shows up once there is at least one later allow rule, or with the default ask behaviour, which covers most real configs. The commands.length > 0 guard in packages/core/src/tool/plugin/shell.ts is still there on dev.
// Reproduces anomalyco/opencode#49948: with every shell command denied except one// explicit allow, a bare redirect (`> victim.txt`) still runs through the shell// tool without any permission check and empties the file.import{Effect,Stream}from"effect"import{Llm,OpenCodeDriver}from"opencode-drive"constcall=(id: string,name: string,input: any)=>Stream.make(Llm.toolCall({index: 0, id, name, input }),Llm.finish("tool-calls"))exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),tui: {recording: true},project: {git: true,files: {"victim.txt": "important data\n"}},config: {autoupdate: false,// Deny every shell command except `git status`. (A lone `"*": "deny"`// removes the shell tool from the model entirely, so one allow keeps it visible.)permissions: [{action: "shell",resource: "*",effect: "deny"},{action: "shell",resource: "git status",effect: "allow"},],},}asany,({ ui, llm })=>Effect.gen(function*(){yield*llm.serve((req: any)=>{constmessages=req.body.messagesasany[]if(JSON.stringify(messages[0]).includes("You name conversations"))returnStream.make(Llm.text("Bare redirect"))constresults=messages.filter((m)=>m.role==="tool")for(constrofresults)console.error(`TOOL RESULT ${r.tool_call_id}: ${String(r.content).slice(0,400)}`)switch(results.length){case0: returncall("call_cat","shell",{command: "cat victim.txt",description: "control: denied"})case1: returncall("call_redirect","shell",{command: "> victim.txt",description: "bare redirect"})case2: returncall("call_read","read",{path: "victim.txt"})default: returnStream.make(Llm.text("ALL DONE"))}})yield*ui.submit("Run cat victim.txt, then > victim.txt, then read victim.txt")yield*ui.waitFor("ALL DONE")yield*Effect.sleep(1000)yield*ui.screenshot("result")}),)
Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.
Activity
AceRothstein71 commentedon Sep 19, 2026
Relates to PR #48638. On the branch head (
7063bdd9ea), redirect targets are now scanned regardless of command count (packages/opencode/src/tool/shell.ts:1939-1949plusaddPath/askat:1671-1686,:1487-1506), so a bare redirect to an external path (> /etc/x) does raise theexternal_directoryprompt. However, a bare redirect whose target is inside the project still yields zero scan patterns, so theshell-tool permission (shell.ts:1506) is not evaluated and a"*": denyrule does not block the write — the in-project repro in this issue is not fully closed. Wave commits9c5eb44d12…7063bdd9ea. No claim of full resolution.Hotragn commentedon Sep 27, 2026
Thanks — that's a useful split, and it matches what I'm seeing.
To be clear about scope, since #48638 and my PR touch different files on different branches:
dev, inpackages/opencode/src/tool/shell.ts.v2, inpackages/core/src/tool/plugin/shell.ts.On current
v2(e22c1622e0) the guard you're describing is still there verbatim:Same shape as what you found:
authorizeExternalruns unconditionally, so an external redirect target still raises the external-directory prompt, while theshellpermission itself is gated on the scan having produced commands. So the in-project case you describe as not fully closed is exactly the one that remains open onv2too, and it's what #49954 addresses — it drops the count gate and falls back to the raw invocation, so"*": denyapplies to> in-project-file.The two are complementary rather than overlapping: yours covers the path-authorization side, this covers the permission-rule side on the other branch. Worth someone porting the
devredirect-target scanning across as well, sincev2gets the same benefit for external targets only throughdirectories.code-sayuri commentedon Oct 20, 2026
Another trigger of the same zero-commands path, on Windows with pwsh: a bare .NET expression statement.
opencode server 2.0.32, Windows 11 10.0.22631. The shell tool runs PowerShell. Subagent rules:
{ action: "*", resource: "*", effect: deny }, then a shell allowlist (git *,Get-Content *, …), then pattern denies such as'*?:\*'.Get-Content C:\Windows\win.iniPermission denied: shell1 | ForEach-Object { [IO.File]::ReadAllText('C:\Windows\win.ini') }Permission denied: shellpython -c "print(open('C:/Windows/win.ini').read()[:20])"Permission denied: shell[IO.File]::ReadAllText('C:\Windows\win.ini')Reproduced in 3 of 3 runs. One run had
experimental.portable_shell_scanner: true; I did not confirm that the server reloaded the setting first. The statement has no command word, so I assume the scanner returns zero commands, as with a bare redirect. I have not calledShellParse.scandirectly. As with--in #54393, a deny-by-default shell policy still lets the agent read any file the user can read.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I can confirm this on v2.2.0 and on the latest
dev(62ae125d9).What I ran, with a simulated model:
victim.txtcontainsimportant data.git status.cat victim.txt, which is rejected withPermission denied: shell.> victim.txt. It runs with no prompt or denial, and reading the file afterwards shows 0 lines.The recording and screenshot below show this, and the script below reproduces it.
One detail: with only
"*": "deny", the shell tool is removed from the model's tool list entirely, so the call never reaches the scanner. The bypass shows up once there is at least one later allow rule, or with the default ask behaviour, which covers most real configs. Thecommands.length > 0guard inpackages/core/src/tool/plugin/shell.tsis still there ondev.Related issues:
allowinpermission.ts--in a pwsh command also skips the permission checkrecording.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.