mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const realpathSync = vi.hoisted(() => vi.fn<(path: string) => string>());
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
realpathSync,
|
||||
default: { realpathSync },
|
||||
}));
|
||||
|
||||
import { canonicalizePath } from "#src/path/canonicalize-path";
|
||||
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
function enoent(p: string): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error(`ENOENT: no such file or directory '${p}'`), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
|
||||
describe("canonicalizePath", () => {
|
||||
beforeEach(() => {
|
||||
realpathSync.mockReset();
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(canonicalizePath("", posixPathFlavor)).toBe("");
|
||||
});
|
||||
|
||||
test("returns realpathSync result when path exists", () => {
|
||||
realpathSync.mockReturnValueOnce("/real/projects/app");
|
||||
expect(canonicalizePath("/projects/link", posixPathFlavor)).toBe(
|
||||
"/real/projects/app",
|
||||
);
|
||||
});
|
||||
|
||||
test("re-appends a non-existent leaf to the canonical parent", () => {
|
||||
realpathSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw enoent("/projects/app/new-file.ts");
|
||||
})
|
||||
.mockReturnValueOnce("/canonical/app");
|
||||
expect(canonicalizePath("/projects/app/new-file.ts", posixPathFlavor)).toBe(
|
||||
"/canonical/app/new-file.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("walks up multiple levels for a deeply non-existent path", () => {
|
||||
realpathSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw enoent("/projects/app/src/new-file.ts");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw enoent("/projects/app/src");
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw enoent("/projects/app");
|
||||
})
|
||||
.mockReturnValueOnce("/canonical/projects");
|
||||
expect(
|
||||
canonicalizePath("/projects/app/src/new-file.ts", posixPathFlavor),
|
||||
).toBe("/canonical/projects/app/src/new-file.ts");
|
||||
});
|
||||
|
||||
test("returns input unchanged when walk reaches filesystem root (all ENOENT)", () => {
|
||||
realpathSync.mockImplementation(() => {
|
||||
throw enoent("");
|
||||
});
|
||||
expect(canonicalizePath("/nonexistent/path/file.ts", posixPathFlavor)).toBe(
|
||||
"/nonexistent/path/file.ts",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns input unchanged on ELOOP (symlink loop)", () => {
|
||||
realpathSync.mockImplementation(() => {
|
||||
throw Object.assign(new Error("ELOOP"), { code: "ELOOP" });
|
||||
});
|
||||
expect(canonicalizePath("/some/looping/path", posixPathFlavor)).toBe(
|
||||
"/some/looping/path",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns input unchanged on EACCES (permission denied)", () => {
|
||||
realpathSync.mockImplementation(() => {
|
||||
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
||||
});
|
||||
expect(canonicalizePath("/restricted/path", posixPathFlavor)).toBe(
|
||||
"/restricted/path",
|
||||
);
|
||||
});
|
||||
|
||||
test("handles ENOTDIR by walking up (like ENOENT)", () => {
|
||||
realpathSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("ENOTDIR"), { code: "ENOTDIR" });
|
||||
})
|
||||
.mockReturnValueOnce("/real/parent");
|
||||
expect(canonicalizePath("/real/parent/not-a-dir", posixPathFlavor)).toBe(
|
||||
"/real/parent/not-a-dir",
|
||||
);
|
||||
});
|
||||
|
||||
// ── injected platform flavor (win32-separator splitting) ──────────────
|
||||
|
||||
test("win32: splits and rejoins on the backslash separator", () => {
|
||||
realpathSync
|
||||
.mockImplementationOnce(() => {
|
||||
throw enoent("C:\\projects\\link\\file.ts");
|
||||
})
|
||||
.mockReturnValueOnce("C:\\real\\app");
|
||||
expect(
|
||||
canonicalizePath("C:\\projects\\link\\file.ts", win32PathFlavor),
|
||||
).toBe("C:\\real\\app\\file.ts");
|
||||
});
|
||||
|
||||
test("win32: resolves an existing path via realpathSync", () => {
|
||||
realpathSync.mockReturnValueOnce("C:\\real\\app");
|
||||
expect(canonicalizePath("C:\\projects\\link", win32PathFlavor)).toBe(
|
||||
"C:\\real\\app",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock node:fs so the discriminator test can assert realpathSync is untouched.
|
||||
const realpathSync = vi.hoisted(() =>
|
||||
vi.fn<(path: string) => string>((p) => p),
|
||||
);
|
||||
vi.mock("node:fs", () => ({
|
||||
realpathSync,
|
||||
default: { realpathSync },
|
||||
}));
|
||||
|
||||
import { isPathOutsideWorkingDirectory } from "#src/path/path-containment";
|
||||
import { posixPathFlavor } from "#src/path/path-flavor";
|
||||
|
||||
describe("isPathOutsideWorkingDirectory", () => {
|
||||
// Pure geometry over already-canonical operands: the caller (PathNormalizer)
|
||||
// prepares the canonical path and cwd; this predicate never canonicalizes.
|
||||
const canonicalCwd = "/projects/my-app";
|
||||
|
||||
test("does not canonicalize its operands (no filesystem access)", () => {
|
||||
realpathSync.mockClear();
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/projects/my-app/src",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
);
|
||||
expect(realpathSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns false when path is inside cwd", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/projects/my-app/src",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when path equals cwd", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/projects/my-app",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when path is outside cwd", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/etc/passwd",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for an empty canonical path", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory("", canonicalCwd, posixPathFlavor),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for an empty canonical cwd", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory("/etc/passwd", "", posixPathFlavor),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for /dev/null (safe system path)", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory("/dev/null", canonicalCwd, posixPathFlavor),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for /dev/stdin (safe system path)", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/dev/stdin",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for /dev/stdout (safe system path)", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/dev/stdout",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for /dev/stderr (safe system path)", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/dev/stderr",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for /dev/null/subdir (not a safe path)", () => {
|
||||
expect(
|
||||
isPathOutsideWorkingDirectory(
|
||||
"/dev/null/subdir",
|
||||
canonicalCwd,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { posix as posixPath, win32 as winPath } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
pathFlavorForPlatform,
|
||||
posixPathFlavor,
|
||||
win32PathFlavor,
|
||||
} from "#src/path/path-flavor";
|
||||
|
||||
describe("win32PathFlavor", () => {
|
||||
it("exposes the win32 path implementation", () => {
|
||||
expect(win32PathFlavor.impl).toBe(winPath);
|
||||
});
|
||||
|
||||
it("carries the win32 case/separator match options", () => {
|
||||
expect(win32PathFlavor.matchOptions).toEqual({
|
||||
caseInsensitive: true,
|
||||
windowsSeparators: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("folds to lower case", () => {
|
||||
expect(win32PathFlavor.fold("C:\\Foo\\Bar")).toBe("c:\\foo\\bar");
|
||||
});
|
||||
|
||||
it("resolves, normalizes, and folds a comparable value against a base", () => {
|
||||
expect(win32PathFlavor.comparable("Foo/Bar", "C:\\base")).toBe(
|
||||
"c:\\base\\foo\\bar",
|
||||
);
|
||||
});
|
||||
|
||||
it("decides containment with win32 (case-folding) geometry", () => {
|
||||
expect(win32PathFlavor.isWithin("C:\\base\\sub", "C:\\base")).toBe(true);
|
||||
expect(win32PathFlavor.isWithin("C:\\base", "C:\\base")).toBe(true);
|
||||
expect(win32PathFlavor.isWithin("C:\\other", "C:\\base")).toBe(false);
|
||||
});
|
||||
|
||||
it("folds case for a case-different descendant", () => {
|
||||
expect(
|
||||
win32PathFlavor.isWithin(
|
||||
"c:\\users\\foo\\dir\\sub\\x.md",
|
||||
"C:\\Users\\Foo\\dir",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("folds case when path equals directory in a different case", () => {
|
||||
expect(
|
||||
win32PathFlavor.isWithin(
|
||||
"c:\\users\\foo\\dir\\sub",
|
||||
"C:\\USERS\\foo\\DIR",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a win32 sibling directory", () => {
|
||||
expect(
|
||||
win32PathFlavor.isWithin("C:\\Users\\Foo\\other", "C:\\Users\\Foo\\dir"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes either separator as a path separator", () => {
|
||||
expect(win32PathFlavor.hasPathSeparator("dir/file")).toBe(true);
|
||||
expect(win32PathFlavor.hasPathSeparator("dir\\file")).toBe(true);
|
||||
expect(win32PathFlavor.hasPathSeparator("plain")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies bash tokens with MSYS semantics", () => {
|
||||
expect(win32PathFlavor.bashTokenShape("/dev/null")).toEqual({
|
||||
kind: "device",
|
||||
});
|
||||
expect(win32PathFlavor.bashTokenShape("/c/Users/x")).toEqual({
|
||||
kind: "drive-mount",
|
||||
windowsPath: "C:\\Users\\x",
|
||||
});
|
||||
expect(win32PathFlavor.bashTokenShape("/tmp/x")).toEqual({
|
||||
kind: "posix-absolute",
|
||||
});
|
||||
expect(win32PathFlavor.bashTokenShape("relative/x")).toEqual({
|
||||
kind: "plain",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("posixPathFlavor", () => {
|
||||
it("exposes the posix path implementation", () => {
|
||||
expect(posixPathFlavor.impl).toBe(posixPath);
|
||||
});
|
||||
|
||||
it("carries no win32 match options", () => {
|
||||
expect(posixPathFlavor.matchOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fold case", () => {
|
||||
expect(posixPathFlavor.fold("/Foo/Bar")).toBe("/Foo/Bar");
|
||||
});
|
||||
|
||||
it("resolves and normalizes a comparable value without folding", () => {
|
||||
expect(posixPathFlavor.comparable("Foo/Bar", "/base")).toBe(
|
||||
"/base/Foo/Bar",
|
||||
);
|
||||
});
|
||||
|
||||
it("decides containment with posix geometry", () => {
|
||||
expect(posixPathFlavor.isWithin("/base/sub", "/base")).toBe(true);
|
||||
expect(posixPathFlavor.isWithin("/base", "/base")).toBe(true);
|
||||
expect(posixPathFlavor.isWithin("/a/b/c/d/e", "/a/b")).toBe(true);
|
||||
expect(posixPathFlavor.isWithin("/other", "/base")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a sibling directory sharing a name prefix", () => {
|
||||
expect(posixPathFlavor.isWithin("/a/bc", "/a/b")).toBe(false);
|
||||
});
|
||||
|
||||
it("stays case-sensitive", () => {
|
||||
expect(posixPathFlavor.isWithin("/a/B/c", "/a/b")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty operands", () => {
|
||||
expect(posixPathFlavor.isWithin("", "/a/b")).toBe(false);
|
||||
expect(posixPathFlavor.isWithin("/a/b", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes only the forward slash as a path separator", () => {
|
||||
expect(posixPathFlavor.hasPathSeparator("dir/file")).toBe(true);
|
||||
expect(posixPathFlavor.hasPathSeparator("dir\\file")).toBe(false);
|
||||
expect(posixPathFlavor.hasPathSeparator("plain")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats every bash token as an ordinary path", () => {
|
||||
expect(posixPathFlavor.bashTokenShape("/dev/null")).toEqual({
|
||||
kind: "plain",
|
||||
});
|
||||
expect(posixPathFlavor.bashTokenShape("/c/Users/x")).toEqual({
|
||||
kind: "plain",
|
||||
});
|
||||
expect(posixPathFlavor.bashTokenShape("/tmp/x")).toEqual({ kind: "plain" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathFlavorForPlatform", () => {
|
||||
it("selects the win32 flavor for win32", () => {
|
||||
expect(pathFlavorForPlatform("win32")).toBe(win32PathFlavor);
|
||||
});
|
||||
|
||||
it("selects the posix flavor for every other platform", () => {
|
||||
expect(pathFlavorForPlatform("linux")).toBe(posixPathFlavor);
|
||||
expect(pathFlavorForPlatform("darwin")).toBe(posixPathFlavor);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Hoisted stub so the vi.mock factory can reference it.
|
||||
const { mockSpawnSync } = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:child_process so tests that exercise the subprocess fallback path
|
||||
// don't actually invoke npm. Default: subprocess fails (non-zero exit), so
|
||||
// tests focused on the walk-up strategy continue to expect null.
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawnSync: mockSpawnSync,
|
||||
default: { spawnSync: mockSpawnSync },
|
||||
}));
|
||||
|
||||
import { discoverGlobalNodeModulesRoot } from "#src/node-modules-discovery";
|
||||
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
||||
import { isPiInfrastructureRead } from "#src/path/pi-infrastructure-read";
|
||||
|
||||
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
||||
|
||||
describe("discoverGlobalNodeModulesRoot", () => {
|
||||
beforeEach(() => {
|
||||
// Default: subprocess fails, so walk-up-focused tests see null for URLs
|
||||
// with no node_modules ancestor.
|
||||
mockSpawnSync.mockReset();
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
});
|
||||
|
||||
test("returns the node_modules dir when the file is inside one", () => {
|
||||
const url =
|
||||
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/opt/homebrew/lib/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns node_modules for a deeply nested file", () => {
|
||||
const url =
|
||||
"file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/home/user/.nvm/versions/node/v20/lib/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns node_modules for a bun global install path", () => {
|
||||
const url =
|
||||
"file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/home/user/.bun/install/global/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the innermost (closest-to-file) node_modules ancestor", () => {
|
||||
// The walk-up algorithm stops at the first node_modules dir it encounters,
|
||||
// which is the innermost one when the file is inside a nested install.
|
||||
// In practice this never happens for a real global install — the extension
|
||||
// is always directly at <global_root>/node_modules/pi-permission-system/…
|
||||
const url =
|
||||
"file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/opt/lib/node_modules/some-pkg/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when the file is not inside any node_modules directory", () => {
|
||||
const url =
|
||||
"file:///home/user/development/pi-permission-system/dist/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a root-level file", () => {
|
||||
const url = "file:///external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for an invalid URL", () => {
|
||||
expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
test("works with the real import.meta.url of this extension (smoke test)", () => {
|
||||
// The extension IS installed inside a node_modules tree when running in CI
|
||||
// or global install. In a local dev checkout the result may be null — that's
|
||||
// the documented graceful-degradation path.
|
||||
const result = discoverGlobalNodeModulesRoot();
|
||||
expect(result === null || result.endsWith("node_modules")).toBe(true);
|
||||
});
|
||||
|
||||
test("the discovered path includes the pi-permission-system package directory", () => {
|
||||
const url =
|
||||
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
||||
const root = discoverGlobalNodeModulesRoot(url);
|
||||
expect(root).not.toBeNull();
|
||||
expect(join(root!, "pi-permission-system")).toBe(
|
||||
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPiInfrastructureRead ─────────────────────────────────────────────────
|
||||
|
||||
const INFRA_DIRS = [
|
||||
"/home/user/.pi/agent",
|
||||
"/home/user/.pi/agent/git",
|
||||
"/opt/homebrew/lib/node_modules",
|
||||
];
|
||||
const CWD = "/home/user/project";
|
||||
|
||||
describe("isPiInfrastructureRead", () => {
|
||||
// ── read tools allowed for infra paths ──────────────────────────────────
|
||||
|
||||
test("allows 'read' tool for a file inside agentDir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'find' tool for a path inside node_modules infra dir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"find",
|
||||
"/opt/homebrew/lib/node_modules/pi-ask-user/skills",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'grep' tool for a path inside agentDir/git", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"grep",
|
||||
"/home/user/.pi/agent/git/some-package/README.md",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'ls' tool for a path inside node_modules infra dir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"ls",
|
||||
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── write tools never allowed even for infra paths ───────────────────────
|
||||
|
||||
test("blocks 'write' tool for a file inside agentDir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"write",
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("blocks 'edit' tool for a file inside node_modules", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"edit",
|
||||
"/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("blocks 'bash' tool regardless of path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"bash",
|
||||
"/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── non-infra paths not allowed ──────────────────────────────────────────
|
||||
|
||||
test("does not allow 'read' for a path outside all infra dirs", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/etc/passwd",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
|
||||
// /home/user/.pi/agent-other should not match /home/user/.pi/agent
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/home/user/.pi/agent-other/config.json",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
|
||||
|
||||
test("allows 'read' for a path inside project-local .pi/npm/", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'read' for a path inside project-local .pi/git/", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks 'write' for a path inside project-local .pi/npm/", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"write",
|
||||
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── empty / edge cases ───────────────────────────────────────────────────
|
||||
|
||||
test("returns false when infrastructureDirs is empty and path is not project-local", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead("read", "/etc/passwd", [], CWD, posixPathFlavor),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
|
||||
// Project-local paths are checked separately from the dirs array.
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${CWD}/.pi/npm/node_modules/x/SKILL.md`,
|
||||
[],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPiInfrastructureRead — glob patterns ─────────────────────────────────
|
||||
|
||||
describe("isPiInfrastructureRead with glob patterns", () => {
|
||||
test("glob entry matches a versioned nested path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("** behaves the same as * (matches across path separators)", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("glob entry does not match an unrelated path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/etc/passwd",
|
||||
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("? matches exactly one character", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/X/file.md",
|
||||
["/opt/homebrew/?/file.md"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("? does not match multiple characters", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/abc/file.md",
|
||||
["/opt/homebrew/?/file.md"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("mixed array of plain dirs and glob patterns — both branches work", () => {
|
||||
const dirs = [
|
||||
"/home/user/.pi/agent",
|
||||
"/opt/homebrew/*/@earendil-works/pi-coding-agent/*",
|
||||
];
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/home/user/.pi/agent/config.json",
|
||||
dirs,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
dirs,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("plain entry with ~ prefix matches after home expansion", () => {
|
||||
const home = homedir();
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${home}/.pi/agent/config.json`,
|
||||
["~/.pi/agent"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("write tool with a glob-matching path is still rejected", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"write",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPiInfrastructureRead — win32 case-insensitive matching ───────────────
|
||||
|
||||
describe("isPiInfrastructureRead on win32", () => {
|
||||
test("plain infra dir matches a case-different path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"c:\\users\\foo\\.pi\\agent\\config.json",
|
||||
["C:\\Users\\Foo\\.pi\\agent"],
|
||||
"C:\\proj",
|
||||
win32PathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("glob infra dir matches case-insensitively", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
|
||||
["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
|
||||
"C:\\proj",
|
||||
win32PathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a path outside every infra dir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"c:\\windows\\system32\\drivers\\etc\\hosts",
|
||||
["C:\\Users\\Foo\\.pi\\agent"],
|
||||
"C:\\proj",
|
||||
win32PathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user