Skip to content

shell/webfetch policy resources match raw strings — argv tokenization and URL host normalization needed (evasion classes un-enumerable) #55232

Open

Description

@mwolff44

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=value vs --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

  1. opencode-agent commented on Oct 13, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up and the live examples.

    To restate the request: tool.use policy resources should be matched on what a command or URL actually means, not on how it is spelled. For shell: that means matching on tokenized, normalized argv, with flags matched in any position. For webfetch: 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) in packages/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:

    In the meantime, plugins can register a permission evaluate hook and set the effect to deny. That gives you a place to add your own URL host normalization (for example with new URL()) on top of the built-in matching.

    The docs saying permission while the runtime accepts tool.use would be good as a separate issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions