From 6809be2d0af6a6bfa20eddb65b107a26eeac1214 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 28 Aug 2026 21:03:52 -0400 Subject: [PATCH] fix(core): reuse permission policy for pending approvals (#46050) --- packages/core/src/permission.ts | 13 +-- packages/core/test/permission.test.ts | 117 ++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 20 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 23bfd87529e..acc0705352c 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -292,20 +292,11 @@ const layer = Layer.effect( pending.delete(input.requestID) if (input.reply !== "always" || !existing.request.save?.length) return - const rememberedRules = yield* savedRules() for (const [id, item] of pending) { - const rules = yield* configured(item.request.sessionID, item.agent).pipe( + const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe( Effect.catchTag("Session.NotFoundError", () => Effect.undefined), ) - if (!rules) continue - if (denied(item.request, rules)) continue - const effective = [...rules, ...rememberedRules] - if ( - !item.request.resources.every( - (resource) => evaluate(item.request.action, resource, effective).effect === "allow", - ) - ) - continue + if (result?.effect !== "allow") continue yield* bus.publish(Permission.Event.Replied, { sessionID: item.request.sessionID, requestID: item.request.id, diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index caf7b572ac6..3c08246f364 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -10,6 +10,7 @@ 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 type { PermissionEvaluation } from "@opencode-ai/plugin/effect/permission" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -40,7 +41,7 @@ const it = testEffect( ), ) -function setup(rules: Permission.Ruleset = []) { +function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_test")) { return Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -52,7 +53,7 @@ function setup(rules: Permission.Ruleset = []) { yield* db .insert(SessionTable) .values({ - id: Session.ID.make("ses_test"), + id: sessionID, project_id: Project.ID.global, slug: "test", directory: "/project", @@ -88,18 +89,19 @@ function assertion(input: Partial = {}) { } satisfies Permission.AssertInput } -function waitForRequest() { +function waitForRequest(input: Partial = {}) { return Effect.gen(function* () { + const value = assertion(input) const service = yield* Permission.Service const bus = yield* Bus.Service const asked = yield* Deferred.make() - const unsubscribe = yield* bus.listen((event) => - event.type === Permission.Event.Asked.type - ? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid) - : Effect.void, - ) + 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(assertion()).pipe(Effect.forkScoped) + const fiber = yield* service.assert(value).pipe(Effect.forkScoped) const request = yield* Deferred.await(asked) return { service, fiber, request } }) @@ -383,6 +385,103 @@ describe("Permission", () => { expect(yield* saved.list()).toEqual([]) }), ) + + for (const effect of ["ask", "deny", "allow"] as const) { + it.effect(`reevaluates pending requests with hooks after always: ${effect}`, () => + Effect.gen(function* () { + yield* setup() + yield* setup([], Session.ID.make("ses_other")) + const agents = yield* Agent.Service + yield* agents.transform((editor) => + editor.update(Agent.ID.make("reviewer"), (agent) => { + agent.permissions = [] + }), + ) + const context = { + sessionID: Session.ID.make("ses_other"), + agent: Agent.ID.make("reviewer"), + action: "read", + resources: ["src/protected.ts", "src/private.ts"], + metadata: { purpose: "protected" }, + source: { type: "tool", messageID: "msg_other", id: "call_other" }, + } satisfies Permission.AssertInput + const hooks = yield* PluginHooks.Service + const seen: PermissionEvaluation[] = [] + yield* hooks.register("permission", "evaluate", (event) => + Effect.sync(() => { + seen.push({ ...event }) + if (event.effect === "allow") event.effect = effect + }), + ) + const selected = yield* waitForRequest({ save: ["src/*"] }) + const other = yield* waitForRequest({ id: Permission.ID.create("per_other"), ...context }) + expect(yield* selected.service.list()).toEqual([selected.request, other.request]) + + yield* selected.service.reply({ requestID: selected.request.id, reply: "always" }) + yield* Fiber.join(selected.fiber) + expect(yield* selected.service.list()).toEqual(effect === "allow" ? [] : [other.request]) + expect(seen).toMatchObject([ + { sessionID: selected.request.sessionID, effect: "ask" }, + { ...context, effect: "ask" }, + { ...context, effect: "allow" }, + ]) + if (effect !== "allow") { + expect(other.fiber.pollUnsafe()).toBeUndefined() + yield* other.service.reply({ requestID: other.request.id, reply: "once" }) + } + yield* Fiber.join(other.fiber) + expect(yield* selected.service.list()).toEqual([]) + }), + ) + } + + for (const guard of ["configured deny", "missing Session"] as const) { + it.effect(`skips pending auto-approval after always for ${guard}`, () => + Effect.gen(function* () { + yield* setup() + yield* setup([], Session.ID.make("ses_other")) + const agents = yield* Agent.Service + yield* agents.transform((editor) => + editor.update(Agent.ID.make("reviewer"), (agent) => { + agent.permissions = [] + }), + ) + const selected = yield* waitForRequest({ save: ["src/*"] }) + const other = yield* waitForRequest({ + id: Permission.ID.create("per_other"), + sessionID: Session.ID.make("ses_other"), + agent: Agent.ID.make("reviewer"), + }) + if (guard === "configured deny") { + yield* agents.transform((editor) => + editor.update(Agent.ID.make("reviewer"), (agent) => { + agent.permissions = [{ action: "read", resource: "*", effect: "deny" }] + }), + ) + } + if (guard === "missing Session") { + const { db } = yield* Database.Service + yield* db.delete(SessionTable).where(eq(SessionTable.id, other.request.sessionID)).run().pipe(Effect.orDie) + } + const hooks = yield* PluginHooks.Service + const seen: PermissionEvaluation[] = [] + yield* hooks.register("permission", "evaluate", (event) => + Effect.sync(() => { + seen.push({ ...event }) + event.effect = "allow" + }), + ) + + yield* selected.service.reply({ requestID: selected.request.id, reply: "always" }) + yield* Fiber.join(selected.fiber) + expect(yield* selected.service.list()).toEqual([other.request]) + expect(other.fiber.pollUnsafe()).toBeUndefined() + expect(seen).toEqual([]) + yield* Fiber.interrupt(other.fiber) + expect(yield* selected.service.list()).toEqual([]) + }), + ) + } }) describe("shell scanner permission impact", () => {