Skip to content

permission: an empty resources list resolves to allow, discarding the ask default #51648

Open

Description

@feiiiiii5

Description

evaluateInput resolves an empty resources list to allow, discarding the fail-safe default the evaluator itself uses.

evaluate() (packages/core/src/permission.ts:87-95) treats an unmatched rule as ask, never allow:

.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
  action, resource: "*", effect: "ask",
}

but the aggregation over a list of resources has no such default (packages/core/src/permission.ts:177-178):

const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"

With resources: [] the map is empty, so nothing can hold the line and the answer is allow. denied() (:165-167) is vacuously false on [] for the same reason. Request.resources is a plain Schema.Array(Schema.String), so nothing rejects an empty list.

Reachable today from the always-registered opencode_list_mcp_resources tool, whose own input description invites the model to omit server ("Omit to list every server"). With no MCP servers configured, mcp-resource.ts:34-35 computes servers as [] and asserts with resources: servers (:36-44) — the comment above it says "Listing every server checks each server name so per-server rules still apply", and with no servers no check happens. A configured deny for that action, or any deny-all agent (missingAgentPermissions, permission.ts:20), does not apply, and no prompt is shown.

The blast radius for this one tool is small: no servers means an empty catalog anyway. What makes it worth fixing is the shape — resources: <filtered list> is the established in-tree pattern, so any tool that builds its list from a filter inherits a silent allow, and nothing in the UI shows that a rule was skipped. No test covers an empty resources (git grep 'resources: \[\]' over the tree only hits e2e mock payloads).

