import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { DecisionRecorder } from "#src/decision-audit"; import type { DecisionReporter } from "#src/decision-reporter"; import { toRecord } from "#src/value-guards"; import type { GateOutcome } from "./gates/types"; /** The SDK-facing result shape for a `tool_call` handler. */ type ToolCallResult = { block?: true; reason?: string }; /** * Narrow debug surface for the per-call decision trace. The concrete logger * self-gates on `debugLog`, so the boundary emits unconditionally and the * entry is dropped when the toggle is off (no per-call spam in normal use). */ export interface DecisionTracer { debug(event: string, details?: Record): void; } /** * The only `tool_call` handler the SDK sees. * * Guarantees fail-closed: it owns the `try/catch → block` and is the sole place * an internal {@link GateOutcome} is translated to the SDK result shape, so * "we didn't decide" can never silently mean "allow." * * The SDK's `emitToolCall` (`@earendil-works/pi-coding-agent` * `dist/core/extensions/runner.js`) awaits the registered handler with **no** * try/catch — unlike `emitUserBash` directly below it, which catches and * continues. A thrown gate therefore yields no `{ block: true }` and the * command runs ungated with nothing logged. This boundary absorbs that throw, * blocks, and writes a `gate_error` review-log entry. * * Fail-closed = **block** (not `ask`) for an unexpected exception: the command * may be unknown and the prompt infrastructure itself may be what threw, so a * hard block is the unambiguous safe outcome. */ export function createFailClosedToolCall( gate: (event: unknown, ctx: ExtensionContext) => Promise, reporter: DecisionReporter, audit: DecisionRecorder, tracer: DecisionTracer, ): (event: unknown, ctx: ExtensionContext) => Promise { return async (event, ctx) => { try { const outcome = await gate(event, ctx); audit.recordDecision(outcome.action); tracer.debug("permission.decision", { toolName: bestEffortToolName(event), action: outcome.action, ...(outcome.action === "block" ? { reason: outcome.reason } : {}), }); return outcome.action === "block" ? { block: true, reason: outcome.reason } : {}; } catch (error) { audit.recordError(); reporter.writeReviewLog("permission_request.blocked", { toolName: bestEffortToolName(event), command: bestEffortCommand(event), resolution: "gate_error", error: errorMessage(error), }); return { block: true, reason: formatGateErrorReason(error) }; } }; } // ── Defensive event readers (never throw) ────────────────────────────────── /** Best-effort tool name from a raw event; never throws. */ function bestEffortToolName(event: unknown): string { const record = toRecord(event); const name = record.name ?? record.toolName; return typeof name === "string" && name ? name : ""; } /** Best-effort bash command from a raw event; never throws. */ function bestEffortCommand(event: unknown): string | undefined { const record = toRecord(event); const input = toRecord(record.input ?? record.arguments); return typeof input.command === "string" ? input.command : undefined; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function formatGateErrorReason(error: unknown): string { return `Permission gate failed and blocked the tool call (fail-closed): ${errorMessage(error)}`; }