/** * 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(); 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); }); });