Shape question: should an empty list be ask (fail safe, consistent with evaluate's default), or should the schema require a non-empty list and the call site skip the assert when there is nothing to check? I lean to the first, since a tool that would otherwise be unfiltered should not become unprompted — happy to be argued out of it.

Steps to reproduce

  1. Configure no MCP servers, and a permission rule that denies the action:
{ "permissions": [{ "action": "opencode_list_mcp_resources", "resource": "*", "effect": "deny" }] }
  1. Ask the model to list MCP resources (it calls the tool with no server argument, as the input description suggests).
  2. The call proceeds instead of being blocked, and no prompt appears.

Verified at c33de198e (v2) against the real service: ask({action, resources: []}) under a deny-all ruleset returns {effect: "allow"}, and assert with resources: [] returns without raising BlockedError. permission.ts is identical at that commit and at the local v2 head, so the line numbers hold for both.

OpenCode version

c33de198e (v2)

Operating System

macOS (arm64) — service-level test with bun test, no local TUI session and no model call.

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced this on the latest dev (86ce19d96) with a service-level test of the permission service (attached below). I didn't run it at c33de198e, but that commit is an ancestor of dev and the denied()/evaluateInput lines are the same there.

    • With an agent ruleset of { action: "*", resource: "*", effect: "deny" }, ask({ action: "opencode_list_mcp_resources", resources: [] }) returns { effect: "allow" }.
    • With no rules, resources: [] returns allow, while resources: ["x"] returns ask. So the default ask is lost only when the list is empty.
    • With a deny rule for opencode_list_mcp_resources, assert with resources: [] succeeds instead of raising BlockedError.

    The cause matches your analysis: denied() uses .some() and the aggregation in evaluateInput uses .includes("ask"), and both fall through to allow when the list is empty. This is now with the maintainers to decide on the fix (an empty list resolving to ask/wildcard vs. a non-empty schema).

    Reproduction script (permission-empty-resources.test.ts)
    import { describe, expect, test } from "bun:test"
    import { Cause, Deferred, Effect, Fiber, Layer, Schema } from "effect"
    import { Agent } from "@opencode/core/agent"
    import { Database } from "@opencode/core/database/database"
    import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
    import { LayerNode } from "@opencode/util/effect/layer-node"
    import { Bus } from "@opencode/core/bus"
    import { Location } from "@opencode/core/location"
    import { Permission } from "@opencode/core/permission"
    import { PermissionTable } from "@opencode/core/permission/sql"
    import { PermissionSaved } from "@opencode/core/permission/saved"
    import { Project } from "@opencode/core/project"
    import { ProjectTable } from "@opencode/core/project/sql"
    import { AbsolutePath } from "@opencode/core/schema"
    import { Session } from "@opencode/core/session"
    import { SessionTable } from "@opencode/core/session/sql"
    import { SessionStore } from "@opencode/core/session/store"
    import { ShellParse } from "@opencode/core/shell/parse"
    import { eq } from "drizzle-orm"
    import { location } from "./fixture/location"
    import { testEffect } from "./lib/effect"
    
    const current = Layer.succeed(
      Location.Service,
      Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
    )
    const it = testEffect(
      AppNodeBuilder.build(
        LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
        [Location.node.replace(current)],
      ),
    )
    
    function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_test")) {
      return Effect.gen(function* () {
        const { db } = yield* Database.Service
        yield* db
          .insert(ProjectTable)
          .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
          .onConflictDoNothing()
          .run()
          .pipe(Effect.orDie)
        yield* db
          .insert(SessionTable)
          .values({
            id: sessionID,
            project_id: Project.ID.global,
            slug: "test",
            directory: "/project",
            title: "test",
            version: "test",
            agent: Agent.ID.make("test"),
          })
          .onConflictDoNothing()
          .run()
          .pipe(Effect.orDie)
        yield* setRules(rules)
      })
    }
    
    function setRules(rules: Permission.Ruleset) {
      return Effect.gen(function* () {
        const agents = yield* Agent.Service
        yield* agents.transform((editor) =>
          editor.update(Agent.ID.make("test"), (agent) => {
            agent.permissions = [...rules]
          }),
        )
      })
    }
    
    function assertion(input: Partial<Permission.AssertInput> = {}) {
      return {
        id: Permission.ID.create("per_test"),
        sessionID: Session.ID.make("ses_test"),
        action: "read",
        resources: ["src/index.ts"],
        ...input,
      } satisfies Permission.AssertInput
    }
    
    function waitForRequest(input: Partial<Permission.AssertInput> = {}) {
      return Effect.gen(function* () {
        const value = assertion(input)
        const service = yield* Permission.Service
        const bus = yield* Bus.Service
        const asked = yield* Deferred.make<Permission.Request>()
        const unsubscribe = yield* bus.listen((event) => {
          if (event.type !== Permission.Event.Asked.type) return Effect.void
          const request = event.data as Permission.Request
          return request.id === value.id ? Deferred.succeed(asked, request).pipe(Effect.asVoid) : Effect.void
        })
        yield* Effect.addFinalizer(() => unsubscribe)
        const fiber = yield* service.assert(value).pipe(Effect.forkScoped)
        const request = yield* Deferred.await(asked)
        return { service, fiber, request }
      })
    }
    
    describe("empty resources (#51648)", () => {
      it.effect("ask with resources: [] under a deny-all ruleset", () =>
        Effect.gen(function* () {
          yield* setup([{ action: "*", resource: "*", effect: "deny" }])
          const service = yield* Permission.Service
          const result = yield* service.ask(assertion({ action: "opencode_list_mcp_resources", resources: [] }))
          console.log("ask(resources: []) under deny-all ->", JSON.stringify(result))
          expect(result.effect).not.toBe("allow")
        }),
      )
      it.effect("ask with resources: [] and no rules (default is ask)", () =>
        Effect.gen(function* () {
          yield* setup([])
          const service = yield* Permission.Service
          const result = yield* service.ask(assertion({ action: "opencode_list_mcp_resources", resources: [] }))
          console.log("ask(resources: []) with no rules ->", JSON.stringify(result))
          const nonEmpty = yield* service.ask(assertion({ id: Permission.ID.create("per_test2"), action: "opencode_list_mcp_resources", resources: ["x"] }))
          console.log("ask(resources: [\"x\"]) with no rules ->", JSON.stringify(nonEmpty))
          expect(result.effect).toBe("ask")
        }),
      )
      it.effect("assert with resources: [] under deny-all", () =>
        Effect.gen(function* () {
          yield* setup([{ action: "opencode_list_mcp_resources", resource: "*", effect: "deny" }])
          const service = yield* Permission.Service
          const exit = yield* service.assert(assertion({ action: "opencode_list_mcp_resources", resources: [] })).pipe(Effect.exit)
          console.log("assert(resources: []) under deny ->", exit._tag)
          expect(exit._tag).toBe("Failure")
        }),
      )
    })

    Run it: bun permission-empty-resources.test.ts

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions