mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
169 lines
5.7 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|