import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionTable } from "@opencode-ai/core/permission/sql" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionStore } from "@opencode-ai/core/session/store" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) const sessions = SessionV2.layer.pipe( Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) const layer = PermissionV2.locationLayer.pipe( Layer.provideMerge(Database.defaultLayer), Layer.provideMerge(SessionStore.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(current), Layer.provideMerge(sessions), Layer.provideMerge(SessionExecution.noopLayer), Layer.provideMerge(PermissionSaved.defaultLayer), ) const it = testEffect(layer) function setup(rules: PermissionV2.Ruleset = []) { return Effect.gen(function* () { const { db } = yield* Database.Service yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) .onConflictDoNothing() .run() .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ id: SessionV2.ID.make("ses_test"), project_id: Project.ID.global, slug: "test", directory: "/project", title: "test", version: "test", agent: "test", }) .onConflictDoNothing() .run() .pipe(Effect.orDie) yield* setRules(rules) }) } function setRules(rules: PermissionV2.Ruleset) { return Effect.gen(function* () { const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("test"), (agent) => { agent.permissions = [...rules] }), ) }) } function assertion(input: Partial = {}) { return { id: PermissionV2.ID.create("per_test"), sessionID: SessionV2.ID.make("ses_test"), action: "read", resources: ["src/index.ts"], ...input, } satisfies PermissionV2.AssertInput } function waitForRequest() { return Effect.gen(function* () { const service = yield* PermissionV2.Service const events = yield* EventV2.Service const asked = yield* Deferred.make() const unsubscribe = yield* events.listen((event) => event.type === PermissionV2.Event.Asked.type ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid) : Effect.void, ) yield* Effect.addFinalizer(() => unsubscribe) const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped) const request = yield* Deferred.await(asked) return { service, fiber, request } }) } describe("PermissionV2", () => { it.effect("returns the evaluated effect and only queues prompts", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" }) expect(yield* service.list()).toEqual([]) yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" }) expect(yield* service.list()).toEqual([]) yield* setRules([]) expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" }) expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined() }), ) it.effect("evaluates against an explicit provider-turn agent", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions.push({ action: "read", resource: "*", effect: "deny" }) }), ) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" }) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" }) yield* agents.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), (agent) => { agent.permissions = [] }), ) expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "ask" }) expect(yield* service.get(PermissionV2.ID.create("per_test"))).not.toHaveProperty("agent") }), ) it.effect("allows and denies from explicit rules without asking", () => Effect.gen(function* () { yield* setup([{ action: "read", resource: "*", effect: "allow" }]) const service = yield* PermissionV2.Service yield* service.assert(assertion()) yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) const denied = yield* service.assert(assertion()).pipe(Effect.flip) expect(denied).toBeInstanceOf(PermissionV2.DeniedError) expect(yield* service.list()).toEqual([]) }), ) it.effect("allows managed output reads without granting external directory access", () => Effect.gen(function* () { yield* setup([ { action: "*", resource: "*", effect: "deny" }, { action: "read", resource: "*", effect: "allow" }, ]) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" }) expect( yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })), ).toMatchObject({ effect: "deny" }) }), ) it.effect("uses build permissions when the Session agent is omitted", () => Effect.gen(function* () { yield* setup() const { db } = yield* Database.Service yield* db .update(SessionTable) .set({ agent: null }) .where(eq(SessionTable.id, SessionV2.ID.make("ses_test"))) .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }] }), ) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion({ action: "todowrite", resources: ["*"] }))).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow", }) expect(yield* service.list()).toEqual([]) }), ) it.effect("denies omitted-agent permissions when no primary default agent exists", () => Effect.gen(function* () { yield* setup() const { db } = yield* Database.Service yield* db .update(SessionTable) .set({ agent: null }) .where(eq(SessionTable.id, SessionV2.ID.make("ses_test"))) .run() .pipe(Effect.orDie) const agents = yield* AgentV2.Service yield* agents.transform((editor) => { editor.remove(AgentV2.ID.make("test")) editor.remove(AgentV2.ID.make("build")) }) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" }) expect(yield* service.list()).toEqual([]) }), ) it.effect("evaluates bash with the normal configured-rule semantics", () => Effect.gen(function* () { yield* setup([{ action: "*", resource: "*", effect: "allow" }]) const service = yield* PermissionV2.Service const bash = assertion({ action: "bash", resources: ["pwd"] }) expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" }) yield* setRules([]) expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" }) expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined() }), ) it.effect("uses saved bash approvals while preserving configured deny precedence", () => Effect.gen(function* () { yield* setup() const saved = yield* PermissionSaved.Service yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] }) const service = yield* PermissionV2.Service expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow", }) expect(yield* service.list()).toEqual([]) yield* setRules([{ action: "bash", resource: "*", effect: "deny" }]) expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny", }) }), ) it.effect("resolves an asked permission once", () => Effect.gen(function* () { yield* setup() const { service, fiber, request } = yield* waitForRequest() expect(yield* service.list()).toEqual([request]) expect(yield* service.forSession(request.sessionID)).toEqual([request]) expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([]) expect(yield* service.get(request.id)).toEqual(request) yield* service.reply({ requestID: request.id, reply: "once" }) yield* Fiber.join(fiber) expect(yield* service.list()).toEqual([]) expect(yield* service.get(request.id)).toBeUndefined() }), ) it.effect("stores and removes saved resources for a project", () => Effect.gen(function* () { yield* setup() const service = yield* PermissionV2.Service const asked = yield* Deferred.make() const events = yield* EventV2.Service const unsubscribe = yield* events.listen((event) => event.type === PermissionV2.Event.Asked.type ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid) : Effect.void, ) yield* Effect.addFinalizer(() => unsubscribe) const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped) const request = yield* Deferred.await(asked) yield* service.reply({ requestID: request.id, reply: "always" }) yield* Fiber.join(fiber) const { db } = yield* Database.Service expect( yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(), ).toMatchObject([{ action: "read", resource: "src/*" }]) const saved = yield* PermissionSaved.Service const id = (yield* saved.list())[0]!.id expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }]) yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] })) yield* saved.remove(id) expect(yield* saved.list()).toEqual([]) }), ) })