Skip to content

Commit 6cb5a3f

Browse files
committed
refactor(core): resolve permission rules internally
1 parent 1914397 commit 6cb5a3f

5 files changed

Lines changed: 131 additions & 48 deletions

File tree

packages/core/src/agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export * as AgentV2 from "./agent"
33
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
44
import { castDraft, enableMapSet, type Draft } from "immer"
55
import { ModelV2 } from "./model"
6-
import { PermissionV2 } from "./permission"
6+
import { PermissionSchema } from "./permission/schema"
77
import { ProviderV2 } from "./provider"
88
import { PositiveInt } from "./schema"
99
import { State } from "./state"
@@ -26,7 +26,7 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
2626
hidden: Schema.Boolean,
2727
color: Color.pipe(Schema.optional),
2828
steps: PositiveInt.pipe(Schema.optional),
29-
permissions: PermissionV2.Ruleset,
29+
permissions: PermissionSchema.Ruleset,
3030
}) {
3131
static empty(id: ID) {
3232
return new Info({

packages/core/src/location-layer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AppFileSystem } from "./filesystem"
1515
import { Global } from "./global"
1616
import { Database } from "./database/database"
1717
import { PermissionV2 } from "./permission"
18+
import { SessionV2 } from "./session"
1819

1920
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
2021
lookup: (ref: Location.Ref) => {
@@ -40,5 +41,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
4041
AppFileSystem.defaultLayer,
4142
Global.defaultLayer,
4243
Database.defaultLayer,
44+
SessionV2.defaultLayer,
4345
],
4446
}) {}

packages/core/src/permission.ts

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,25 @@ import { eq } from "drizzle-orm"
55
import { Database } from "./database/database"
66
import { EventV2 } from "./event"
77
import { Location } from "./location"
8+
import { AgentV2 } from "./agent"
89
import { SessionV2 } from "./session"
910
import { withStatics } from "./schema"
1011
import { Identifier } from "./util/identifier"
1112
import { Wildcard } from "./util/wildcard"
1213
import { PermissionTable } from "./permission/sql"
14+
import { PermissionSchema } from "./permission/schema"
15+
16+
export { Effect, Rule, Ruleset } from "./permission/schema"
17+
type Effect = PermissionSchema.Effect
18+
type Rule = PermissionSchema.Rule
19+
type Ruleset = PermissionSchema.Ruleset
1320

1421
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
1522
Schema.brand("PermissionV2.ID"),
1623
withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
1724
)
1825
export type ID = typeof ID.Type
1926

20-
export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
21-
export type Effect = typeof Effect.Type
22-
23-
export const Rule = Schema.Struct({
24-
action: Schema.String,
25-
resource: Schema.String,
26-
effect: Effect,
27-
}).annotate({ identifier: "PermissionV2.Rule" })
28-
export type Rule = typeof Rule.Type
29-
30-
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
31-
export type Ruleset = typeof Ruleset.Type
32-
3327
export const Source = Schema.Union([
3428
Schema.Struct({
3529
type: Schema.Literal("tool"),
@@ -61,7 +55,6 @@ export const AssertInput = Schema.Struct({
6155
remember: Schema.Array(Schema.String).pipe(Schema.optional),
6256
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
6357
source: Source.pipe(Schema.optional),
64-
rules: Ruleset,
6558
}).annotate({ identifier: "PermissionV2.AssertInput" })
6659
export type AssertInput = typeof AssertInput.Type
6760

@@ -72,6 +65,12 @@ export const ReplyInput = Schema.Struct({
7265
}).annotate({ identifier: "PermissionV2.ReplyInput" })
7366
export type ReplyInput = typeof ReplyInput.Type
7467

68+
export const AskResult = Schema.Struct({
69+
id: ID,
70+
effect: PermissionSchema.Effect,
71+
}).annotate({ identifier: "PermissionV2.AskResult" })
72+
export type AskResult = typeof AskResult.Type
73+
7574
export const Event = {
7675
Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
7776
Replied: EventV2.define({
@@ -91,7 +90,7 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
9190
}) {}
9291

9392
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
94-
rules: Ruleset,
93+
rules: PermissionSchema.Ruleset,
9594
}) {}
9695

9796
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
@@ -117,8 +116,8 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
117116
}
118117

119118
export interface Interface {
120-
readonly ask: (input: AssertInput) => EffectRuntime.Effect<Request>
121-
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error>
119+
readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
120+
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
122121
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
123122
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
124123
readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
@@ -129,7 +128,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
129128

130129
interface Pending {
131130
readonly request: Request
132-
readonly rules: Ruleset
133131
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
134132
}
135133

@@ -139,6 +137,8 @@ export const layer = Layer.effect(
139137
const { db } = yield* Database.Service
140138
const events = yield* EventV2.Service
141139
const location = yield* Location.Service
140+
const agents = yield* AgentV2.Service
141+
const sessions = yield* SessionV2.Service
142142
const pending = new Map<ID, Pending>()
143143

144144
yield* EffectRuntime.addFinalizer(() =>
@@ -163,15 +163,31 @@ export const layer = Layer.effect(
163163
return rows.map((row): Rule => ({ action: row.action, resource: row.resource, effect: "allow" }))
164164
})
165165

166+
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
167+
const session = yield* sessions.get(sessionID)
168+
if (!session.agent) return []
169+
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
170+
})
171+
172+
function denied(input: AssertInput, rules: Ruleset) {
173+
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
174+
}
175+
176+
function relevant(input: AssertInput, rules: Ruleset) {
177+
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
178+
}
179+
166180
const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) {
167-
const rules = [...input.rules, ...(yield* remembered())]
168-
const effects = input.resources.map((resource) => evaluate(input.action, resource, rules).effect)
181+
const rules = yield* configured(input.sessionID)
182+
if (denied(input, rules)) return { effect: "deny" as const, rules }
183+
const all = [...rules, ...(yield* remembered())]
184+
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
169185
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
170-
return { effect, rules }
186+
return { effect, rules: all }
171187
})
172188

