mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
190 lines
6.0 KiB
TypeScript
190 lines
6.0 KiB
TypeScript
/**
|
|
* 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<string, unknown>) => void>(),
|
|
};
|
|
}
|
|
|
|
function gateReturning(outcome: GateOutcome) {
|
|
return vi
|
|
.fn<(event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>>()
|
|
.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<GateOutcome>>()
|
|
.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<GateOutcome>>()
|
|
.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<GateOutcome>>()
|
|
.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<GateOutcome>>()
|
|
.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",
|
|
}),
|
|
);
|
|
});
|
|
});
|