import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import z from "zod" import { Log } from "../util/log" import { Identifier } from "../id/id" import { Plugin } from "../plugin" import { Instance } from "../project/instance" import { Wildcard } from "../util/wildcard" export namespace Permission { const log = Log.create({ service: "permission" }) function toKeys(pattern: Info["pattern"], type: string): string[] { return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern] } function covered(keys: string[], approved: Record): boolean { const pats = Object.keys(approved) return keys.every((k) => pats.some((p) => Wildcard.match(k, p))) } export const Info = z .object({ id: z.string(), type: z.string(), pattern: z.union([z.string(), z.array(z.string())]).optional(), sessionID: z.string(), messageID: z.string(), callID: z.string().optional(), message: z.string(), metadata: z.record(z.string(), z.any()), time: z.object({ created: z.number(), }), }) .meta({ ref: "Permission", }) export type Info = z.infer export const Event = { Updated: BusEvent.define("permission.updated", Info), Replied: BusEvent.define( "permission.replied", z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string(), }), ), } const state = Instance.state( () => { const pending: { [sessionID: string]: { [permissionID: string]: { info: Info resolve: () => void reject: (e: any) => void } } } = {} const approved: { [sessionID: string]: { [permissionID: string]: boolean } } = {} return { pending, approved, } }, async (state) => { for (const pending of Object.values(state.pending)) { for (const item of Object.values(pending)) { item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata)) } } }, ) export function pending() { return state().pending } export function list() { const { pending } = state() const result: Info[] = [] for (const items of Object.values(pending)) { for (const item of Object.values(items)) { result.push(item.info) } } return result.sort((a, b) => a.id.localeCompare(b.id)) } export async function ask(input: { type: Info["type"] message: Info["message"] pattern?: Info["pattern"] callID?: Info["callID"] sessionID: Info["sessionID"] messageID: Info["messageID"] metadata: Info["metadata"] }) { const { pending, approved } = state() log.info("asking", { sessionID: input.sessionID, messageID: input.messageID, toolCallID: input.callID, pattern: input.pattern, }) const approvedForSession = approved[input.sessionID] || {} const keys = toKeys(input.pattern, input.type) if (covered(keys, approvedForSession)) return const info: Info = { id: Identifier.ascending("permission"), type: input.type, pattern: input.pattern, sessionID: input.sessionID, messageID: input.messageID, callID: input.callID, message: input.message, metadata: input.metadata, time: { created: Date.now(), }, } switch ( await Plugin.trigger("permission.ask", info, { status: "ask", }).then((x) => x.status) ) { case "deny": throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata) case "allow": return } pending[input.sessionID] = pending[input.sessionID] || {} return new Promise((resolve, reject) => { pending[input.sessionID][info.id] = { info, resolve, reject, } Bus.publish(Event.Updated, info) }) } export const Response = z.enum(["once", "always", "reject"]) export type Response = z.infer export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) { log.info("response", input) const { pending, approved } = state() const match = pending[input.sessionID]?.[input.permissionID] if (!match) return delete pending[input.sessionID][input.permissionID] Bus.publish(Event.Replied, { sessionID: input.sessionID, permissionID: input.permissionID, response: input.response, }) if (input.response === "reject") { match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata)) return } match.resolve() if (input.response === "always") { approved[input.sessionID] = approved[input.sessionID] || {} const approveKeys = toKeys(match.info.pattern, match.info.type) for (const k of approveKeys) { approved[input.sessionID][k] = true } const items = pending[input.sessionID] if (!items) return for (const item of Object.values(items)) { const itemKeys = toKeys(item.info.pattern, item.info.type) if (covered(itemKeys, approved[input.sessionID])) { respond({ sessionID: item.info.sessionID, permissionID: item.info.id, response: input.response, }) } } } } export class RejectedError extends Error { constructor( public readonly sessionID: string, public readonly permissionID: string, public readonly toolCallID?: string, public readonly metadata?: Record, public readonly reason?: string, ) { super( reason !== undefined ? reason : `The user rejected permission to use this specific tool call. You may try again with different parameters.`, ) } } }