173-
const create = EffectRuntime.fnUntraced(function* (input: AssertInput) {
174-
const request: Request = {
189+
function request(input: AssertInput): Request {
190+
return {
175191
id: input.id ?? ID.create(),
176192
sessionID: input.sessionID,
177193
action: input.action,
@@ -180,27 +196,32 @@ export const layer = Layer.effect(
180196
metadata: input.metadata,
181197
source: input.source,
182198
}
199+
}
200+
201+
const create = EffectRuntime.fnUntraced(function* (request: Request) {
183202
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
184-
const item = { request, rules: input.rules, deferred }
203+
const item = { request, deferred }
185204
pending.set(request.id, item)
186205
yield* events.publish(Event.Asked, request)
187206
return item
188207
})
189208

190209
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
191-
const pending = yield* create(input)
192-
return pending.request
210+
const result = yield* evaluateInput(input)
211+
const value = request(input)
212+
if (result.effect === "ask") yield* create(value)
213+
return { id: value.id, effect: result.effect }
193214
})
194215

195216
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
196217
const result = yield* evaluateInput(input)
197218
if (result.effect === "deny") {
198219
return yield* new DeniedError({
199-
rules: result.rules.filter((candidate) => Wildcard.match(input.action, candidate.action)),
220+
rules: relevant(input, result.rules),
200221
})
201222
}
202223
if (result.effect === "allow") return
203-
const item = yield* create(input)
224+
const item = yield* create(request(input))
204225
return yield* Deferred.await(item.deferred).pipe(
205226
EffectRuntime.ensuring(
206227
EffectRuntime.sync(() => {
@@ -257,9 +278,15 @@ export const layer = Layer.effect(
257278

258279
const rememberedRules = yield* remembered()
259280
for (const [id, item] of pending) {
260-
const rules = [...item.rules, ...rememberedRules]
281+
const input = { ...item.request }
282+
const rules = yield* configured(item.request.sessionID).pipe(
283+
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
284+
)
285+
if (!rules) continue
286+
if (denied(input, rules)) continue
287+
const effective = [...rules, ...rememberedRules]
261288
if (
262-
!item.request.resources.every((resource) => evaluate(item.request.action, resource, rules).effect === "allow")
289+
!item.request.resources.every((resource) => evaluate(item.request.action, resource, effective).effect === "allow")
263290
)
264291
continue
265292
pending.delete(id)
@@ -288,4 +315,4 @@ export const layer = Layer.effect(
288315
}),
289316
)
290317

291-
export const locationLayer = layer
318+
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export * as PermissionSchema from "./schema"
2+
3+
import { Schema } from "effect"
4+
5+
export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
6+
export type Effect = typeof Effect.Type
7+
8+
export const Rule = Schema.Struct({
9+
action: Schema.String,
10+
resource: Schema.String,
11+
effect: Effect,
12+
}).annotate({ identifier: "PermissionV2.Rule" })
13+
export type Rule = typeof Rule.Type
14+
15+
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
16+
export type Ruleset = typeof Ruleset.Type

packages/core/test/permission.test.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect } from "bun:test"
22
import { Deferred, Effect, Fiber, Layer } from "effect"
3+
import { AgentV2 } from "@opencode-ai/core/agent"
34
import { Database } from "@opencode-ai/core/database/database"
45
import { EventV2 } from "@opencode-ai/core/event"
56
import { Location } from "@opencode-ai/core/location"
@@ -9,6 +10,7 @@ import { Project } from "@opencode-ai/core/project"
910
import { ProjectTable } from "@opencode-ai/core/project/sql"
1011
import { AbsolutePath } from "@opencode-ai/core/schema"
1112
import { SessionV2 } from "@opencode-ai/core/session"
13+
import { SessionTable } from "@opencode-ai/core/session/sql"
1214
import { eq } from "drizzle-orm"
1315
import { location } from "./fixture/location"
1416
import { testEffect } from "./lib/effect"
@@ -19,10 +21,16 @@ const current = Layer.succeed(
1921
Location.Service.of(location({ directory: AbsolutePath.make("project") })),
2022
)
2123
const events = EventV2.layer.pipe(Layer.provide(database))
22-
const layer = PermissionV2.locationLayer.pipe(Layer.provideMerge(database), Layer.provideMerge(events), Layer.provideMerge(current))
24+
const sessions = SessionV2.layer.pipe(Layer.provide(database))
25+
const layer = PermissionV2.locationLayer.pipe(
26+
Layer.provideMerge(database),
27+
Layer.provideMerge(events),
28+
Layer.provideMerge(current),
29+
Layer.provideMerge(sessions),
30+
)
2331
const it = testEffect(layer)
2432

25-
function project() {
33+
function setup(rules: PermissionV2.Ruleset = []) {
2634
return Effect.gen(function* () {
2735
const { db } = yield* Database.Service
2836
yield* db
@@ -31,6 +39,33 @@ function project() {
3139
.onConflictDoNothing()
3240
.run()
3341
.pipe(Effect.orDie)
42+
yield* db
43+
.insert(SessionTable)
44+
.values({
45+
id: SessionV2.ID.make("ses_test"),
46+
project_id: Project.ID.global,
47+
slug: "test",
48+
directory: "project",
49+
title: "test",
50+
version: "test",
51+
agent: "test",
52+
})
53+
.onConflictDoNothing()
54+
.run()
55+
.pipe(Effect.orDie)
56+
yield* setRules(rules)
57+
})
58+
}
59+
60+
function setRules(rules: PermissionV2.Ruleset) {
61+
return Effect.gen(function* () {
62+
const agents = yield* AgentV2.Service
63+
const update = yield* agents.transform()
64+
yield* update((editor) =>
65+
editor.update(AgentV2.ID.make("test"), (agent) => {
66+
agent.permissions = [...rules]
67+
}),
68+
)
3469
})
3570
}
3671

@@ -40,7 +75,6 @@ function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
4075
sessionID: SessionV2.ID.make("ses_test"),
4176
action: "read",
4277
resources: ["src/index.ts"],
43-
rules: [],
4478
...input,
4579
} satisfies PermissionV2.AssertInput
4680
}
@@ -63,32 +97,36 @@ function waitForRequest() {
6397
}
6498

6599
describe("PermissionV2", () => {
66-
it.effect("asks without evaluating configured rules or blocking", () =>
100+
it.effect("returns the evaluated effect and only queues prompts", () =>
67101
Effect.gen(function* () {
102+
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
68103
const service = yield* PermissionV2.Service
69-
const request = yield* service.ask(assertion({ rules: [{ action: "read", resource: "*", effect: "allow" }] }))
70-
expect(yield* service.get(request.id)).toEqual(request)
71-
yield* service.reply({ requestID: request.id, reply: "once" })
72-
expect(yield* service.get(request.id)).toBeUndefined()
104+
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
105+
expect(yield* service.list()).toEqual([])
106+
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
107+
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
108+
expect(yield* service.list()).toEqual([])
109+
yield* setRules([])
110+
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
111+
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
73112
}),
74113
)
75114

76115
it.effect("allows and denies from explicit rules without asking", () =>
77116
Effect.gen(function* () {
117+
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
78118
const service = yield* PermissionV2.Service
79-
yield* service.assert(
80-
assertion({ rules: [{ action: "read", resource: "*", effect: "allow" }] }),
81-
)
82-
const denied = yield* service
83-
.assert(assertion({ rules: [{ action: "read", resource: "*", effect: "deny" }] }))
84-
.pipe(Effect.flip)
119+
yield* service.assert(assertion())
120+
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
121+
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
85122
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
86123
expect(yield* service.list()).toEqual([])
87124
}),
88125
)
89126

90127
it.effect("resolves an asked permission once", () =>
91128
Effect.gen(function* () {
129+
yield* setup()
92130
const { service, fiber, request } = yield* waitForRequest()
93131
expect(yield* service.list()).toEqual([request])
94132
expect(yield* service.forSession(request.sessionID)).toEqual([request])
@@ -103,7 +141,7 @@ describe("PermissionV2", () => {
103141

104142
it.effect("stores remembered resources for the location project", () =>
105143
Effect.gen(function* () {
106-
yield* project()
144+
yield* setup()
107145
const service = yield* PermissionV2.Service
108146
const asked = yield* Deferred.make<PermissionV2.Request>()
109147
const events = yield* EventV2.Service

0 commit comments

Comments
 (0)