import { describe, expect, it } from "vitest"; import type { GateBypass, GateDescriptor, } from "#src/handlers/gates/descriptor"; import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor"; import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory"; import type { ToolCallContext } from "#src/handlers/gates/types"; import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import type { ScopedPermissionResolver } from "#src/permission-resolver"; import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry"; import { makeResolver } from "#test/helpers/gate-fixtures"; import { makeCheckResult } from "#test/helpers/handler-fixtures"; // ── helpers ───────────────────────────��────────────────────────────��─────── function makeTcc(overrides: Partial = {}): ToolCallContext { return { toolName: "read", agentName: null, input: { path: "/outside/project/file.ts" }, toolCallId: "tc-1", cwd: "/test/project", ...overrides, }; } // Default resolver for descriptor-shape tests that do not assert the resolved // state: returns `ask` for the external_directory surface so a descriptor is // produced. Tests that assert the typed+resolved matching pass an explicit // resolver to `describeExternalDirectoryGate` directly. function gateUnderTest( tcc: ToolCallContext, infraDirs: string[], extractors?: ToolAccessExtractorLookup, resolver: ScopedPermissionResolver = makeResolver( makeCheckResult({ state: "ask", toolName: "external_directory" }), ), ) { return describeExternalDirectoryGate( tcc, infraDirs, resolver, new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd), extractors, ); } // ── tests ────────────────────��────────────────────────────────────��──────── describe("describeExternalDirectoryGate", () => { it("returns null when tool is not path-bearing", () => { const result = gateUnderTest( makeTcc({ toolName: "bash", input: { command: "ls" } }), ["/test/agent"], ); expect(result).toBeNull(); }); it("returns null when path is inside CWD", () => { const result = gateUnderTest( makeTcc({ input: { path: "/test/project/src/index.ts" } }), ["/test/agent"], ); expect(result).toBeNull(); }); // ── Pi infrastructure read bypass ─────────────────���──────────────────── it("returns GateBypass for read targeting an infra dir", () => { const result = gateUnderTest( makeTcc({ toolName: "read", input: { path: "/test/agent/git/some-package/SKILL.md" }, }), ["/test/agent", "/test/agent/git"], ); expect(result).not.toBeNull(); expect(isGateBypass(result)).toBe(true); const bypass = result as GateBypass; expect(bypass.action).toBe("allow"); expect(bypass.decision).toMatchObject({ resolution: "infrastructure_auto_allowed", result: "allow", }); expect(bypass.log).toMatchObject({ event: "permission_request.infrastructure_auto_allowed", }); // Containment allowed this, not a rule the operator wrote. expect(bypass.decidedBy).toEqual({ kind: "infrastructure_read" }); }); it("returns GateBypass respecting custom infraDirs", () => { const result = gateUnderTest( makeTcc({ toolName: "read", input: { path: "/custom/infra/SKILL.md" }, }), ["/custom/infra"], ); expect(isGateBypass(result)).toBe(true); }); it("does NOT bypass for write tools targeting infra dirs", () => { const result = gateUnderTest( makeTcc({ toolName: "write", input: { path: "/test/agent/git/some-file.ts", content: "x" }, }), ["/test/agent", "/test/agent/git"], ); // Should be a GateDescriptor (needs permission check), not a bypass expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); }); // ── GateDescriptor for external paths ─────────────────────────────────�� it("returns GateDescriptor with surface 'external_directory'", () => { const result = gateUnderTest(makeTcc(), ["/test/agent"]); expect(isGateDescriptor(result)).toBe(true); const desc = result as GateDescriptor; expect(desc.surface).toBe("external_directory"); }); it("decision value is the external path", () => { const result = gateUnderTest( makeTcc({ input: { path: "/outside/project/file.ts" } }), ["/test/agent"], ) as GateDescriptor; expect(result.decision.value).toBe("/outside/project/file.ts"); expect(result.decision.surface).toBe("external_directory"); }); it("carries the child-fixed access facts on promptDetails (external_directory surface)", () => { const path = "/outside/project/file.ts"; const result = gateUnderTest(makeTcc({ input: { path } }), [ "/test/agent", ]) as GateDescriptor; const accessPath = new PathNormalizer( pathFlavorForPlatform(process.platform), "/test/project", ).forPath(path); expect(result.promptDetails.accessIntent).toEqual({ surface: "external_directory", matchValues: accessPath.matchValues(), boundaryValue: accessPath.boundaryValue(), }); }); it("emits an external_directory payload carrying the escaped boundary", () => { const result = gateUnderTest( makeTcc({ input: { path: "/outside/project/file.ts" } }), ["/test/agent"], ) as GateDescriptor; expect(result.payload.kind).toBe("external_directory"); expect(result.payload.request.value).toBe("/outside/project/file.ts"); expect(result.payload.evidence).toContainEqual({ label: "working directory", text: "/test/project", detail: null, }); }); it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => { const result = gateUnderTest( makeTcc({ input: { path: "/outside/project/file.ts" } }), ["/test/agent"], ) as GateDescriptor; expect(result.input).toEqual({}); expect(result.preCheck).toBeDefined(); expect(result.preCheck?.state).toBe("ask"); }); it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", toolName: "external_directory" }), ); gateUnderTest( makeTcc({ input: { path: "/outside/project/file.ts" } }), ["/test/agent"], undefined, resolver, ); expect(resolver.resolve).toHaveBeenCalledWith( expect.objectContaining({ kind: "access-path", surface: "external_directory", agentName: undefined, }), ); const intent = resolver.resolve.mock.calls[0][0]; expect(intent.kind).toBe("access-path"); if (intent.kind === "access-path") { expect(intent.path.matchValues()).toEqual(["/outside/project/file.ts"]); } }); it("sessionApproval uses deriveApprovalPattern", () => { const result = gateUnderTest( makeTcc({ input: { path: "/outside/project/file.ts" } }), ["/test/agent"], ) as GateDescriptor; expect(result.sessionApproval).toBeDefined(); expect(result.sessionApproval?.surface).toBe("external_directory"); expect(result.sessionApproval?.representativePattern).toBeDefined(); }); it("payload contains the external path and the boundary it escaped", () => { const result = gateUnderTest( makeTcc({ input: { path: "/outside/project/file.ts" } }), ["/test/agent"], ) as GateDescriptor; expect(result.payload.kind).toBe("external_directory"); expect(result.payload.request.toolName).toBe("read"); expect(result.payload.request.value).toBe("/outside/project/file.ts"); expect(result.payload.evidence).toContainEqual({ label: "working directory", text: "/test/project", detail: null, }); }); it("promptDetails includes path and tool_call source", () => { const result = gateUnderTest( makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }), ["/test/agent"], ) as GateDescriptor; expect(result.promptDetails).toMatchObject({ source: "tool_call", agentName: "agent-1", toolCallId: "tc-5", toolName: "read", path: "/outside/project/file.ts", }); }); it("logContext includes the path, and no prompt wording", () => { const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor; expect(result.logContext).toMatchObject({ source: "tool_call", path: "/outside/project/file.ts", }); // The payload's request facts are stamped by the runner, not the gate. expect(result.logContext).not.toHaveProperty("message"); }); }); // Extension and MCP tools are now external-directory gated (#352) ─────────── describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => { it("gates an extension tool with an external input.path", () => { const result = gateUnderTest( makeTcc({ toolName: "my-ext", input: { path: "/outside/project/file.ts" }, }), ["/test/agent"], ); expect(isGateDescriptor(result)).toBe(true); expect((result as GateDescriptor).surface).toBe("external_directory"); }); it("gates an MCP tool with an external arguments.path", () => { const result = gateUnderTest( makeTcc({ toolName: "mcp", input: { arguments: { path: "/outside/project/file.ts" } }, }), ["/test/agent"], ); expect(isGateDescriptor(result)).toBe(true); }); it("uses a registered extractor's external path for a custom-shaped tool", () => { const extractors = { get: (name: string) => name === "ffgrep" ? (input: Record) => typeof input.target === "string" ? input.target : undefined : undefined, }; const result = gateUnderTest( makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }), ["/test/agent"], extractors, ); expect(isGateDescriptor(result)).toBe(true); }); it("returns null for an extension tool whose path is inside cwd", () => { const result = gateUnderTest( makeTcc({ toolName: "my-ext", input: { path: "/test/project/src/x.ts" }, }), ["/test/agent"], ); expect(result).toBeNull(); }); });