import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type Static, Type } from "typebox"; import { Check, Errors } from "typebox/value"; import type { CellCallTracer } from "../tool/call-trace.ts"; /** * Approval gate for cell tool calls (port of pi-fabric's ApprovalController * surface, scoped to the three risk classes a cell can carry). * * The reserved tool name is RESERVED_APPROVAL_TOOL ("__approval__"); preludes * expose it as `approve(toolName, args?, risk?)`. The host-side call site is * the tool execution shim (src/host/tool-execution.ts), which checks every * cell tool call before it executes. The policy values are allow, ask, deny; * the defaults are allow for `read` and ask for `write` and `execute`. `ask` * opens the pi approval prompt when an interactive UI is available; without a * UI it resolves to the `ask` verdict so the call site can fail the call. * Each verdict is recorded in the WP-3 cell call tracer when one is provided. */ /** Risk class of a cell tool call. */ export type ApprovalRisk = "read" | "write" | "execute"; /** Policy mode for one risk class. */ export type ApprovalPolicy = "allow" | "ask" | "deny"; /** Verdict of one check: allow, ask, or deny. */ export type ApprovalVerdict = "allow" | "ask" | "deny"; /** Default policy: reads pass, writes and execution ask. */ export const DEFAULT_APPROVAL_POLICY: Readonly< Record > = { read: "allow", write: "ask", execute: "ask", }; const APPROVAL_CHOICE_ALLOW_ONCE = "Allow once"; const APPROVAL_CHOICE_ALLOW_SESSION = "Allow this session"; const APPROVAL_CHOICE_DENY = "Deny"; const WRITE_RISK_TOOLS = new Set([ "write", "edit", "apply_patch", "patch", "rm", "mv", "cp", "mkdir", "touch", ]); const EXECUTE_RISK_TOOLS = new Set([ "bash", "exec", "sh", "shell", "run", "eval", "spawn_agent", "terminal", ]); /** * Default name-based risk classification for the host gate. Write-shaped and * execute-shaped tool names get the risky classes; everything else is treated * as read (which the default policy allows). Override via the shim's * `approvals.classifyRisk` when the host needs a different mapping. */ export function classifyToolRisk(toolName: string): ApprovalRisk { if (WRITE_RISK_TOOLS.has(toolName)) { return "write"; } if (EXECUTE_RISK_TOOLS.has(toolName)) { return "execute"; } return "read"; } /** Thrown when a check resolves to a denial (policy or user choice). */ export class ApprovalDeniedError extends Error { readonly name = "ApprovalDeniedError"; readonly risk: ApprovalRisk; readonly toolName: string; constructor(message: string, toolName: string, risk: ApprovalRisk) { super(message); this.risk = risk; this.toolName = toolName; } } const approvalCheckArgsSchema = Type.Object( { op: Type.Literal("check"), toolName: Type.String({ minLength: 1 }), args: Type.Optional(Type.Unknown()), risk: Type.Union([ Type.Literal("read"), Type.Literal("write"), Type.Literal("execute"), ]), }, { additionalProperties: false } ); type ApprovalCheckArgs = Static; class ApprovalArgumentsError extends Error { readonly name = "ApprovalArgumentsError"; constructor(summary: string) { super(`approve() received invalid arguments: ${summary}`); } } /** Result of one approval check. */ export interface ApprovalCheckResult { readonly verdict: ApprovalVerdict; readonly risk: ApprovalRisk; readonly toolName: string; } export interface RunEvalApprovalOptions { /** Policy overrides per risk class; defaults come from DEFAULT_APPROVAL_POLICY. */ readonly policy?: Partial>; /** Host context; `ask` opens the approval prompt when `hasUI` is true. */ readonly context?: ExtensionContext; /** Risk classes granted for the rest of the session (the "Allow this session" choice). */ readonly sessionApprovals?: Set; readonly signal?: AbortSignal; /** WP-3 cell call tracer: each verdict is recorded when a tracer is provided. */ readonly tracer?: CellCallTracer; } function parseApprovalCheckArgs(value: unknown): ApprovalCheckArgs { if (Check(approvalCheckArgsSchema, value)) { return value; } const summary = Errors(approvalCheckArgsSchema, value) .map((error) => `${error.instancePath || "/"} ${error.message}`) .join("; "); throw new ApprovalArgumentsError(summary || "invalid value"); } /** * Handle one approval operation: `check(toolName, args, risk)`. Resolves the * policy for the risk class, opens the pi approval prompt for `ask` when the * UI is available, and records the verdict in the tracer when one is provided. * A resolved denial throws ApprovalDeniedError; an `ask` that cannot reach a * UI resolves to the `ask` verdict so the call site can fail the call. */ export async function runEvalApproval( args: unknown, options: RunEvalApprovalOptions = {} ): Promise { const parsed = parseApprovalCheckArgs(args); if (options.signal?.aborted) { throw new Error("Approval check cancelled"); } const { toolName, risk } = parsed; const mode = options.policy?.[risk] ?? DEFAULT_APPROVAL_POLICY[risk]; const recordVerdict = ( verdict: ApprovalVerdict, outcome: "succeeded" | "failed", error?: string ): void => { options.tracer?.record({ tool: toolName, args: { risk, verdict }, outcome, ...(outcome === "failed" ? { stage: "approve" as const } : {}), ...(error === undefined ? {} : { error }), }); }; if (options.sessionApprovals?.has(risk)) { recordVerdict("allow", "succeeded"); return { verdict: "allow", risk, toolName }; } if (mode === "allow") { recordVerdict("allow", "succeeded"); return { verdict: "allow", risk, toolName }; } if (mode === "deny") { const message = `${toolName} is denied by the ${risk} policy`; recordVerdict("deny", "failed", message); throw new ApprovalDeniedError(message, toolName, risk); } // mode === "ask": open the pi approval prompt when the UI is available. const context = options.context; if ( context?.hasUI === true && context.ui !== undefined && typeof context.ui.select === "function" ) { const picked = await context.ui.select( `${toolName} requests ${risk} access. Allow this call?`, [APPROVAL_CHOICE_ALLOW_ONCE, APPROVAL_CHOICE_ALLOW_SESSION, APPROVAL_CHOICE_DENY] ); if (picked === APPROVAL_CHOICE_ALLOW_SESSION) { options.sessionApprovals?.add(risk); } if ( picked === APPROVAL_CHOICE_ALLOW_ONCE || picked === APPROVAL_CHOICE_ALLOW_SESSION ) { recordVerdict("allow", "succeeded"); return { verdict: "allow", risk, toolName }; } const message = `User denied ${risk} access for ${toolName}`; recordVerdict("deny", "failed", message); throw new ApprovalDeniedError(message, toolName, risk); } recordVerdict("ask", "succeeded"); return { verdict: "ask", risk, toolName }; }