Repository navigation
Description
Summary
experimental.policies is the only layer that overrides an "Allow always" click — but its resources are matched as raw strings with simple whole-value wildcards, so the same command or URL in a different spelling evades every deny. We empirically characterized the evasion classes against opencode v2.0.27 (every example below was executed live, both interactive and under --auto; repro config at the bottom). Three of the classes cannot be closed by adding patterns, because the spellings are unbounded.
Evasion classes (live-proven on 2.0.27)
1. Quote-splitting breaks any substring deny, silently in both modes:
# deny: shell:git diff *--no-index* (and every position-anchored form)
git diff "--no-"index ~/.ssh/id_rsa /dev/null # executed, 0 prompts — secret reached the model context
git diff "--n"o-index "--"output=/tmp/x f /dev/null # host file written
The shell reassembles the token; the matcher never sees the denied substring. No pattern enumeration can close this — the quote can sit anywhere.
2. Anything before the subcommand evades subcommand-anchored denies (exposed under --auto):
git --no-pager diff --no-index ~/.ssh/id_rsa /dev/null
git -C /repo diff --no-index ~/.ssh/id_rsa /dev/null
git diff --no-index ~/.ssh/id_rsa /dev/null # double space
env GIT_PAGER=cat git diff --no-index ~/secret /dev/null
3. URL re-spelling evades webfetch host denies — same address, different string:
# deny: webfetch:http://[::ffff:169.254.169.254]*
http://[0:0:0:0:0:ffff:169.254.169.254]/ # expanded form — fetched
http://[::ffff:ac11:1]/ # hex octets — fetched
http://[::FFFF:169.254.169.254]/ # uppercase — fetched
4. Wrappers and absolute paths (env curl …, /usr/bin/curl) — partially mitigable by enumeration only.
Request
- For
shell:resources: evaluate the policy against the tokenized argv rather than the raw string — normalize--flag=valuevs--flag value, unpack short-flag bundles, and match flags position-insensitively (including flags that precede the subcommand, like--no-pager/-C). Patterns would then match command semantics, not spellings. - For
webfetch:resources: parse the URL and match against the normalized host — lowercase, canonical IPv6, and IPv4-mapped IPv6 expanded to dotted IPv4.
Why it matters
A policies deny is the strongest statement a user can make — it survives config edits and Allow-always clicks. Today it is silently defeated by re-spelling the same command. Our audit cycle stopped adding deny patterns once quote-splitting proved the class un-enumerable; the argv/URL normalization above is the only complete closure.
Minimal repro config
{
"experimental": {
"policies": [
{ "action": "tool.use", "resource": "shell:git diff *--no-index*", "effect": "deny" },
{ "action": "tool.use", "resource": "webfetch:http://[::ffff:169.254.169.254]*", "effect": "deny" }
]
}
}Deploy, then run the class-1 and class-3 commands above: each resolves allow/fetches; the non-quoted / non-respelled equivalents are denied as expected. Verified on v2.0.27; the runtime accepts tool.use as the policies action (the public docs spell permission — happy to file that separately if it is a docs bug).
Activity
opencode-agent commentedon Oct 13, 2026
Thanks for the detailed write-up and the live examples.
To restate the request:
tool.usepolicy resources should be matched on what a command or URL actually means, not on how it is spelled. Forshell:that means matching on tokenized, normalized argv, with flags matched in any position. Forwebfetch:it means matching on a normalized host (lowercased, canonical IPv6, IPv4-mapped addresses as dotted IPv4). That way a deny can't be avoided by quoting differently, adding flags before the subcommand, or writing the same address another way.The code explains what you saw. Policy resources are currently compared as raw strings with wildcard matching (
Wildcard.match("<action>:<resource>", policy.resource)inpackages/core/src/config/plugin/policy.ts), so each spelling counts as a different string. I've filed this as a feature/hardening request for maintainers to consider; I can't promise if or when it will change.Related issues, most relevant first:
experimental.policiesdenies can be bypassed (provider.use during config hot-reload)experimental.policiesnot enforced for model selection or LLM execution #34971: earlier gaps inexperimental.policiesenforcementIn the meantime, plugins can register a permission
evaluatehook and set the effect to deny. That gives you a place to add your own URL host normalization (for example withnew URL()) on top of the built-in matching.The docs saying
permissionwhile the runtime acceptstool.usewould be good as a separate issue.