diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts deleted file mode 100644 index f1cd43fdbe5..00000000000 --- a/packages/opencode/src/permission/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -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.`, - ) - } - } -} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index f95aaf34525..ad5e1b1271d 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -120,28 +120,57 @@ export namespace PermissionNext { async (input) => { const s = await state() const { ruleset, ...request } = input - for (const pattern of request.patterns ?? []) { + const ask = (request.patterns ?? []).reduce((ask, pattern) => { const rule = evaluate(request.permission, pattern, ruleset, s.approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) - if (rule.action === "deny") + if (rule.action === "deny") { throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) - if (rule.action === "ask") { - const id = input.id ?? Identifier.ascending("permission") - return new Promise((resolve, reject) => { - const info: Request = { - id, - ...request, + } + return ask || rule.action === "ask" + }, false) + + if (!ask) return + + const id = input.id ?? Identifier.ascending("permission") + const info: Request = { + id, + ...request, + } + + return new Promise((resolve, reject) => { + s.pending[id] = { + info, + resolve, + reject, + } + + void import("@/plugin") + .then((plugin) => + plugin.Plugin.trigger("permission.ask", info, { + status: "ask" as "ask" | "allow" | "deny", + }), + ) + .then((result) => { + const existing = s.pending[id] + if (!existing) return + if (result.status === "deny") { + delete s.pending[id] + existing.reject(new RejectedError()) + return } - s.pending[id] = { - info, - resolve, - reject, + if (result.status === "allow") { + delete s.pending[id] + existing.resolve() + return } Bus.publish(Event.Asked, info) }) - } - if (rule.action === "allow") continue - } + .catch((error) => { + log.error("permission.ask plugin failed", { error }) + if (!s.pending[id]) return + Bus.publish(Event.Asked, info) + }) + }) }, )