Compare commits

...
Author SHA1 Message Date
Aiden Cline d335b04b76 fix(core): match absolute permission rules for relative paths 2026-08-31 18:17:09 -05:00
2 changed files with 180 additions and 6 deletions
+28 -6
View File
@@ -3,6 +3,7 @@ export * as Permission from "./permission.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import path from "path"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { Agent } from "./agent.js"
@@ -84,11 +85,22 @@ 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 {
export function evaluate(
action: string,
resource: string | { value: string; absolute: string },
...rulesets: Permission.Ruleset[]
): Permission.Rule {
const target = typeof resource === "string" ? { value: resource, absolute: undefined } : resource
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
rulesets.flat().findLast(
(rule) =>
Wildcard.match(action, rule.action) &&
(Wildcard.match(target.value, rule.resource) ||
// Only absolute rules see the absolute identity; relative patterns retain their scope.
(target.absolute !== undefined &&
path.isAbsolute(rule.resource.replaceAll("\\", "/")) &&
Wildcard.match(target.absolute, rule.resource))),
) ?? {
action,
resource: "*",
effect: "ask",
@@ -157,8 +169,18 @@ const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
function evaluateResource(action: string, resource: string, rules: Permission.Ruleset) {
return evaluate(
action,
(action === "read" || action === "edit") && !path.isAbsolute(resource)
? { value: resource, absolute: path.resolve(location.directory, resource) }
: resource,
rules,
)
}
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) {
@@ -169,7 +191,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,
+152
View File
@@ -6,10 +6,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PlanPlugin } from "@opencode-ai/core/plugin/plan"
import type { PermissionEvaluation } from "@opencode-ai/plugin/effect/permission"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -18,9 +20,12 @@ import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { ShellParse } from "@opencode-ai/core/shell/parse"
import { Global } from "@opencode-ai/util/global"
import path from "path"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const current = Layer.succeed(
Location.Service,
@@ -36,6 +41,7 @@ const it = testEffect(
Agent.node,
PluginHooks.node,
Permission.node,
LocationMutation.node,
]),
[Location.node.replace(current)],
),
@@ -108,6 +114,152 @@ function waitForRequest(input: Partial<Permission.AssertInput> = {}) {
}
describe("Permission", () => {
it.effect("allows the unmodified Plan plugin's absolute rule when home is the Location", () =>
Effect.gen(function* () {
yield* setup()
const agents = yield* Agent.Service
yield* PlanPlugin.Plugin.effect(
host({
agent: {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) =>
callback({
...draft,
list: () => [],
get: () => undefined,
}),
),
},
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: () => Effect.succeed({ dispose: Effect.void }),
},
session: { hook: () => Effect.succeed({ dispose: Effect.void }) },
}),
).pipe(Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/project" })))
const mutation = yield* LocationMutation.Service
const service = yield* Permission.Service
const target = yield* mutation.resolve({ path: "/project/.opencode/plan/work.md", kind: "file" })
expect(target.resource).toBe(".opencode/plan/work.md")
expect(target.externalDirectory).toBeUndefined()
yield* service.assert(assertion({ agent: Agent.ID.make("plan"), action: "edit", resources: [target.resource] }))
expect(
yield* service.ask(assertion({ agent: Agent.ID.make("plan"), action: "edit", resources: ["source.ts"] })),
).toMatchObject({ effect: "deny" })
}),
)
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: "/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 relative ${action} rules and ordering across absolute rules`, () =>
Effect.gen(function* () {
const service = yield* Permission.Service
yield* setup([
{ action, resource: "/project/*", effect: "allow" },
{ action, resource: "src/*", effect: "deny" },
{ action, resource: "/project/src/generated/*", effect: "allow" },
])
expect(yield* service.ask(assertion({ action, resources: ["src/index.ts"] }))).toMatchObject({ effect: "deny" })
expect(yield* service.ask(assertion({ action, resources: ["src/generated/index.ts"] }))).toMatchObject({
effect: "allow",
})
yield* setRules([
{ action, resource: "*", effect: "allow" },
{ action, resource: "/project/src/*", effect: "deny" },
{ action, resource: "src/generated/*", effect: "allow" },
])
expect(yield* service.ask(assertion({ action, resources: ["src/index.ts"] }))).toMatchObject({ effect: "deny" })
expect(yield* service.ask(assertion({ action, resources: ["src/generated/index.ts"] }))).toMatchObject({
effect: "allow",
})
expect(
yield* service.ask(assertion({ action, resources: ["src/generated/index.ts", "src/index.ts"] })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect(`uses absolute saved ${action} approvals without overriding relative denies`, () =>
Effect.gen(function* () {
yield* setup([{ action, resource: "src/private/*", effect: "deny" }])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action, resources: ["/project/src/*"] })
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action, resources: ["src/index.ts"] }))).toMatchObject({
effect: "allow",
})
expect(yield* service.ask(assertion({ action, resources: ["src/private/key.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([{ action, resource: "/project/src/*", effect: "deny" }])
yield* saved.add({ projectID: Project.ID.global, action, resources: ["*"] })
expect(yield* service.ask(assertion({ action, resources: ["src/index.ts"] }))).toMatchObject({ effect: "deny" })
}),
)
}
for (const action of ["shell", "glob", "grep", "webfetch", "custom", "external_directory"]) {
it.effect(`does not treat ${action} resources as paths`, () =>
Effect.gen(function* () {
yield* setup([
{ action, resource: "*", effect: "deny" },
{ action, resource: "/project/src/*", effect: "allow" },
])
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action, resources: ["src/index.ts"] }))).toMatchObject({ effect: "deny" })
}),
)
}
it.effect("keeps relative wildcard scope and supports absolute action-wildcard rules", () =>
Effect.gen(function* () {
yield* setup([
{ action: "*", resource: "*", effect: "deny" },
{ action: "*", resource: "*project*", effect: "allow" },
{ action: "*", resource: "/pro?ect/plans/*", effect: "allow" },
])
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toMatchObject({ effect: "deny" })
expect(yield* service.ask(assertion({ resources: ["plans/nested/work.md"] }))).toMatchObject({ effect: "allow" })
expect(yield* service.ask(assertion({ resources: ["/outside/plans/work.md"] }))).toMatchObject({ effect: "deny" })
yield* setRules([{ action: "read", resource: path.resolve("/project", "../README.md"), effect: "allow" }])
expect(yield* service.ask(assertion({ resources: ["../README.md"] }))).toMatchObject({ effect: "allow" })
yield* setRules([{ action: "read", resource: "/project/src/*".replaceAll("/", "\\"), effect: "allow" }])
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
}),
)
it.effect("reevaluates relative pending resources after saving an absolute approval", () =>
Effect.gen(function* () {
yield* setup()
const selected = yield* waitForRequest({ save: ["/project/src/*"] })
const other = yield* waitForRequest({ id: Permission.ID.create("per_other"), resources: ["src/other.ts"] })
expect(other.request.resources).toEqual(["src/other.ts"])
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
yield* Fiber.join(selected.fiber)
yield* Fiber.join(other.fiber)
expect(yield* selected.service.list()).toEqual([])
const saved = yield* PermissionSaved.Service
expect((yield* saved.list()).map((rule) => rule.resource)).toEqual(["/project/src/*"])
}),
)
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])