/** * Acceptance test for issue #418. * * Reproduces the reported bug with a real symlink (no `realpathSync` mock): * an `external_directory` allow configured for the path as the user types it * (`/*`) must allow access even though the OS resolves `` to a * different canonical directory. Exercised end-to-end through the real * `PermissionManager` + `PermissionResolver` for both a path-bearing tool and * a bash command, and for an allow keyed on the symlink-resolved form too. */ import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BashProgram } from "#src/access-intent/bash/program"; import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory"; import { type GateDescriptor, 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 { PermissionResolver } from "#src/permission-resolver"; import { SessionRules } from "#src/session-rules"; import type { ScopeConfig } from "#src/types"; import { createManager } from "#test/helpers/manager-harness"; // ── real symlink fixture ───────────────────────────────────────────────────── let realDir: string; let linkDir: string; let cwd: string; const tempRoots: string[] = []; function mkTemp(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); tempRoots.push(dir); return dir; } beforeEach(() => { realDir = mkTemp("ext-real-"); const linkParent = mkTemp("ext-link-"); linkDir = join(linkParent, "link"); symlinkSync(realDir, linkDir); cwd = mkTemp("ext-cwd-"); }); afterEach(() => { while (tempRoots.length > 0) { const dir = tempRoots.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); } }); function makeResolver(config: ScopeConfig) { const { manager, cleanup } = createManager(config); manager.configureForCwd(cwd); const resolver = new PermissionResolver(manager, new SessionRules()); return { resolver, cleanup }; } function readTcc(): ToolCallContext { return { toolName: "read", agentName: null, input: { path: join(linkDir, "file.ts") }, toolCallId: "tc-1", cwd, }; } // ── tests ──────────────────────────────────────────────────────────────────── describe("external_directory symlink acceptance (#418)", () => { it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => { const { resolver, cleanup } = makeResolver({ permission: { external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" }, }, }); try { const result = describeExternalDirectoryGate( readTcc(), [], resolver, new PathNormalizer(pathFlavorForPlatform(process.platform), cwd), ); expect(isGateDescriptor(result)).toBe(true); expect((result as GateDescriptor).preCheck?.state).toBe("allow"); } finally { cleanup(); } }); it("allows a path-bearing tool when the allow is keyed on the resolved path", () => { // Key the allow on the fully symlink-resolved directory (on macOS the // tmpdir root itself is a symlink, e.g. /var -> /private/var). const resolved = realpathSync(realDir); const { resolver, cleanup } = makeResolver({ permission: { external_directory: { "*": "ask", [`${resolved}/*`]: "allow" }, }, }); try { const result = describeExternalDirectoryGate( readTcc(), [], resolver, new PathNormalizer(pathFlavorForPlatform(process.platform), cwd), ); expect(isGateDescriptor(result)).toBe(true); expect((result as GateDescriptor).preCheck?.state).toBe("allow"); } finally { cleanup(); } }); it("still prompts (ask) when no external_directory allow matches", () => { const { resolver, cleanup } = makeResolver({ permission: { external_directory: { "*": "ask" } }, }); try { const result = describeExternalDirectoryGate( readTcc(), [], resolver, new PathNormalizer(pathFlavorForPlatform(process.platform), cwd), ); expect(isGateDescriptor(result)).toBe(true); expect((result as GateDescriptor).preCheck?.state).toBe("ask"); } finally { cleanup(); } }); it("allows a bash command referencing the typed (symlinked) path", async () => { const { resolver, cleanup } = makeResolver({ permission: { external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" }, }, }); try { const command = `cat ${join(linkDir, "file.ts")}`; const tcc: ToolCallContext = { toolName: "bash", agentName: null, input: { command }, toolCallId: "tc-2", cwd, }; const program = await BashProgram.parse( command, new PathNormalizer(pathFlavorForPlatform(process.platform), cwd), ); const result = describeBashExternalDirectoryGate(tcc, program, resolver); // All external paths are covered by the allow → bypass, no prompt. expect(isGateBypass(result)).toBe(true); } finally { cleanup(); } }); });