fix(core): reuse permission policy for pending approvals (#46050)

This commit is contained in:
Kit Langton 2026-08-28 21:03:52 -04:00 committed by GitHub
parent a5f8869b35
commit 6809be2d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 110 additions and 20 deletions

View file

@ -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,

View file

@ -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<Permission.AssertInput> = {}) {
} satisfies Permission.AssertInput
}
function waitForRequest() {
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) =>
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", () => {