feat: unwrap upermission namespace to flat exports + barrel (#22710)
This commit is contained in:
parent
d22b5f026d
commit
47577ae857
2 changed files with 324 additions and 325 deletions
|
|
@ -1,325 +1 @@
|
||||||
import { Bus } from "@/bus"
|
export * as Permission from "./permission"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Config } from "@/config"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
|
||||||
import { zod } from "@/util/effect-zod"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { withStatics } from "@/util/schema"
|
|
||||||
import { Wildcard } from "@/util/wildcard"
|
|
||||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
|
||||||
import os from "os"
|
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
|
|
||||||
export namespace Permission {
|
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
export const Action = Schema.Literals(["allow", "deny", "ask"])
|
|
||||||
.annotate({ identifier: "PermissionAction" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type Action = Schema.Schema.Type<typeof Action>
|
|
||||||
|
|
||||||
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
|
||||||
permission: Schema.String,
|
|
||||||
pattern: Schema.String,
|
|
||||||
action: Action,
|
|
||||||
}) {
|
|
||||||
static readonly zod = zod(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
|
||||||
.annotate({ identifier: "PermissionRuleset" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
|
|
||||||
|
|
||||||
export class Request extends Schema.Class<Request>("PermissionRequest")({
|
|
||||||
id: PermissionID,
|
|
||||||
sessionID: SessionID,
|
|
||||||
permission: Schema.String,
|
|
||||||
patterns: Schema.Array(Schema.String),
|
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
|
||||||
always: Schema.Array(Schema.String),
|
|
||||||
tool: Schema.optional(
|
|
||||||
Schema.Struct({
|
|
||||||
messageID: MessageID,
|
|
||||||
callID: Schema.String,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}) {
|
|
||||||
static readonly zod = zod(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type Reply = Schema.Schema.Type<typeof Reply>
|
|
||||||
|
|
||||||
const reply = {
|
|
||||||
reply: Reply,
|
|
||||||
message: Schema.optional(Schema.String),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReplyBody = Schema.Struct(reply)
|
|
||||||
.annotate({ identifier: "PermissionReplyBody" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
|
|
||||||
|
|
||||||
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
|
|
||||||
projectID: ProjectID,
|
|
||||||
patterns: Schema.Array(Schema.String),
|
|
||||||
}) {
|
|
||||||
static readonly zod = zod(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("permission.asked", Request.zod),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
zod(
|
|
||||||
Schema.Struct({
|
|
||||||
sessionID: SessionID,
|
|
||||||
requestID: PermissionID,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
|
||||||
ruleset: Schema.Any,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
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)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
|
||||||
|
|
||||||
export const AskInput = Schema.Struct({
|
|
||||||
...Request.fields,
|
|
||||||
id: Schema.optional(PermissionID),
|
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
|
||||||
.annotate({ identifier: "PermissionAskInput" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type AskInput = Schema.Schema.Type<typeof AskInput>
|
|
||||||
|
|
||||||
export const ReplyInput = Schema.Struct({
|
|
||||||
requestID: PermissionID,
|
|
||||||
...reply,
|
|
||||||
})
|
|
||||||
.annotate({ identifier: "PermissionReplyInput" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
|
||||||
readonly reply: (input: ReplyInput) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
pending: Map<PermissionID, PendingEntry>
|
|
||||||
approved: Ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
|
||||||
return evalRule(permission, pattern, ...rulesets)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Permission.state")(function* (ctx) {
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
|
||||||
)
|
|
||||||
const state = {
|
|
||||||
pending: new Map<PermissionID, PendingEntry>(),
|
|
||||||
approved: row?.data ?? [],
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (const item of state.pending.values()) {
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
state.pending.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
|
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
let needsAsk = false
|
|
||||||
|
|
||||||
for (const pattern of request.patterns) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny") {
|
|
||||||
return yield* new DeniedError({
|
|
||||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
needsAsk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsAsk) return
|
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
|
||||||
const info = Schema.decodeUnknownSync(Request)({
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
})
|
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
yield* bus.publish(Event.Asked, info)
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
|
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
yield* bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
existing.deferred,
|
|
||||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
pending.delete(id)
|
|
||||||
yield* bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(existing.deferred, undefined)
|
|
||||||
if (input.reply === "once") return
|
|
||||||
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
const ok = item.info.patterns.every(
|
|
||||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
pending.delete(id)
|
|
||||||
yield* bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
|
||||||
if (pattern === "~") return os.homedir()
|
|
||||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
|
||||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
|
||||||
const ruleset: Ruleset = []
|
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ruleset.push(
|
|
||||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
|
||||||
return rulesets.flat()
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
|
||||||
|
|
||||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
|
||||||
const result = new Set<string>()
|
|
||||||
for (const tool of tools) {
|
|
||||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
|
||||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
|
||||||
if (!rule) continue
|
|
||||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
323
packages/opencode/src/permission/permission.ts
Normal file
323
packages/opencode/src/permission/permission.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Config } from "@/config"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||||
|
import os from "os"
|
||||||
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||||
|
.annotate({ identifier: "PermissionAction" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Action = Schema.Schema.Type<typeof Action>
|
||||||
|
|
||||||
|
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||||
|
permission: Schema.String,
|
||||||
|
pattern: Schema.String,
|
||||||
|
action: Action,
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
||||||
|
.annotate({ identifier: "PermissionRuleset" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
|
||||||
|
|
||||||
|
export class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||||
|
id: PermissionID,
|
||||||
|
sessionID: SessionID,
|
||||||
|
permission: Schema.String,
|
||||||
|
patterns: Schema.Array(Schema.String),
|
||||||
|
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
always: Schema.Array(Schema.String),
|
||||||
|
tool: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
messageID: MessageID,
|
||||||
|
callID: Schema.String,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Reply = Schema.Schema.Type<typeof Reply>
|
||||||
|
|
||||||
|
const reply = {
|
||||||
|
reply: Reply,
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReplyBody = Schema.Struct(reply)
|
||||||
|
.annotate({ identifier: "PermissionReplyBody" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
|
||||||
|
|
||||||
|
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
|
||||||
|
projectID: ProjectID,
|
||||||
|
patterns: Schema.Array(Schema.String),
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request.zod),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
zod(
|
||||||
|
Schema.Struct({
|
||||||
|
sessionID: SessionID,
|
||||||
|
requestID: PermissionID,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
export const AskInput = Schema.Struct({
|
||||||
|
...Request.fields,
|
||||||
|
id: Schema.optional(PermissionID),
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "PermissionAskInput" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type AskInput = Schema.Schema.Type<typeof AskInput>
|
||||||
|
|
||||||
|
export const ReplyInput = Schema.Struct({
|
||||||
|
requestID: PermissionID,
|
||||||
|
...reply,
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "PermissionReplyInput" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
||||||
|
readonly reply: (input: ReplyInput) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
pending: Map<PermissionID, PendingEntry>
|
||||||
|
approved: Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||||
|
return evalRule(permission, pattern, ...rulesets)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Permission.state")(function* (ctx) {
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||||
|
)
|
||||||
|
const state = {
|
||||||
|
pending: new Map<PermissionID, PendingEntry>(),
|
||||||
|
approved: row?.data ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
for (const item of state.pending.values()) {
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
state.pending.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
|
||||||
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let needsAsk = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
needsAsk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAsk) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info = Schema.decodeUnknownSync(Request)({
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
})
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
yield* bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
|
||||||
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
yield* bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
pending.delete(id)
|
||||||
|
yield* bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
pending.delete(id)
|
||||||
|
yield* bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function expand(pattern: string): string {
|
||||||
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
|
if (pattern === "~") return os.homedir()
|
||||||
|
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||||
|
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromConfig(permission: Config.Permission) {
|
||||||
|
const ruleset: Ruleset = []
|
||||||
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleset.push(
|
||||||
|
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||||
|
return rulesets.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|
||||||
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
|
if (!rule) continue
|
||||||
|
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue