import { afterEach, describe, expect, it, vi } from "vitest"; // Mock node:os so tilde-expansion is deterministic across platforms. vi.mock("node:os", () => { const homedir = vi.fn(() => "/mock/home"); return { homedir, default: { homedir }, }; }); import { AccessPath } from "#src/access-intent/access-path"; import { BashProgram } from "#src/access-intent/bash/program"; import { describeBashPathGate } from "#src/handlers/gates/bash-path"; import type { GateBypass, GateDescriptor, GateResult, } from "#src/handlers/gates/descriptor"; import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor"; import type { ToolCallContext } from "#src/handlers/gates/types"; import { pathFlavorForPlatform, posixPathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import type { ScopedPermissionResolver } from "#src/permission-resolver"; import { getNonEmptyString, toRecord } from "#src/value-guards"; import { makeGateCheckResult as makeCheckResult, makePathDispatchResolver, makeResolver, makeTcc, } from "#test/helpers/gate-fixtures"; afterEach(() => { vi.restoreAllMocks(); }); /** * Mirror the handler's parse-once derivation: parse the bash command into a * shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts` * does, so the gate is exercised through the production wiring. */ async function describeGate( tcc: ToolCallContext, resolver: ScopedPermissionResolver, ): Promise { return describeGateOnPlatform(process.platform, tcc, resolver); } /** * Variant of {@link describeGate} that injects an explicit host platform, so a * win32-specific decision can be exercised on a POSIX CI host (and vice versa) * without mocking `node:path` (#520). */ async function describeGateOnPlatform( platform: NodeJS.Platform, tcc: ToolCallContext, resolver: ScopedPermissionResolver, ): Promise { const command = getNonEmptyString(toRecord(tcc.input).command); const bashProgram = tcc.toolName === "bash" && command ? await BashProgram.parse( command, new PathNormalizer(pathFlavorForPlatform(platform), tcc.cwd), ) : null; return describeBashPathGate(tcc, bashProgram, resolver); } // ── tests ────────────────────────────────────────────────────────────────── describe("describeBashPathGate", () => { it("returns null for non-bash tools", async () => { const result = await describeGate( makeTcc({ toolName: "read", input: { path: ".env" } }), makeResolver(), ); expect(result).toBeNull(); }); it("returns null when no tokens are extracted", async () => { const result = await describeGate( makeTcc({ input: { command: "echo hello" } }), makeResolver(), ); expect(result).toBeNull(); }); it("returns null when all tokens evaluate to allow", async () => { const result = await describeGate( makeTcc(), makeResolver(makeCheckResult({ state: "allow" })), ); expect(result).toBeNull(); }); it("returns GateDescriptor when a token evaluates to deny", async () => { const result = await describeGate( makeTcc(), makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })), ); 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 a token evaluates to ask", async () => { const result = await describeGate( makeTcc(), makeResolver(makeCheckResult({ state: "ask", matchedPattern: "*" })), ); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); const desc = result as GateDescriptor; expect(desc.preCheck?.state).toBe("ask"); }); it("descriptor includes triggering token in prompt message", async () => { const result = (await describeGate( makeTcc(), makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })), )) as GateDescriptor; expect(result.promptDetails.command).toBe("cat .env"); // The bash path gate asks about the offending token, not the command. expect(result.payload.kind).toBe("path"); expect(result.payload.request.value).toBe(".env"); }); it("descriptor decision uses surface 'path'", async () => { const result = (await describeGate( makeTcc(), makeResolver(makeCheckResult({ state: "deny", matchedPattern: "*.env" })), )) as GateDescriptor; expect(result.decision.surface).toBe("path"); }); it("carries the deciding token's access facts on promptDetails (bash path surface)", async () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = (await describeGate(makeTcc(), resolver)) as GateDescriptor; // The facts are the string projection of the same AccessPath the gate // resolved for the deciding token. const intent = resolver.resolve.mock.calls.at(-1)?.[0]; const path = intent?.kind === "access-path" ? intent.path : undefined; expect(path).toBeDefined(); expect(result.promptDetails.accessIntent).toEqual({ surface: "path", matchValues: path?.matchValues(), boundaryValue: path?.boundaryValue(), }); }); it("returns GateBypass when session rule covers the path", async () => { const result = await describeGate( makeTcc(), makeResolver(makeCheckResult({ state: "allow", source: "session" })), ); expect(result).not.toBeNull(); expect(isGateBypass(result)).toBe(true); expect((result as GateBypass).action).toBe("allow"); expect((result as GateBypass).decidedBy).toEqual({ kind: "session_approval", surface: "path", pattern: null, }); }); it("returns null when command is missing", async () => { const result = await describeGate(makeTcc({ input: {} }), makeResolver()); expect(result).toBeNull(); }); it("evaluates most restrictive across multiple tokens", async () => { const resolver = makePathDispatchResolver( { "src/foo.ts": makeCheckResult({ state: "allow" }) }, makeCheckResult({ state: "deny", matchedPattern: "*.env" }), ); const result = await describeGate( makeTcc({ input: { command: "cat src/foo.ts .env" } }), resolver, ); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); expect((result as GateDescriptor).preCheck?.state).toBe("deny"); }); it("deny wins in multi-token: cp .env README.md", async () => { const resolver = makePathDispatchResolver( { ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) }, makeCheckResult({ state: "allow" }), ); const result = await describeGate( makeTcc({ input: { command: "cp .env README.md" } }), resolver, ); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); const desc = result as GateDescriptor; expect(desc.preCheck?.state).toBe("deny"); expect(desc.decision.value).toBe(".env"); }); it("extracts redirect target: echo test > .env triggers deny", async () => { const resolver = makePathDispatchResolver( { ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) }, makeCheckResult({ state: "allow" }), ); const result = await describeGate( makeTcc({ input: { command: "echo test > .env" } }), resolver, ); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); expect((result as GateDescriptor).preCheck?.state).toBe("deny"); }); it("returns null when all tokens match only the universal default", async () => { const result = await describeGate( makeTcc(), makeResolver( makeCheckResult({ state: "ask", matchedPattern: undefined, source: "special", origin: "builtin", }), ), ); expect(result).toBeNull(); }); it("ignores tokens matching universal default but fires for explicit rule matches", async () => { const resolver = makePathDispatchResolver( { ".env": makeCheckResult({ state: "deny", matchedPattern: "*.env" }) }, // Other tokens match only the universal default (no matchedPattern) makeCheckResult({ state: "ask", matchedPattern: undefined, source: "special", origin: "builtin", }), ); const result = await describeGate( makeTcc({ input: { command: "cat src/foo.ts .env" } }), resolver, ); expect(result).not.toBeNull(); expect(isGateDescriptor(result)).toBe(true); const desc = result as GateDescriptor; expect(desc.preCheck?.state).toBe("deny"); expect(desc.decision.value).toBe(".env"); }); it("resolves cd-aware policy values while keeping the raw prompt token", async () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*" }), ); const result = (await describeGate( makeTcc({ input: { command: "cd nested && cat src/file.txt" }, cwd: "/test/project", }), resolver, )) as GateDescriptor; expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forPath("src/file.txt", { cwd: "/test/project", resolveBase: "/test/project/nested", flavor: posixPathFlavor, }), agentName: undefined, }); // The raw token drives the prompt payload, the decision, and the approval. expect(result.payload.request.value).toBe("src/file.txt"); expect(result.decision.value).toBe("src/file.txt"); }); it("does not resolve relative policy values through an unknown cd", async () => { const resolver = makeResolver( makeCheckResult({ state: "deny", matchedPattern: "*" }), ); await describeGate( makeTcc({ input: { command: 'cd "$DIR" && cat src/foo.ts' }, cwd: "/test/project", }), resolver, ); expect(resolver.resolve).toHaveBeenCalledWith({ kind: "access-path", surface: "path", path: AccessPath.forLiteral("src/foo.ts"), agentName: undefined, }); }); it("binds a current-directory token's session approval to the cwd subtree", async () => { const resolver = makeResolver( makeCheckResult({ state: "ask", matchedPattern: "*" }), ); const result = (await describeGate( makeTcc({ input: { command: "cat .env" }, cwd: "/test/project", }), resolver, )) as GateDescriptor; expect(result.decision.value).toBe(".env"); expect(result.sessionApproval?.surface).toBe("path"); expect(result.sessionApproval?.representativePattern).toBe( "/test/project/*", ); }); }); // Home-relative path characterization (#350) ────────────────────────────── // // The parser extracts ~/... tokens from bash commands; the resolver receives // the raw token and normalizeInput handles expansion. These tests verify the // gate correctly dispatches ~/... tokens through the deny/ask path. describe("describeBashPathGate — home-relative paths", () => { it("extracts ~/... token and builds descriptor on deny", async () => { // node:os is mocked: homedir() returns "/mock/home". // cat ~/.ssh/config → token "~/.ssh/config" extracted. const resolver = makePathDispatchResolver( { "/mock/home/.ssh/config": makeCheckResult({ state: "deny", matchedPattern: "~/.ssh/*", }), }, makeCheckResult({ state: "allow" }), ); const result = (await describeGate( makeTcc({ input: { command: "cat ~/.ssh/config" } }), resolver, )) as GateDescriptor; expect(isGateDescriptor(result)).toBe(true); expect(result.preCheck?.state).toBe("deny"); expect(result.promptDetails.command).toBe("cat ~/.ssh/config"); expect(result.payload.request.value).toBe("~/.ssh/config"); }); it("extracts $HOME/... token and builds descriptor on deny", async () => { const resolver = makePathDispatchResolver( { "/mock/home/.ssh/config": makeCheckResult({ state: "deny", matchedPattern: "$HOME/.ssh/*", }), }, makeCheckResult({ state: "allow" }), ); const result = (await describeGate( makeTcc({ input: { command: "cat $HOME/.ssh/config" } }), resolver, )) as GateDescriptor; expect(isGateDescriptor(result)).toBe(true); expect(result.preCheck?.state).toBe("deny"); // A plain `$HOME` reference is resolved at token collection (#694), so the // displayed token is the path the shell will actually touch — and it now // agrees with the session-approval pattern, which has always been derived // from the expanded `AccessPath.value()`. A `~` token keeps its raw // spelling: it is shape-classified directly and never needed // collection-time expansion. expect(result.payload.request.value).toBe("/mock/home/.ssh/config"); }); }); // Win32 backslash-relative path gating (#520) ────────────────────────────── // // On Windows a backslash is a path separator, so a backslash-relative bash // argument (`cat dir\file`) must be gated by a `path` rule the same as its // forward-slash equivalent (`dir/file`). On POSIX `\` is a legal filename // character, so the token stays bare and is not gated. describe("describeBashPathGate — win32 backslash-relative paths", () => { it("denies a backslash-relative token matching a path rule on win32", async () => { // The win32 normalizer resolves `dir\file` to matchValues including the // relative `dir\file` alias, which the rule (`dir/file`, folded to // `dir\file` under win32 separators) matches. const resolver = makePathDispatchResolver( { "dir\\file": makeCheckResult({ state: "deny", matchedPattern: "dir/file", }), }, makeCheckResult({ state: "allow" }), ); const result = (await describeGateOnPlatform( "win32", makeTcc({ input: { command: "cat dir\\file" }, cwd: "C:\\Projects\\App", }), resolver, )) as GateDescriptor; expect(isGateDescriptor(result)).toBe(true); expect(result.preCheck?.state).toBe("deny"); expect(result.payload.request.value).toBe("dir\\file"); }); it("does not gate a backslash-relative token on posix (stays bare)", async () => { const resolver = makePathDispatchResolver( { "dir\\file": makeCheckResult({ state: "deny", matchedPattern: "dir/file", }), }, makeCheckResult({ state: "allow" }), ); const result = await describeGateOnPlatform( "linux", makeTcc({ input: { command: "cat dir\\file" }, cwd: "/projects/app", }), resolver, ); expect(result).toBeNull(); }); });