Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions packages/core/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,30 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Per

export type Error = BlockedError | CorrectedError

function specificity(s: string): number {
const idx = s.search(/[*?]/)
return idx === -1 ? s.length : idx
}

export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
const all = rulesets.flat()
let best: Permission.Rule | undefined
let bestActionSpec = -1
let bestResSpec = -1

for (const rule of all) {
if (Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) {
const actionSpec = specificity(rule.action)
const resSpec = specificity(rule.resource)
if (best === undefined || actionSpec > bestActionSpec || (actionSpec === bestActionSpec && resSpec > bestResSpec) || (actionSpec === bestActionSpec && resSpec === bestResSpec)) {
best = rule
bestActionSpec = actionSpec
bestResSpec = resSpec
}
}
)
}

return best ?? { action, resource: "*", effect: "ask" }
}

export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
Expand Down
25 changes: 20 additions & 5 deletions packages/core/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,32 @@ const layer = Layer.effect(
let statements: Info[] = []
yield* Location.Service

function specificity(s: string): number {
const idx = s.search(/[*?]/)
return idx === -1 ? s.length : idx
}

return Service.of({
load: EffectRuntime.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
let best: Info | undefined
let bestActionSpec = -1
let bestResSpec = -1
for (const statement of statements) {
if (Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource)) {
const actionSpec = specificity(statement.action)
const resSpec = specificity(statement.resource)
if (best === undefined || actionSpec > bestActionSpec || (actionSpec === bestActionSpec && resSpec > bestResSpec) || (actionSpec === bestActionSpec && resSpec === bestResSpec)) {
best = statement
bestActionSpec = actionSpec
bestResSpec = resSpec
}
}
}
return best?.effect ?? fallback
}),
})
}),
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,27 @@ const layer = Layer.effect(
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))

function specificity(s: string): number {
const idx = s.search(/[*?]/)
return idx === -1 ? s.length : idx
}

function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
let best: PermissionV2.Rule | undefined
let bestActionSpec = -1
let bestResSpec = -1
for (const rule of rules) {
if (Wildcard.match(action, rule.action)) {
const actionSpec = specificity(rule.action)
const resSpec = specificity(rule.resource)
if (best === undefined || actionSpec > bestActionSpec || (actionSpec === bestActionSpec && resSpec > bestResSpec) || (actionSpec === bestActionSpec && resSpec === bestResSpec)) {
best = rule
bestActionSpec = actionSpec
bestResSpec = resSpec
}
}
}
return best?.resource === "*" && best.effect === "deny"
}

export const node = makeLocationNode({
Expand Down
67 changes: 66 additions & 1 deletion packages/core/test/permission.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
Expand Down Expand Up @@ -102,6 +102,71 @@ function waitForRequest() {
})
}

describe("evaluate specificity", () => {
test("exact match", () => {
expect(PermissionV2.evaluate("bash", "rm", [{ action: "bash", resource: "rm", effect: "deny" }]).effect).toBe("deny")
})

test("wildcard match", () => {
expect(PermissionV2.evaluate("bash", "rm", [{ action: "bash", resource: "*", effect: "allow" }]).effect).toBe("allow")
})

test("no match returns ask", () => {
expect(PermissionV2.evaluate("bash", "rm", [{ action: "edit", resource: "*", effect: "allow" }]).effect).toBe("ask")
})

test("empty ruleset returns ask", () => {
expect(PermissionV2.evaluate("bash", "rm", []).effect).toBe("ask")
})

test("more specific action beats less specific", () => {
const result = PermissionV2.evaluate("bash", "rm", [
{ action: "bash", resource: "*", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" },
])
expect(result.effect).toBe("allow")
})

test("more specific resource beats less specific", () => {
const result = PermissionV2.evaluate("bash", "rm", [
{ action: "bash", resource: "rm", effect: "deny" },
{ action: "bash", resource: "*", effect: "allow" },
])
expect(result.effect).toBe("deny")
})

test("exact resource beats wildcard regardless of order", () => {
const result = PermissionV2.evaluate("bash", "/bin/rm", [
{ action: "bash", resource: "/bin/rm", effect: "deny" },
{ action: "bash", resource: "*", effect: "allow" },
])
expect(result.effect).toBe("deny")
})

test("same specificity picks last (tiebreaker)", () => {
const result = PermissionV2.evaluate("bash", "rm", [
{ action: "bash", resource: "rm", effect: "deny" },
{ action: "bash", resource: "rm", effect: "allow" },
])
expect(result.effect).toBe("allow")
})

test("glob resource specificity", () => {
const result = PermissionV2.evaluate("edit", "src/components/Button.tsx", [
{ action: "edit", resource: "src/components/*", effect: "allow" },
{ action: "edit", resource: "src/*", effect: "deny" },
])
expect(result.effect).toBe("allow")
})

test("action wildcard with specific resource", () => {
const result = PermissionV2.evaluate("bash", "rm", [
{ action: "*", resource: "rm", effect: "deny" },
])
expect(result.effect).toBe("deny")
})
})

describe("PermissionV2", () => {
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ describe("Policy", () => {
}),
)

it.effect("more specific action beats less specific regardless of order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
])

// "provider.use" (spec 12) beats "provider.*" (spec 9), so allow wins
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "deny")).toBe("deny")
}),
)

it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("ToolRegistry", () => {
{ action: "question", resource: "private", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
).toEqual(["question"])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
}),
)
Expand Down
47 changes: 37 additions & 10 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,30 @@ interface State {
approved: PermissionV1.Rule[]
}

function specificity(s: string): number {
const idx = s.search(/[*?]/)
return idx === -1 ? s.length : idx
}

export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
const all = rulesets.flat()
let best: PermissionV1.Rule | undefined
let bestPermSpec = -1
let bestPatSpec = -1

for (const rule of all) {
if (Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) {
const permSpec = specificity(rule.permission)
const patSpec = specificity(rule.pattern)
if (best === undefined || permSpec > bestPermSpec || (permSpec === bestPermSpec && patSpec > bestPatSpec) || (permSpec === bestPermSpec && patSpec === bestPatSpec)) {
best = rule
bestPermSpec = permSpec
bestPatSpec = patSpec
}
}
)
}

return best ?? { action: "ask", permission, pattern: "*" }
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
Expand Down Expand Up @@ -207,8 +221,21 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
let best: PermissionV1.Rule | undefined
let bestPermSpec = -1
let bestPatSpec = -1
for (const rule of ruleset) {
if (Wildcard.match(permission, rule.permission)) {
const permSpec = specificity(rule.permission)
const patSpec = specificity(rule.pattern)
if (best === undefined || permSpec > bestPermSpec || (permSpec === bestPermSpec && patSpec > bestPatSpec) || (permSpec === bestPermSpec && patSpec === bestPatSpec)) {
best = rule
bestPermSpec = permSpec
bestPatSpec = patSpec
}
}
}
return best?.pattern === "*" && best.action === "deny"
}),
)
}
Expand Down
Loading
Loading