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,501 @@
/**
* Integration tests for external_directory tool_call enforcement.
*
* These tests exercise PermissionGateHandler.handleToolCall with the
* external-directory gate, verifying the full descriptor→runner pipeline
* while mocking only the PermissionSession boundary.
*
* Regression guard: importing the four external-directory message helpers
* ensures the test file fails to load if any helper is removed.
*/
import { describe, expect, it, vi } from "vitest";
import { EXTENSION_TAG } from "#src/presentation/agent-renderer";
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import type { PermissionCheckResult } from "#src/types";
import {
ALL_PATH_BEARING_TOOLS,
ALL_TOOLS,
blockReviewEntries,
EXT_DIR_CWD,
EXTERNAL_PATH,
findExtDirDecision,
makeApprovingPrompter,
makeDenyingPrompter,
makeExtDirCheck,
makeUnavailablePrompter,
OPTIONAL_PATH_TOOLS,
} from "#test/helpers/external-directory-fixtures";
import {
getDecisionEvents,
makeCtx,
makeHandler,
makeToolCallEvent,
} from "#test/helpers/handler-fixtures";
// ── SDK stubs ──────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── Regression guard: helper presence ──────────────────────────────────────
describe("external_directory helper regression guard", () => {
it("the external-directory ask names the path it gates", () => {
expect(
buildExternalDirectoryAskPayload({
toolName: "read",
pathValue: "/outside/file",
cwd: "/project",
agentName: null,
}).request.value,
).toBe("/outside/file");
});
it("EXTENSION_TAG is the expected value", () => {
expect(EXTENSION_TAG).toBe("[pi-permission-system]");
});
// formatExternalDirectoryDenyReason, formatExternalDirectoryUserDeniedReason,
// and formatExternalDirectoryHardStopHint are now renders over the prompt
// payload. Their behavior is tested in presentation/agent-renderer.test.ts.
});
// ── Path scope: gate applicability ────────────────────────────────────────
describe("external_directory path scope", () => {
it("skips external_directory check when path is inside CWD", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", {
input: { path: `${EXT_DIR_CWD}/src/index.ts` },
});
const result = await handler.handleToolCall(event, makeCtx());
// Should not be blocked — the external_directory gate is skipped,
// and the tool gate sees "allow" (default toolState in makeExtDirCheck)
expect(result).toEqual({ action: "allow" });
});
it("fires external_directory check when path is outside CWD", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("skips external_directory check for non-path-bearing tool (bash)", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny", "allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("bash", {
input: { command: `cat ${EXTERNAL_PATH}` },
});
// bash is not in PATH_BEARING_TOOLS, so the external_directory gate
// for tool path does not fire (bash-external-directory gate is separate)
const result = await handler.handleToolCall(event, makeCtx());
// bash-external-directory gate MAY fire separately, but the tool-path
// external_directory gate does NOT fire for bash
// We verify the checkPermission was not called with "external_directory"
// from the tool-path gate by checking the result is not blocked by it
expect(result).toBeDefined();
});
it.each(
ALL_PATH_BEARING_TOOLS,
)("blocks %s with an out-of-cwd path when external_directory is deny", async (toolName) => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent(toolName, {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it.each(
OPTIONAL_PATH_TOOLS,
)("skips external_directory check for %s when path is omitted", async (toolName) => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
// No path in input — external_directory gate should not fire
const event = makeToolCallEvent(toolName);
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
});
// ── Policy state matrix: allow and deny ────────────────────────────────────
describe("external_directory policy state — allow", () => {
it("falls through to tool gate when external_directory is allow", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("emits decision event with policy_allow on external_directory surface", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "policy_allow",
});
});
it("does not write a block review-log entry when external_directory is allow", async () => {
const { handler, logger } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(blockReviewEntries(logger)).toHaveLength(0);
});
});
// #144: allow external reads, gate external writes
describe("external_directory — allow external reads, gate external writes (#144)", () => {
it("allows read of external path when external_directory and read are both allow", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("prompts for write to external path when external_directory allows but write is ask", async () => {
const { handler, prompter } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
// external_directory passes; write gate prompts and user approves
expect(result).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledOnce();
});
it("blocks write to external path when external_directory allows but write is deny", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("emits separate decision events for external_directory and write surfaces", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
await handler.handleToolCall(event, makeCtx());
const decisions = getDecisionEvents(events);
const writeDecision = decisions.find((d) => d.surface === "write");
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "policy_allow",
});
expect(writeDecision).toMatchObject({
surface: "write",
result: "deny",
resolution: "policy_deny",
});
});
});
describe("external_directory policy state — deny", () => {
it("blocks with reason containing the external path", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
expect((result as { reason?: string }).reason).toContain(EXTERNAL_PATH);
});
it("block reason contains extension attribution", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect((result as { reason?: string }).reason).toContain(
"[pi-permission-system]",
);
expect((result as { reason?: string }).reason).not.toContain("Hard stop");
});
it("writes review-log entry with resolution policy_denied", async () => {
const { handler, logger } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
const entries = blockReviewEntries(logger);
expect(entries.length).toBeGreaterThanOrEqual(1);
expect(entries[0][1]).toMatchObject({
resolution: "policy_denied",
});
});
it("emits decision event with policy_deny on external_directory surface", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "policy_deny",
});
});
});
// ── Policy state matrix: ask ────────────────────────────────────────────────
describe("external_directory policy state — ask", () => {
it("does not block when user approves", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("emits user_approved decision when user approves", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "user_approved",
});
});
it("blocks when user denies", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("emits user_denied decision when user denies", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "user_denied",
});
});
it("block reason includes denialReason when user provides one", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter("not needed"),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
expect((result as { reason?: string }).reason).toContain("not needed");
});
it("blocks with confirmation_unavailable when no UI is available", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeUnavailablePrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(
event,
makeCtx({ hasUI: false }),
);
expect(result).toMatchObject({ action: "block" });
// The gate surface names the boundary; an unavailable verdict states only
// that approval was unreachable, since no retry shape changes that.
expect((result as { reason?: string }).reason).toBe(
`${EXTENSION_TAG} This 'external_directory' call for tool 'read' for path '${EXTERNAL_PATH}' requires approval, but no interactive UI is available.`,
);
});
it("emits confirmation_unavailable decision when no UI", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeUnavailablePrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "confirmation_unavailable",
});
});
});
// ── Per-agent override ─────────────────────────────────────────────────────
describe("external_directory per-agent override", () => {
it("honors per-agent override of external_directory policy", async () => {
// checkPermission varies by agentName: allow for "special-agent", deny otherwise
const agentAwareCheck = vi
.fn()
.mockImplementation(
(
surface: string,
_input: unknown,
agentName?: string,
): PermissionCheckResult => {
if (surface === "external_directory") {
const state =
agentName === "special-agent" ? "allow" : ("deny" as const);
return {
state,
toolName: surface,
source: "tool",
origin: agentName === "special-agent" ? "agent" : "global",
};
}
return {
state: "allow",
toolName: surface,
source: "tool",
origin: "builtin",
};
},
);
// With agent override → allowed
const { handler: handler1, events: events1 } = makeHandler({
session: {
checkPermission: agentAwareCheck,
resolveAgentName: vi.fn().mockReturnValue("special-agent"),
},
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result1 = await handler1.handleToolCall(event, makeCtx());
expect(result1).toEqual({ action: "allow" });
expect(findExtDirDecision(events1)).toMatchObject({
result: "allow",
resolution: "policy_allow",
agentName: "special-agent",
});
// Without agent override → denied
const { handler: handler2 } = makeHandler({
session: {
checkPermission: agentAwareCheck,
resolveAgentName: vi.fn().mockReturnValue(null),
},
tools: ALL_TOOLS,
});
const result2 = await handler2.handleToolCall(event, makeCtx());
expect(result2).toMatchObject({ action: "block" });
});
});
// ── Decision event surface and value ──────────────────────────────────────
describe("external_directory decision event fields", () => {
it("decision event value is the external path", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
const extDirDecision = findExtDirDecision(events);
expect(extDirDecision).toBeDefined();
expect(extDirDecision!.value).toBe(EXTERNAL_PATH);
});
it("decision event includes agentName when present", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeExtDirCheck("allow"),
resolveAgentName: vi.fn().mockReturnValue("my-agent"),
},
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
agentName: "my-agent",
});
});
it("decision event agentName is null when no agent", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
agentName: null,
});
});
});