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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user