Files
my-pi/pi-permission-system/test/handlers/external-directory-session-dedup.test.ts
T

176 lines
7.2 KiB
TypeScript

/**
* Integration tests verifying that sequential tool calls to the same
* external path only prompt once — the session-approval recorded by the
* first call covers the second.
*
* Uses real PermissionSession + PermissionResolver + SessionRules so the
* stateful approval-tracking path is exercised end-to-end.
*/
import { describe, expect, it, vi } from "vitest";
import {
makeApprovingPrompter,
makeDeduplicatingHandler,
makeDedupWiring,
makeExtDirBashEvent,
makeExtDirToolEvent,
} from "#test/helpers/external-directory-fixtures";
import { makeCtx } from "#test/helpers/handler-fixtures";
// ── SDK stub ───────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── tests ──────────────────────────────────────────────────────────────────
describe("external-directory session dedup", () => {
describe("path-bearing tools (read, write, edit)", () => {
it("does not re-prompt for the same external path after session approval", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
const externalPath = "/outside/project/data.txt";
// First call — should prompt
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
const result1 = await handler.handleToolCall(event1, ctx);
expect(result1).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — same path, should hit session rule, no prompt
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
const result2 = await handler.handleToolCall(event2, ctx);
expect(result2).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does not re-prompt for a different file in the same external directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — prompt for /outside/project/a.txt
const event1 = makeExtDirToolEvent(
"read",
"/outside/project/a.txt",
"tc-1",
);
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — /outside/project/b.txt is in the same directory
const event2 = makeExtDirToolEvent(
"read",
"/outside/project/b.txt",
"tc-2",
);
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does prompt for a file in a different external directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — /outside/alpha/file.txt
const event1 = makeExtDirToolEvent(
"read",
"/outside/alpha/file.txt",
"tc-1",
);
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — /outside/beta/file.txt is a different directory
const event2 = makeExtDirToolEvent(
"read",
"/outside/beta/file.txt",
"tc-2",
);
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(2);
});
it("re-prompts when user approved once (not for session)", async () => {
const approveOnce = makeApprovingPrompter();
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
const ctx = makeCtx();
const externalPath = "/outside/project/data.txt";
// First call — prompt, approved once
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — no session rule recorded, should prompt again
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(2);
});
});
describe("bash commands with external paths", () => {
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — bash referencing /tmp/out.txt
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
const result1 = await handler.handleToolCall(event1, ctx);
expect(result1).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — different bash command, same external path
const event2 = makeExtDirBashEvent("cat /tmp/out.txt", "tc-2");
const result2 = await handler.handleToolCall(event2, ctx);
expect(result2).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does not re-prompt for read after bash already approved the same directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — bash writes to /tmp/out.txt
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — read from /tmp/out.txt (same directory, different tool)
const event2 = makeExtDirToolEvent("read", "/tmp/out.txt", "tc-2");
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
describe("session shutdown clears external-directory approvals", () => {
it("re-prompts for the same path after session shutdown", async () => {
const { handler, prompter, session } = makeDedupWiring();
const externalPath = "/tmp/sibling/foo.ts";
const ctx = makeCtx();
const event = makeExtDirToolEvent("read", externalPath, "tc-1");
// First access: prompt fires and records session approval.
await handler.handleToolCall(event, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
// Second access: covered by session approval — no re-prompt.
await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
// Shutdown clears session approvals.
session.shutdown();
// Third access: session rules cleared — must re-prompt.
await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(2);
});
});