Skip to content

Commit 964474a

Browse files
authored
refactor: collapse permission barrel into permission/index.ts (#22915)
1 parent ab15fc1 commit 964474a

2 files changed

Lines changed: 325 additions & 324 deletions

File tree

Lines changed: 325 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,325 @@
1-
export * as Permission from "./permission"
1+
import { Bus } from "@/bus"
2+
import { BusEvent } from "@/bus/bus-event"
3+
import { ConfigPermission } from "@/config/permission"
4+
import { InstanceState } from "@/effect"
5+
import { ProjectID } from "@/project/schema"
6+
import { MessageID, SessionID } from "@/session/schema"
7+
import { PermissionTable } from "@/session/session.sql"
8+
import { Database, eq } from "@/storage"
9+
import { zod } from "@/util/effect-zod"
10+
import { Log } from "@/util"
11+
import { withStatics } from "@/util/schema"
12+
import { Wildcard } from "@/util"
13+
import { Deferred, Effect, Layer, Schema, Context } from "effect"
14+
import os from "os"
15+
import { evaluate as evalRule } from "./evaluate"
16+
import { PermissionID } from "./schema"
17+
18+
const log = Log.create({ service: "permission" })
19+
20+
export const Action = Schema.Literals(["allow", "deny", "ask"])
21+
.annotate({ identifier: "PermissionAction" })
22+
.pipe(withStatics((s) => ({ zod: zod(s) })))
23+
export type Action = Schema.Schema.Type<typeof Action>
24+
25+
export class Rule extends Schema.Class<Rule>("PermissionRule")({
26+
permission: Schema.String,
27+
pattern: Schema.String,
28+
action: Action,
29+
}) {
30+
static readonly zod = zod(this)
31+
}
32+
33+
export const Ruleset = Schema.mutable(Schema.Array(Rule))
34+
.annotate({ identifier: "PermissionRuleset" })
35+
.pipe(withStatics((s) => ({ zod: zod(s) })))
36+
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
37+
38+
export class Request extends Schema.Class<Request>("PermissionRequest")({
39+
id: PermissionID,
40+
sessionID: SessionID,
41+
permission: Schema.String,
42+
patterns: Schema.Array(Schema.String),
43+
metadata: Schema.Record(Schema.String, Schema.Unknown),
44+
always: Schema.Array(Schema.String),
45+
tool: Schema.optional(
46+
Schema.Struct({
47+
messageID: MessageID,
48+
callID: Schema.String,
49+
}),
50+
),
51+
}) {
52+
static readonly zod = zod(this)
53+
}
54+
55+
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
56+
export type Reply = Schema.Schema.Type<typeof Reply>
57+
58+
const reply = {
59+
reply: Reply,
60+
message: Schema.optional(Schema.String),
61+
}
62+
63+
export const ReplyBody = Schema.Struct(reply)
64+
.annotate({ identifier: "PermissionReplyBody" })
65+
.pipe(withStatics((s) => ({ zod: zod(s) })))
66+
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
67+
68+
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
69+
projectID: ProjectID,
70+
patterns: Schema.Array(Schema.String),
71+
}) {
72+
static readonly zod = zod(this)
73+
}
74+
75+
export const Event = {
76+
Asked: BusEvent.define("permission.asked", Request.zod),
77+
Replied: BusEvent.define(
78+
"permission.replied",
79+
zod(
80+
Schema.Struct({
81+
sessionID: SessionID,
82+
requestID: PermissionID,
83+
reply: Reply,
84+
}),
85+
),
86+
),
87+
}
88+
89+
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
90+
override get message() {
91+
return "The user rejected permission to use this specific tool call."
92+
}
93+
}
94+
95+
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
96+
feedback: Schema.String,
97+
}) {
98+
override get message() {
99+
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
100+
}
101+
}
102+
103+
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
104+
ruleset: Schema.Any,
105+
}) {
106+
override get message() {
107+
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
108+
}
109+
}
110+
111+
export type Error = DeniedError | RejectedError | CorrectedError
112+
113+
export const AskInput = Schema.Struct({
114+
...Request.fields,
115+
id: Schema.optional(PermissionID),
116+
ruleset: Ruleset,
117+
})
118+
.annotate({ identifier: "PermissionAskInput" })
119+
.pipe(withStatics((s) => ({ zod: zod(s) })))
120+
export type AskInput = Schema.Schema.Type<typeof AskInput>
121+
122+
export const ReplyInput = Schema.Struct({
123+
requestID: PermissionID,
124+
...reply,
125+
})
126+
.annotate({ identifier: "PermissionReplyInput" })
127+
.pipe(withStatics((s) => ({ zod: zod(s) })))
128+
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
129+
130+
export interface Interface {
131+
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
132+
readonly reply: (input: ReplyInput) => Effect.Effect<void>
133+
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
134+
}
135+
136+
interface PendingEntry {
137+
info: Request
138+
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
139+
}
140+
141+
interface State {
142+
pending: Map<PermissionID, PendingEntry>
143+
approved: Ruleset
144+
}
145+
146+
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
147+
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
148+
return evalRule(permission, pattern, ...rulesets)
149+
}
150+
151+
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
152+
153+
export const layer = Layer.effect(
154+
Service,
155+
Effect.gen(function* () {
156+
const bus = yield* Bus.Service
157+
const state = yield* InstanceState.make<State>(
158+
Effect.fn("Permission.state")(function* (ctx) {
159+
const row = Database.use((db) =>
160+
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
161+
)
162+
const state = {
163+
pending: new Map<PermissionID, PendingEntry>(),
164+
approved: row?.data ?? [],
165+
}
166+
167+
yield* Effect.addFinalizer(() =>
168+
Effect.gen(function* () {
169+
for (const item of state.pending.values()) {
170+
yield* Deferred.fail(item.deferred, new RejectedError())
171+
}
172+
state.pending.clear()
173+
}),
174+
)
175+
176+
return state
177+
}),
178+
)
179+
180+
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
181+
const { approved, pending } = yield* InstanceState.get(state)
182+
const { ruleset, ...request } = input
183+
let needsAsk = false
184+
185+
for (const pattern of request.patterns) {
186+
const rule = evaluate(request.permission, pattern, ruleset, approved)
187+
log.info("evaluated", { permission: request.permission, pattern, action: rule })
188+
if (rule.action === "deny") {
189+
return yield* new DeniedError({
190+
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
191+
})
192+
}
193+
if (rule.action === "allow") continue
194+
needsAsk = true
195+
}
196+
197+
if (!needsAsk) return
198+
199+
const id = request.id ?? PermissionID.ascending()
200+
const info = Schema.decodeUnknownSync(Request)({
201+
id,
202+
...request,
203+
})
204+
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
205+
206+
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
207+
pending.set(id, { info, deferred })
208+
yield* bus.publish(Event.Asked, info)
209+
return yield* Effect.ensuring(
210+
Deferred.await(deferred),
211+
Effect.sync(() => {
212+
pending.delete(id)
213+
}),
214+
)
215+
})
216+
217+
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
218+
const { approved, pending } = yield* InstanceState.get(state)
219+
const existing = pending.get(input.requestID)
220+
if (!existing) return
221+
222+
pending.delete(input.requestID)
223+
yield* bus.publish(Event.Replied, {
224+
sessionID: existing.info.sessionID,
225+
requestID: existing.info.id,
226+
reply: input.reply,
227+
})
228+
229+
if (input.reply === "reject") {
230+
yield* Deferred.fail(
231+
existing.deferred,
232+
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
233+
)
234+
235+
for (const [id, item] of pending.entries()) {
236+
if (item.info.sessionID !== existing.info.sessionID) continue
237+
pending.delete(id)
238+
yield* bus.publish(Event.Replied, {
239+
sessionID: item.info.sessionID,
240+
requestID: item.info.id,
241+
reply: "reject",
242+
})
243+
yield* Deferred.fail(item.deferred, new RejectedError())
244+
}
245+
return
246+
}
247+
248+
yield* Deferred.succeed(existing.deferred, undefined)
249+
if (input.reply === "once") return
250+
251+
for (const pattern of existing.info.always) {
252+
approved.push({
253+
permission: existing.info.permission,
254+
pattern,
255+
action: "allow",
256+
})
257+
}
258+
259+
for (const [id, item] of pending.entries()) {
260+
if (item.info.sessionID !== existing.info.sessionID) continue
261+
const ok = item.info.patterns.every(
262+
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
263+
)
264+
if (!ok) continue
265+
pending.delete(id)
266+
yield* bus.publish(Event.Replied, {
267+
sessionID: item.info.sessionID,
268+
requestID: item.info.id,
269+
reply: "always",
270+
})
271+
yield* Deferred.succeed(item.deferred, undefined)
272+
}
273+
})
274+
275+
const list = Effect.fn("Permission.list")(function* () {
276+
const pending = (yield* InstanceState.get(state)).pending
277+
return Array.from(pending.values(), (item) => item.info)
278+
})
279+
280+
return Service.of({ ask, reply, list })
281+
}),
282+
)
283+
284+
function expand(pattern: string): string {
285+
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
286+
if (pattern === "~") return os.homedir()
287+
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
288+
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
289+
return pattern
290+
}
291+
292+
export function fromConfig(permission: ConfigPermission.Info) {
293+
const ruleset: Ruleset = []
294+
for (const [key, value] of Object.entries(permission)) {
295+
if (typeof value === "string") {
296+
ruleset.push({ permission: key, action: value, pattern: "*" })
297+
continue
298+
}
299+
ruleset.push(
300+
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
301+
)
302+
}
303+
return ruleset
304+
}
305+
306+
export function merge(...rulesets: Ruleset[]): Ruleset {
307+
return rulesets.flat()
308+
}
309+
310+
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
311+
312+
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
313+
const result = new Set<string>()
314+
for (const tool of tools) {
315+
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
316+
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
317+
if (!rule) continue
318+
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
319+
}
320+
return result
321+
}
322+
323+
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
324+
325+
export * as Permission from "."

0 commit comments

Comments
 (0)