/** * The fail-closed boundary is the only tool_call handler the SDK sees. * * 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. So a thrown * gate would otherwise yield no block and the command would run ungated with * no trace. This boundary must absorb the throw and fail closed. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import type { GateOutcome } from "#src/handlers/gates/types"; import { createFailClosedToolCall } from "#src/handlers/tool-call-boundary"; import { makeReporter } from "#test/helpers/gate-fixtures"; import { makeCtx, makeToolCallEvent } from "#test/helpers/handler-fixtures"; function makeAudit() { return { recordDecision: vi.fn<(action: "allow" | "block") => void>(), recordError: vi.fn<() => void>(), }; } function makeTracer() { return { debug: vi.fn<(event: string, details?: Record) => void>(), }; } function gateReturning(outcome: GateOutcome) { return vi .fn<(event: unknown, ctx: ExtensionContext) => Promise>() .mockResolvedValue(outcome); } describe("createFailClosedToolCall", () => { it("translates an allow outcome to the empty SDK shape", async () => { const audit = makeAudit(); const reporter = makeReporter(); const boundary = createFailClosedToolCall( gateReturning({ action: "allow" }), reporter, audit, makeTracer(), ); const result = await boundary(makeToolCallEvent("read"), makeCtx()); expect(result).toEqual({}); expect(audit.recordDecision).toHaveBeenCalledWith("allow"); expect(audit.recordError).not.toHaveBeenCalled(); expect(reporter.writeReviewLog).not.toHaveBeenCalled(); }); it("translates a block outcome to the SDK block shape with the reason", async () => { const audit = makeAudit(); const reporter = makeReporter(); const boundary = createFailClosedToolCall( gateReturning({ action: "block", reason: "denied by policy" }), reporter, audit, makeTracer(), ); const result = await boundary(makeToolCallEvent("read"), makeCtx()); expect(result).toEqual({ block: true, reason: "denied by policy" }); expect(audit.recordDecision).toHaveBeenCalledWith("block"); }); it("writes a per-call decision trace with the tool name and action", async () => { const tracer = makeTracer(); const boundary = createFailClosedToolCall( gateReturning({ action: "allow" }), makeReporter(), makeAudit(), tracer, ); await boundary(makeToolCallEvent("bash"), makeCtx()); expect(tracer.debug).toHaveBeenCalledWith( "permission.decision", expect.objectContaining({ toolName: "bash", action: "allow" }), ); }); it("blocks fail-closed when the gate throws, recording an error and a review-log entry", async () => { const audit = makeAudit(); const reporter = makeReporter(); const gate = vi .fn<(event: unknown, ctx: ExtensionContext) => Promise>() .mockRejectedValue(new Error("parser init failed")); const boundary = createFailClosedToolCall( gate, reporter, audit, makeTracer(), ); const event = makeToolCallEvent("bash", { input: { command: "cd /repo && git push" }, }); const result = await boundary(event, makeCtx()); expect((result as { block?: true }).block).toBe(true); expect(audit.recordError).toHaveBeenCalledTimes(1); expect(audit.recordDecision).not.toHaveBeenCalled(); expect(reporter.writeReviewLog).toHaveBeenCalledWith( "permission_request.blocked", expect.objectContaining({ requestId: expect.stringMatching(/^perm-/), toolName: "bash", command: "cd /repo && git push", resolution: "gate_error", error: "parser init failed", decidedBy: { kind: "gate_error", reason: "parser init failed" }, }), ); }); it("identifies each errored call separately", async () => { const reporter = makeReporter(); const gate = vi .fn<(event: unknown, ctx: ExtensionContext) => Promise>() .mockRejectedValue(new Error("parser init failed")); const boundary = createFailClosedToolCall( gate, reporter, makeAudit(), makeTracer(), ); await boundary(makeToolCallEvent("bash"), makeCtx()); await boundary(makeToolCallEvent("bash"), makeCtx()); const ids = vi .mocked(reporter.writeReviewLog) .mock.calls.map(([, details]) => details.requestId); expect(ids[0]).not.toBe(ids[1]); }); it("still blocks when recording the gate error itself throws", async () => { const reporter = makeReporter({ writeReviewLog: () => { throw new Error("review log unwritable"); }, }); const gate = vi .fn<(event: unknown, ctx: ExtensionContext) => Promise>() .mockRejectedValue(new Error("parser init failed")); const boundary = createFailClosedToolCall( gate, reporter, makeAudit(), makeTracer(), ); const result = await boundary(makeToolCallEvent("bash"), makeCtx()); expect((result as { block?: true }).block).toBe(true); }); it("does not throw when the event is malformed and the gate throws", async () => { const audit = makeAudit(); const reporter = makeReporter(); const gate = vi .fn<(event: unknown, ctx: ExtensionContext) => Promise>() .mockRejectedValue("non-error rejection"); const boundary = createFailClosedToolCall( gate, reporter, audit, makeTracer(), ); const result = await boundary(undefined, makeCtx()); expect((result as { block?: true }).block).toBe(true); expect(reporter.writeReviewLog).toHaveBeenCalledWith( "permission_request.blocked", expect.objectContaining({ resolution: "gate_error", error: "non-error rejection", }), ); }); });