feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,384 @@
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock node:os so tilde-expansion is deterministic across platforms.
// Every other binding passes through, so `tmpdir()` reaches the real module
// for the filesystem-backed `entryExists` fixtures below.
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
const homedir = vi.fn(() => "/mock/home");
return {
...actual,
homedir,
default: { ...actual, homedir },
};
});
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
// Default implementation is identity — lexical assertions are unaffected.
// Every other fs binding passes through to the real module, so filesystem-
// backed helpers (lstatSync, mkdtempSync, symlinkSync, …) stay usable here.
const realpathSync = vi.hoisted(() =>
vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync,
default: { ...actual, realpathSync },
};
});
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { createTmpFixture } from "#test/helpers/tmp-fixture";
describe("PathNormalizer", () => {
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
describe("posix flavor", () => {
const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app");
test("forPath builds an AccessPath resolved against the baked cwd", () => {
const ap = normalizer.forPath("src/foo.ts");
expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
expect(ap.matchValues()).toEqual([
"/projects/my-app/src/foo.ts",
"src/foo.ts",
]);
});
test("forPath honors an explicit resolveBase", () => {
const ap = normalizer.forPath("foo.ts", {
resolveBase: "/projects/my-app/sub",
});
expect(ap.value()).toBe("/projects/my-app/sub/foo.ts");
});
test("forLiteral builds a literal-only AccessPath", () => {
const ap = normalizer.forLiteral("foo.ts");
expect(ap.matchValues()).toEqual(["foo.ts"]);
expect(ap.boundaryValue()).toBe("");
});
test("isAbsolute uses posix rules", () => {
expect(normalizer.isAbsolute("/etc/hosts")).toBe(true);
expect(normalizer.isAbsolute("rel/path")).toBe(false);
});
test("resolveBase resolves an offset against the baked cwd", () => {
expect(normalizer.resolveBase("sub")).toBe("/projects/my-app/sub");
expect(normalizer.resolveBase("/abs")).toBe("/abs");
});
test("joinBase joins an offset with a relative target", () => {
expect(normalizer.joinBase("sub", "nested")).toBe("sub/nested");
});
test("isWithinDirectory decides containment", () => {
expect(normalizer.isWithinDirectory("/a/b/c", "/a/b")).toBe(true);
expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false);
});
test("isOutsideWorkingDirectory tests against the baked cwd", () => {
expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe(
false,
);
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
});
test("isOutsideWorkingDirectory expands a home-relative token", () => {
expect(normalizer.isOutsideWorkingDirectory("~/secrets")).toBe(true);
});
test("isOutsideWorkingDirectory resolves a relative token inside cwd", () => {
expect(normalizer.isOutsideWorkingDirectory("src/index.ts")).toBe(false);
});
test("isOutsideWorkingDirectory follows an in-cwd symlink to an external target", () => {
// ./link -> /etc: realpathSync resolves the full token in one call.
realpathSync.mockImplementation((p: string) => {
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
return p;
});
expect(normalizer.isOutsideWorkingDirectory("./link/hosts")).toBe(true);
});
test("isOutsideWorkingDirectory keeps a path inside a symlinked cwd", () => {
// /tmp -> /private/tmp on macOS; cwd reported as the resolved /private/tmp.
realpathSync.mockImplementation((p: string) => {
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
if (p === "/tmp") return "/private/tmp";
return p;
});
const symlinkNormalizer = new PathNormalizer(
posixPathFlavor,
"/private/tmp",
);
expect(
symlinkNormalizer.isOutsideWorkingDirectory("/tmp/workspace/file.ts"),
).toBe(false);
});
test("comparableValue returns the lexical absolute form (no FS)", () => {
expect(normalizer.comparableValue("src/foo.ts")).toBe(
"/projects/my-app/src/foo.ts",
);
expect(normalizer.comparableValue("/etc/hosts")).toBe("/etc/hosts");
});
test("forBashToken delegates to forPath on posix", () => {
const ap = normalizer.forBashToken("src/foo.ts");
expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
});
test("isBoundaryOutsideWorkingDirectory tests a canonical path against cwd", () => {
expect(
normalizer.isBoundaryOutsideWorkingDirectory("/projects/my-app/src"),
).toBe(false);
expect(normalizer.isBoundaryOutsideWorkingDirectory("/etc/hosts")).toBe(
true,
);
});
test("isBoundaryOutsideWorkingDirectory excludes a safe device path", () => {
expect(normalizer.isBoundaryOutsideWorkingDirectory("/dev/null")).toBe(
false,
);
});
test("interpretBashCdTarget maps an absolute target to absolute (posix)", () => {
expect(normalizer.interpretBashCdTarget("/etc")).toEqual({
kind: "absolute",
value: "/etc",
});
});
test("interpretBashCdTarget maps a relative target to relative (posix)", () => {
expect(normalizer.interpretBashCdTarget("sub")).toEqual({
kind: "relative",
});
});
});
describe("win32 flavor", () => {
const normalizer = new PathNormalizer(win32PathFlavor, "C:\\Projects\\App");
test("forPath builds a case-folded AccessPath with win32 rules", () => {
const ap = normalizer.forPath("src\\foo.ts");
expect(ap.value()).toBe("c:\\projects\\app\\src\\foo.ts");
expect(ap.matchValues()).toEqual([
"c:\\projects\\app\\src\\foo.ts",
"src\\foo.ts",
]);
});
test("isAbsolute uses win32 rules", () => {
expect(normalizer.isAbsolute("C:\\Users\\foo")).toBe(true);
expect(normalizer.isAbsolute("rel\\path")).toBe(false);
});
test("resolveBase resolves an offset against the baked cwd", () => {
expect(normalizer.resolveBase("sub")).toBe("C:\\Projects\\App\\sub");
});
test("joinBase joins an offset with a relative target", () => {
expect(normalizer.joinBase("sub", "nested")).toBe("sub\\nested");
});
test("isWithinDirectory folds case", () => {
expect(
normalizer.isWithinDirectory(
"c:\\users\\foo\\dir\\sub",
"C:\\Users\\Foo\\dir",
),
).toBe(true);
});
test("isOutsideWorkingDirectory case-folds against the baked cwd", () => {
expect(
normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"),
).toBe(false);
expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
});
test("comparableValue case-folds the lexical absolute form", () => {
expect(normalizer.comparableValue("src\\foo.ts")).toBe(
"c:\\projects\\app\\src\\foo.ts",
);
});
test("forBashToken preserves a POSIX device path as a safe device", () => {
const ap = normalizer.forBashToken("/dev/null");
expect(ap.value()).toBe("/dev/null");
expect(ap.boundaryValue()).toBe("/dev/null");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(false);
});
test("forBashToken preserves all four safe device paths", () => {
for (const device of [
"/dev/null",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
]) {
expect(normalizer.forBashToken(device).boundaryValue()).toBe(device);
}
});
test("forBashToken translates an MSYS drive mount to a Windows path", () => {
const ap = normalizer.forBashToken("/c/Other/x");
expect(ap.value()).toBe("c:\\other\\x");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(true);
});
test("forBashToken translates an in-cwd drive mount (not external)", () => {
const ap = normalizer.forBashToken("/c/projects/app/inside.txt");
expect(ap.value()).toBe("c:\\projects\\app\\inside.txt");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(false);
});
test("forBashToken keeps a non-mount POSIX absolute as a literal", () => {
const ap = normalizer.forBashToken("/tmp/foo");
// Matched and displayed as typed: the matcher folds separators on both
// the rule and the value, so a /tmp/* rule resolves without an alias.
expect(ap.value()).toBe("/tmp/foo");
expect(ap.boundaryValue()).toBe("");
expect(ap.matchValues()).toEqual(["/tmp/foo"]);
});
test("interpretBashCdTarget translates a drive-mount target to absolute", () => {
expect(normalizer.interpretBashCdTarget("/c/Other")).toEqual({
kind: "absolute",
value: "C:\\Other",
});
});
test("interpretBashCdTarget maps a non-mount POSIX absolute to unknown", () => {
expect(normalizer.interpretBashCdTarget("/tmp")).toEqual({
kind: "unknown",
});
});
test("interpretBashCdTarget maps a native drive path to absolute", () => {
expect(normalizer.interpretBashCdTarget("C:\\Other")).toEqual({
kind: "absolute",
value: "C:\\Other",
});
});
test("interpretBashCdTarget maps a relative target to relative (win32)", () => {
expect(normalizer.interpretBashCdTarget("sub")).toEqual({
kind: "relative",
});
});
test("isBoundaryOutsideWorkingDirectory case-folds against the baked cwd", () => {
expect(
normalizer.isBoundaryOutsideWorkingDirectory("c:\\projects\\app\\src"),
).toBe(false);
expect(
normalizer.isBoundaryOutsideWorkingDirectory("c:\\other\\dir"),
).toBe(true);
});
});
describe("isInfrastructureRead", () => {
const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app");
test("allows a read-only tool targeting a configured infra dir", () => {
const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
true,
);
});
test("does not allow a write tool targeting an infra dir", () => {
const ap = normalizer.forPath("/infra/git/pkg/file.ts");
expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
false,
);
});
test("does not allow a read-only tool outside any infra dir", () => {
const ap = normalizer.forPath("/elsewhere/file.ts");
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
false,
);
});
test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
});
});
describe("entryExists", () => {
// Real filesystem: the probe's whole purpose is to consult fs state, so
// these use actual temp files rather than the mocked realpathSync above.
const tmp = createTmpFixture();
let root: string;
let normalizer: PathNormalizer;
beforeEach(() => {
root = tmp.dir("pi-perm-exists-");
normalizer = new PathNormalizer(posixPathFlavor, root);
});
afterEach(() => {
tmp.cleanup();
});
test("existing regular file → true", () => {
const file = tmp.file(root, "secret.txt", "contents");
expect(normalizer.entryExists(file)).toBe(true);
});
test("existing directory → true", () => {
const dir = tmp.subdir(root, "nested");
expect(normalizer.entryExists(dir)).toBe(true);
});
test("symlink to an existing target → true", () => {
const target = tmp.file(root, "target.txt");
const link = tmp.symlink(root, "link", target);
expect(normalizer.entryExists(link)).toBe(true);
});
test("dangling symlink → true (lstat: the link itself is an entry)", () => {
const link = tmp.symlink(root, "dangling", join(root, "gone.txt"));
expect(normalizer.entryExists(link)).toBe(true);
});
test("nonexistent path → false", () => {
expect(normalizer.entryExists(join(root, "missing.txt"))).toBe(false);
});
test("path under a nonexistent parent → false (ENOTDIR/ENOENT)", () => {
expect(normalizer.entryExists(join(root, "no-dir", "file.txt"))).toBe(
false,
);
});
test("empty path → false", () => {
expect(normalizer.entryExists("")).toBe(false);
});
test("answers from the filesystem, not the flavor — win32 normalizer agrees", () => {
const file = tmp.file(root, "flavor-independent.txt");
const win32Normalizer = new PathNormalizer(win32PathFlavor, root);
expect(win32Normalizer.entryExists(file)).toBe(true);
expect(win32Normalizer.entryExists(join(root, "nope.txt"))).toBe(false);
});
});
});