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
Whether a permission rule applies depends on how the resource happens to be spelled, in two places in packages/core/src/permission.ts.
shell denies only match the raw command span. With agent rules [{ "shell": "*": "allow" }, { "shell": "sh *": "deny" }], sh -c '...' is denied but /bin/sh -c '...', env sh -c '...', command sh -c '...', FOO=1 /bin/sh -c '...', ./sh -c '...' and ~/bin/sh -c '...' all run. Any prefix the scanner tolerates changes the string the deny row is checked against, so the deny is trivially bypassable.
Path actions compare the rule and the target verbatim. Tools report targets that are relative to the location (agents/test.md), while rules are often absolute (/config/**), so the rule never matches, the * deny floor wins, and the allow rule is inert. The reverse holds too: a relative rule never matches an absolute target.
Net effect: a user cannot tell which of two equivalent spellings a rule will catch.
Steps to reproduce
Give an agent [{ "action": "shell", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "sh *", "effect": "deny" }].
Ask it to run sh -c 'printf x > marker'. Denied, marker not written. Correct.
Ask it to run /bin/sh -c 'printf x > marker'. Runs, marker written.
For path actions, with - { "edit": "*": "deny" }, { "edit": "/config/**": "allow" } and the location directory /config, an edit of agents/test.md is denied even though /config/** should allow it.
Plugins
None.
OpenCode version
v2.1.4 (last tested tag; the local build reports opencode v2.1.4-local). The code paths below are from dev at 1aeb6464d5: denied() and evaluate() in packages/core/src/permission.ts compare the rule resource and the target resource as plain strings.
Operating System
Void Linux, x86_64, kernel 7.2.11_1, glibc 2.41. I do not think this is OS-specific: the defect is in how the rule string and the resource string are compared, and the shell spellings that bypass the deny are plain POSIX or PATH lookups rather than Windows or shell-specific forms.
Thanks for the precise report. I reproduced both parts on the latest dev (17b79f4e0c16), running from source, with a script that calls the same shell scanner and Permission.evaluate the tools use. The script is below.
Path rules: with edit: * deny followed by edit: /config/** allow and the location /config, the edit tool checks agents/test.md (relative to the location). The absolute rule never matches it, so the edit is denied, while the same file written as /config/agents/test.md is allowed. This is the same root cause as #46358, where the Plan agent's absolute plan-directory rule doesn't apply. PR #53365 proposes a fix for read/edit, and #55739 handles ./-prefixed rules.
Shell rules: with shell: * allow followed by shell: "sh *" deny, only sh -c … is denied. /bin/sh -c …, env sh -c …, command sh -c …, FOO=1 /bin/sh -c … and ./sh -c … are all allowed, because the rule is matched against the literal command text. The permissions docs currently describe this literal wildcard matching on the parsed command, so maintainers will need to decide whether deny rules should also match normalized command spellings. Related v1 reports: #54008 (wrappers), and #14110 and #16075 (env prefixes).
Reproduction script (repro.ts)
// Run from an opencode checkout (dev): REPO=/path/to/opencode bun /path/to/repro.ts// Uses the real shell scanner + Permission.evaluate that the shell/edit tools use.import{Effect}from"effect"importpathfrom"node:path"constREPO=process.env.REPO??"/workspace/repo"const{ Permission }=awaitimport(`${REPO}/packages/core/src/permission.ts`)const{ ShellParse }=awaitimport(`${REPO}/packages/core/src/shell/parse.ts`)// 1. shell: allow *, deny "sh *"constshellRules=[{action: "shell",resource: "*",effect: "allow"},{action: "shell",resource: "sh *",effect: "deny"},]constcmds=["sh -c 'printf x > marker'","/bin/sh -c 'printf x > marker'","env sh -c 'printf x > marker'","command sh -c 'printf x > marker'","FOO=1 /bin/sh -c 'printf x > marker'","./sh -c 'printf x > marker'",]for(constportableof[true,false]){console.log(`\n== shell (portable scanner: ${portable}) ==`)for(constcmdofcmds){constparsed: any=awaitEffect.runPromise(ShellParse.scan(cmd,"/bin/bash","/tmp",{ portable })asany)constres=parsed.commands.map((c: any)=>c.resource)consteffects=res.map((r: string)=>Permission.evaluate("shell",r,shellRules).effect)console.log(JSON.stringify(cmd).padEnd(42),"resources:",JSON.stringify(res),"->",effects.join(","))}}// 2. edit: deny *, allow /config/** ; location dir /config// FileAccess.resolve reports in-location targets as path.relative(location.directory, absolute)console.log("\n== edit ==")consteditRules=[{action: "edit",resource: "*",effect: "deny"},{action: "edit",resource: "/config/**",effect: "allow"},]constlocation="/config"constabsolute="/config/agents/test.md"constresource=path.relative(location,absolute)// what the edit tool assertsconsole.log("target",absolute,"resource",JSON.stringify(resource),"->",Permission.evaluate("edit",resource,editRules).effect)console.log("same target as absolute","->",Permission.evaluate("edit",absolute,editRules).effect)
On the path half: agreed it is the same root cause as #46358, and #53365 and #55739 already propose fixes there. I will leave that half to those PRs rather than open a competing one.
The shell half is the part I would like to fix here. #54008 was closed because v1 only takes critical fixes and it asked for a v2 report — this is that report.
Before I open a PR: is deny-only normalization acceptable? What I have derives one extra spelling per command (path-qualified /bin/sh, wrapper-stripped env sh / command sh, assignment-stripped FOO=1 /bin/sh) and uses it only to find deny witnesses. It is never passed to the allow/ask evaluation, to prompts, to saved grants or to permission events, so an extra spelling can only tighten a decision. It cannot widen an allow, and it does not change what "Always allow" stores.
If you would rather normalize at save time, keep the documented literal matching, or handle this differently, say so and I will adjust instead of opening something that gets declined.
+1 for the deny-only approach, it's the smallest safe fix. Two more cases from my v2.2.5 repro (#62513) that stripping wrappers alone wouldn't catch, in case they're in scope:
Commands that take another command as an argument: bash -c 'rm x', sh -c 'rm x', echo x | xargs rm, find . -name x -exec rm {} \;, plus nohup, timeout 5 and time as prefixes. With "rm *": "deny" all of these delete the file.
"Allow always" on a wrapped command saves the wrapper pattern (env *, bash *, find *, timeout *), which then allows any command behind that wrapper. The deny-only change leaves this as is; it might be worth a separate issue.
Activity
opencode-agent commentedon Oct 24, 2026
Thanks for the precise report. I reproduced both parts on the latest
dev(17b79f4e0c16), running from source, with a script that calls the same shell scanner andPermission.evaluatethe tools use. The script is below.Path rules: with
edit: * denyfollowed byedit: /config/** allowand the location/config, the edit tool checksagents/test.md(relative to the location). The absolute rule never matches it, so the edit is denied, while the same file written as/config/agents/test.mdis allowed. This is the same root cause as #46358, where the Plan agent's absolute plan-directory rule doesn't apply. PR #53365 proposes a fix forread/edit, and #55739 handles./-prefixed rules.Shell rules: with
shell: * allowfollowed byshell: "sh *" deny, onlysh -c …is denied./bin/sh -c …,env sh -c …,command sh -c …,FOO=1 /bin/sh -c …and./sh -c …are all allowed, because the rule is matched against the literal command text. The permissions docs currently describe this literal wildcard matching on the parsed command, so maintainers will need to decide whether deny rules should also match normalized command spellings. Related v1 reports: #54008 (wrappers), and #14110 and #16075 (env prefixes).Reproduction script (repro.ts)
Run it:
bun repro.tsvazw commentedon Oct 24, 2026
Thanks — that matches what I see on my side.
On the path half: agreed it is the same root cause as #46358, and #53365 and #55739 already propose fixes there. I will leave that half to those PRs rather than open a competing one.
The shell half is the part I would like to fix here. #54008 was closed because v1 only takes critical fixes and it asked for a v2 report — this is that report.
Before I open a PR: is deny-only normalization acceptable? What I have derives one extra spelling per command (path-qualified
/bin/sh, wrapper-strippedenv sh/command sh, assignment-strippedFOO=1 /bin/sh) and uses it only to find deny witnesses. It is never passed to the allow/ask evaluation, to prompts, to saved grants or to permission events, so an extra spelling can only tighten a decision. It cannot widen an allow, and it does not change what "Always allow" stores.If you would rather normalize at save time, keep the documented literal matching, or handle this differently, say so and I will adjust instead of opening something that gets declined.
Project579 commentedon Nov 7, 2026
+1 for the deny-only approach, it's the smallest safe fix. Two more cases from my v2.2.5 repro (#62513) that stripping wrappers alone wouldn't catch, in case they're in scope:
bash -c 'rm x',sh -c 'rm x',echo x | xargs rm,find . -name x -exec rm {} \;, plusnohup,timeout 5andtimeas prefixes. With"rm *": "deny"all of these delete the file.env *,bash *,find *,timeout *), which then allows any command behind that wrapper. The deny-only change leaves this as is; it might be worth a separate issue.