Skip to content

permissions: rule matching depends on spelling (shell deny bypass, inert absolute rules) #58775

Open

Description

@vazw

Description

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

  1. Give an agent [{ "action": "shell", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "sh *", "effect": "deny" }].
  2. Ask it to run sh -c 'printf x > marker'. Denied, marker not written. Correct.
  3. 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.

Terminal

xterm-256color, default shell /usr/bin/sh.

Activity

  1. opencode-agent commented on Oct 24, 2026

    @opencode-agent
    Contributor

    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"
    import path from "node:path"
    const REPO = process.env.REPO ?? "/workspace/repo"
    const { Permission } = await import(`${REPO}/packages/core/src/permission.ts`)
    const { ShellParse } = await import(`${REPO}/packages/core/src/shell/parse.ts`)
    
    // 1. shell: allow *, deny "sh *"
    const shellRules = [
      { action: "shell", resource: "*", effect: "allow" },
      { action: "shell", resource: "sh *", effect: "deny" },
    ]
    const cmds = [
      "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 (const portable of [true, false]) {
      console.log(`\n== shell (portable scanner: ${portable}) ==`)
      for (const cmd of cmds) {
        const parsed: any = await Effect.runPromise(ShellParse.scan(cmd, "/bin/bash", "/tmp", { portable }) as any)
        const res = parsed.commands.map((c: any) => c.resource)
        const effects = 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 ==")
    const editRules = [
      { action: "edit", resource: "*", effect: "deny" },
      { action: "edit", resource: "/config/**", effect: "allow" },
    ]
    const location = "/config"
    const absolute = "/config/agents/test.md"
    const resource = path.relative(location, absolute) // what the edit tool asserts
    console.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)

    Run it: bun repro.ts

  2. vazw commented on Oct 24, 2026

    @vazw
    Author

    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-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.

  3. Project579 commented on Nov 7, 2026

    @Project579

    +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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions