feat(core): add location-based permission service (#30287)
This commit is contained in:
@@ -19,7 +19,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
yield* defaults((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
|
||||
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -30,16 +30,16 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
|
||||
agents: {
|
||||
build: {
|
||||
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
|
||||
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
|
||||
},
|
||||
reviewer: {
|
||||
model: "openrouter/openai/gpt-5",
|
||||
description: "Review changes",
|
||||
mode: "subagent",
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
},
|
||||
removed: { description: "Removed later" },
|
||||
},
|
||||
@@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git *", action: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "bash", resource: "git *", effect: "allow" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
|
||||
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
|
||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
|
||||
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
if (!reviewer) throw new Error("expected configured reviewer agent")
|
||||
@@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
])
|
||||
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
||||
}),
|
||||
|
||||
@@ -170,8 +170,8 @@ describe("Config", () => {
|
||||
enterprise: { url: "https://share.example.com" },
|
||||
username: "test-user",
|
||||
permissions: [
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git status", action: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "bash", resource: "git status", effect: "allow" },
|
||||
],
|
||||
agents: {
|
||||
reviewer: {
|
||||
@@ -188,7 +188,7 @@ describe("Config", () => {
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
disabled: false,
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
},
|
||||
},
|
||||
snapshots: false,
|
||||
@@ -254,8 +254,8 @@ describe("Config", () => {
|
||||
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
|
||||
expect(documents[0]?.info.username).toBe("test-user")
|
||||
expect(documents[0]?.info.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git status", action: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "bash", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(documents[0]?.info.agents?.reviewer).toEqual({
|
||||
model: "openrouter/openai/gpt-5",
|
||||
@@ -271,7 +271,7 @@ describe("Config", () => {
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
disabled: false,
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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 { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const current = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(Layer.provide(database))
|
||||
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
|
||||
const layer = PermissionV2.locationLayer.pipe(
|
||||
Layer.provideMerge(database),
|
||||
Layer.provideMerge(events),
|
||||
Layer.provideMerge(current),
|
||||
Layer.provideMerge(sessions),
|
||||
Layer.provideMerge(saved),
|
||||
)
|
||||
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
|
||||
const update = yield* agents.transform()
|
||||
yield* update((editor) =>
|
||||
editor.update(AgentV2.ID.make("test"), (agent) => {
|
||||
agent.permissions = [...rules]
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
|
||||
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<PermissionV2.Request>()
|
||||
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("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("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<PermissionV2.Request>()
|
||||
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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user