Compare commits

...
Author SHA1 Message Date
rekram1-node 9a8a721415 fix(core): match absolute permission rules for relative paths 2026-09-20 23:24:22 +00:00
2 changed files with 62 additions and 5 deletions
+27 -5
View File
@@ -3,6 +3,7 @@ export * as Permission from "./permission.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode/schema/permission"
import path from "path"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { Agent } from "./agent.js"
@@ -85,10 +86,23 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Permissi
export type Error = BlockedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return evaluateRules(action, resource, rulesets.flat())
}
function evaluateRules(
action: string,
resource: string,
rules: Permission.Ruleset,
absolute?: string,
): Permission.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
rules.findLast((rule) => {
if (!Wildcard.match(action, rule.action)) return false
// Absolute rules may match the same file identity without widening relative patterns.
const target =
absolute !== undefined && path.isAbsolute(rule.resource.replaceAll("\\", "/")) ? absolute : resource
return Wildcard.match(target, rule.resource)
}) ?? {
action,
resource: "*",
effect: "ask",
@@ -162,8 +176,16 @@ const layer = Layer.effect(
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
})
function evaluateResource(action: string, resource: string, rules: Permission.Ruleset) {
const absolute =
(action === "read" || action === "edit") && !path.isAbsolute(resource)
? path.resolve(location.directory, resource)
: undefined
return evaluateRules(action, resource, rules, absolute)
}
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
return input.resources.some((resource) => evaluateResource(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Permission.Ruleset) {
@@ -174,7 +196,7 @@ const layer = Layer.effect(
const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effects = input.resources.map((resource) => evaluateResource(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
+35
View File
@@ -16,6 +16,7 @@ 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 path from "path"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -24,6 +25,7 @@ const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const project = path.resolve("/project")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
@@ -98,6 +100,39 @@ function waitForRequest(input: Partial<Permission.AssertInput> = {}) {
}
describe("Permission", () => {
for (const action of ["read", "edit"]) {
it.effect(`matches absolute ${action} rules against Location-relative resources`, () =>
Effect.gen(function* () {
yield* setup([
{ action, resource: "*", effect: "deny" },
{ action, resource: path.join(project, ".opencode", "plan", "*"), effect: "allow" },
])
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action, resources: [".opencode/plan/work.md"] }))).toMatchObject({
effect: "allow",
})
expect(yield* service.ask(assertion({ action, resources: ["source.ts"] }))).toMatchObject({ effect: "deny" })
}),
)
}
it.effect("preserves rule order across relative and absolute file patterns", () =>
Effect.gen(function* () {
yield* setup([
{ action: "edit", resource: path.join(project, "*"), effect: "allow" },
{ action: "edit", resource: "src/*", effect: "deny" },
{ action: "edit", resource: path.join(project, "src", "generated", "*"), effect: "allow" },
])
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action: "edit", resources: ["src/index.ts"] }))).toMatchObject({
effect: "deny",
})
expect(yield* service.ask(assertion({ action: "edit", resources: ["src/generated/index.ts"] }))).toMatchObject({
effect: "allow",
})
}),
)
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])