import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock node:fs so realpathSync (used by canonicalizePath) is controllable. // Default implementation is identity — lexical tests are unaffected. const realpathSync = vi.hoisted(() => vi.fn<(path: string) => string>((p) => p), ); vi.mock("node:fs", () => ({ realpathSync, default: { realpathSync }, })); import { AccessPath } from "#src/access-intent/access-path"; import type { GateDescriptor } from "#src/handlers/gates/descriptor"; import { isGateDescriptor } from "#src/handlers/gates/descriptor"; import { describePathGate } from "#src/handlers/gates/path"; import type { ToolCallContext } from "#src/handlers/gates/types"; import { pathFlavorForPlatform, posixPathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { makeGateCheckResult as makeCheckResult, makeResolver, } from "#test/helpers/gate-fixtures"; // ── helpers ──────────────────────────────────────────────────────────────── // path.test.ts uses read-tool defaults; the shared makeTcc uses bash defaults. function makeTcc(overrides: Partial = {}): ToolCallContext { return { toolName: "read", agentName: null, input: { path: ".env" }, toolCallId: "tc-1", cwd: "/test/project", ...overrides, }; } // The gate reads the path normalizer (platform + cwd baked in) from the // session; here it is bound to the makeTcc default cwd. const normalizer = new PathNormalizer( pathFlavorForPlatform(process.platform), "/test/project", ); // ── tests ────────────────────────────────────────────────────────────────── describe("describePathGate", () => { beforeEach(() => { realpathSync.mockReset(); realpathSync.mockImplementation((p: string) => p); }); it("returns null for non-path-bearing tools", () => { const resolver = makeResolver(); const result = describePathGate( makeTcc({ toolName: "bash", input: { command: "ls" } }), resolver, normalizer, ); expect(result).toBeNull(); expect(resolver.resolve).not.toHaveBeenCalled(); }); it("returns null when tool has no extractable path", () => { const resolver = makeResolver(); const result = describePathGate( makeTcc({ toolName: "read", input: {} }), resolver, normalizer, ); expect(result).toBeNull(); }); it("returns null when path check result is allow", () => { const resolver = makeResolver(makeCheckResult({ state: "allow" })); const result = describePathGate(makeTcc(), resolver, normalizer); expect(result).toBeNull(); }); it("returns null when matchedPattern is undefined (universal default)", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: undefined, source: "special", origin: "builtin", }), ); const result = describePathGate(makeTcc(), resolver, normalizer); expect(result).toBeNull(); }); it("returns GateDescriptor when matchedPattern is defined (explicit path rule)", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*.env", source: "special", origin: "global", }), ); const result = describePathGate(makeTcc(), resolver, normalizer); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); }); it("returns GateDescriptor when path check result is deny", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = describePathGate(makeTcc(), resolver, normalizer); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); const desc = result as GateDescriptor; expect(desc.surface).toBe("path"); expect(desc.preCheck?.state).toBe("deny"); }); it("returns GateDescriptor when path check result is ask", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*.env" }), ); const result = describePathGate(makeTcc(), resolver, normalizer); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); const desc = result as GateDescriptor; expect(desc.surface).toBe("path"); expect(desc.preCheck?.state).toBe("ask"); }); it("descriptor has correct session approval surface and pattern", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*" }), ); const result = describePathGate( makeTcc({ input: { path: "/test/project/src/.env" } }), resolver, normalizer, ) as GateDescriptor; expect(result.sessionApproval).toBeDefined(); expect(result.sessionApproval?.surface).toBe("path"); expect(result.sessionApproval?.representativePattern).toBeDefined(); }); it("binds a current-directory file's session approval to the cwd subtree", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*" }), ); const result = describePathGate( makeTcc({ input: { path: "index.html" }, cwd: "/test/project" }), resolver, normalizer, ) as GateDescriptor; expect(result.sessionApproval?.surface).toBe("path"); expect(result.sessionApproval?.representativePattern).toBe( "/test/project/*", ); }); it("descriptor denialContext references the file path and tool name", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = describePathGate( makeTcc(), resolver, normalizer, ) as GateDescriptor; expect(result.payload.kind).toBe("path"); expect(result.payload.request.toolName).toBe("read"); expect(result.payload.request.value).toBe(".env"); expect(result.payload.request.requester.agentName).toBeNull(); }); it("carries the child-fixed access facts on promptDetails (path surface)", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*.env" }), ); const result = describePathGate( makeTcc(), resolver, normalizer, ) as GateDescriptor; const accessPath = AccessPath.forPath(".env", { cwd: "/test/project", flavor: posixPathFlavor, }); expect(result.promptDetails.accessIntent).toEqual({ surface: "path", matchValues: accessPath.matchValues(), boundaryValue: accessPath.boundaryValue(), }); }); it("emits a path payload naming the matched rule", () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*.env" }), ); const result = describePathGate( makeTcc(), resolver, normalizer, ) as GateDescriptor; expect(result.payload.kind).toBe("path"); expect(result.payload.request.value).toBe(".env"); expect(result.payload.request.matchedPattern).toBe("*.env"); }); it("descriptor decision uses surface 'path' and the file path as value", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = describePathGate( makeTcc(), resolver, normalizer, ) as GateDescriptor; expect(result.decision.surface).toBe("path"); expect(result.decision.value).toBe(".env"); }); it("resolves the path surface with an access-path intent and agent name", () => { const resolver = makeResolver(makeCheckResult({ state: "allow" })); describePathGate(makeTcc({ agentName: "my-agent" }), resolver, normalizer); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forPath(".env", { cwd: "/test/project", flavor: posixPathFlavor, }), agentName: "my-agent", }); }); it("emits an access-path whose matchValues include the symlink-resolved form (#486)", () => { // /test/project/.env is a symlink to /vault/secret.env. realpathSync.mockImplementation((p: string) => p === "/test/project/.env" ? "/vault/secret.env" : p, ); const resolver = makeResolver(makeCheckResult({ state: "allow" })); describePathGate(makeTcc(), resolver, normalizer); const intent = resolver.resolve.mock.lastCall?.[0]; expect(intent?.kind).toBe("access-path"); expect(intent?.kind === "access-path" && intent.path.matchValues()).toEqual( ["/test/project/.env", ".env", "/vault/secret.env"], ); }); }); // Home-relative path characterization (#350) ────────────────────────────── // // The gate passes the raw path to the resolver; home expansion is handled // downstream by normalizeInput. These tests lock in that the gate works // correctly when the tool input contains a ~/... or $HOME/... path. describe("describePathGate — home-relative paths", () => { it("passes raw ~/... path to resolver and builds descriptor on deny", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "~/.ssh/*" }), ); const result = describePathGate( makeTcc({ input: { path: "~/.ssh/config" } }), resolver, normalizer, ) as GateDescriptor; expect(isGateDescriptor(result)).toBe(true); expect(result.preCheck?.state).toBe("deny"); // Raw path preserved on the payload for display. expect(result.payload.kind).toBe("path"); expect(result.payload.request.toolName).toBe("read"); expect(result.payload.request.value).toBe("~/.ssh/config"); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forPath("~/.ssh/config", { cwd: "/test/project", flavor: posixPathFlavor, }), agentName: undefined, }); }); it("passes raw $HOME/... path to resolver and builds descriptor on deny", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "$HOME/.ssh/*" }), ); const result = describePathGate( makeTcc({ input: { path: "$HOME/.ssh/config" } }), resolver, normalizer, ) as GateDescriptor; expect(isGateDescriptor(result)).toBe(true); expect(result.preCheck?.state).toBe("deny"); expect(result.payload.kind).toBe("path"); expect(result.payload.request.value).toBe("$HOME/.ssh/config"); }); it("returns null when home-relative path resolves to allow", () => { const resolver = makeResolver(makeCheckResult({ state: "allow" })); const result = describePathGate( makeTcc({ input: { path: "~/.ssh/config" } }), resolver, normalizer, ); expect(result).toBeNull(); }); }); // Extension and MCP tools are now path-gated (#352) ────────────────────────── describe("describePathGate — extension and MCP tools (#352)", () => { function extractorLookup(toolName: string, key: string) { return { get: (name: string) => name === toolName ? (input: Record) => typeof input[key] === "string" ? input[key] : undefined : undefined, }; } it("gates an extension tool that exposes input.path", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = describePathGate( makeTcc({ toolName: "my-ext", input: { path: ".env" } }), resolver, normalizer, ); expect(isGateDescriptor(result)).toBe(true); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forPath(".env", { cwd: "/test/project", flavor: posixPathFlavor, }), agentName: undefined, }); }); it("gates an MCP tool via arguments.path", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = describePathGate( makeTcc({ toolName: "mcp", input: { arguments: { path: ".env" } } }), resolver, normalizer, ); expect(isGateDescriptor(result)).toBe(true); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forPath(".env", { cwd: "/test/project", flavor: posixPathFlavor, }), agentName: undefined, }); }); it("uses a registered extractor's path for a custom-shaped tool", () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*" }), ); describePathGate( makeTcc({ toolName: "ffgrep", input: { target: "/etc/passwd" } }), resolver, normalizer, extractorLookup("ffgrep", "target"), ); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forPath("/etc/passwd", { cwd: "/test/project", flavor: posixPathFlavor, }), agentName: undefined, }); }); it("returns null for an extension tool without a path", () => { const resolver = makeResolver(); const result = describePathGate( makeTcc({ toolName: "my-ext", input: { other: true } }), resolver, normalizer, ); expect(result).toBeNull(); expect(resolver.resolve).not.toHaveBeenCalled(); }); });