feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -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",
}),
);
});
});