Files
my-pi/pi-permission-system/test/handlers/external-directory-symlink-acceptance.test.ts

169 lines
5.7 KiB
TypeScript

/**
* 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
* (`<link>/*`) must allow access even though the OS resolves `<link>` 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();
}
});
});