mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
/**
|
|
* 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,
|
|
});
|
|
});
|
|
});
|