mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user