@gotgenes/pi-permission-system 5.6.1 → 5.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
-
4
- // Hoisted stubs for mocks that reference them in vi.mock factories.
5
- const { mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
6
- mockSpawnSync: vi.fn(),
7
- mockExistsSync: vi.fn(),
8
- }));
9
-
10
- // Mock node:child_process so tests don't spawn real subprocesses.
11
- vi.mock("node:child_process", () => ({
12
- spawnSync: mockSpawnSync,
13
- default: { spawnSync: mockSpawnSync },
14
- }));
15
-
16
- // Mock node:fs so existsSync is controllable.
17
- vi.mock("node:fs", () => ({
18
- existsSync: mockExistsSync,
19
- default: { existsSync: mockExistsSync },
20
- }));
2
+ import { describe, expect, test, vi } from "vitest";
21
3
 
22
4
  // Mock node:os so tilde-expansion is deterministic across platforms.
23
5
  vi.mock("node:os", () => {
@@ -29,79 +11,16 @@ vi.mock("node:os", () => {
29
11
  });
30
12
 
31
13
  import {
32
- discoverGlobalNodeModulesRoot,
33
- formatExternalDirectoryAskPrompt,
34
- formatExternalDirectoryDenyReason,
35
- formatExternalDirectoryHardStopHint,
36
- formatExternalDirectoryUserDeniedReason,
37
14
  getPathBearingToolPath,
38
15
  isPathOutsideWorkingDirectory,
39
16
  isPathWithinDirectory,
17
+ isPiInfrastructureRead,
40
18
  isSafeSystemPath,
41
19
  normalizePathForComparison,
42
20
  PATH_BEARING_TOOLS,
21
+ READ_ONLY_PATH_BEARING_TOOLS,
43
22
  SAFE_SYSTEM_PATHS,
44
- } from "../src/external-directory";
45
-
46
- afterEach(() => {
47
- vi.restoreAllMocks();
48
- });
49
-
50
- describe("PATH_BEARING_TOOLS", () => {
51
- test("contains the expected tool names", () => {
52
- for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
53
- expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
54
- }
55
- });
56
-
57
- test("does not contain bash or mcp", () => {
58
- expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
59
- expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
60
- });
61
- });
62
-
63
- describe("SAFE_SYSTEM_PATHS", () => {
64
- test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
65
- expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
66
- expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
67
- expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
68
- expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
69
- });
70
- });
71
-
72
- describe("isSafeSystemPath", () => {
73
- test("returns true for /dev/null", () => {
74
- expect(isSafeSystemPath("/dev/null")).toBe(true);
75
- });
76
-
77
- test("returns true for /dev/stdin", () => {
78
- expect(isSafeSystemPath("/dev/stdin")).toBe(true);
79
- });
80
-
81
- test("returns true for /dev/stdout", () => {
82
- expect(isSafeSystemPath("/dev/stdout")).toBe(true);
83
- });
84
-
85
- test("returns true for /dev/stderr", () => {
86
- expect(isSafeSystemPath("/dev/stderr")).toBe(true);
87
- });
88
-
89
- test("returns false for an arbitrary absolute path", () => {
90
- expect(isSafeSystemPath("/etc/passwd")).toBe(false);
91
- });
92
-
93
- test("returns false for a path prefixed with a safe system path", () => {
94
- expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
95
- });
96
-
97
- test("returns false for an empty string", () => {
98
- expect(isSafeSystemPath("")).toBe(false);
99
- });
100
-
101
- test("returns false for a relative path", () => {
102
- expect(isSafeSystemPath("dev/null")).toBe(false);
103
- });
104
- });
23
+ } from "../src/path-utils";
105
24
 
106
25
  describe("normalizePathForComparison", () => {
107
26
  const cwd = "/projects/my-app";
@@ -179,6 +98,75 @@ describe("isPathWithinDirectory", () => {
179
98
  });
180
99
  });
181
100
 
101
+ describe("PATH_BEARING_TOOLS", () => {
102
+ test("contains the expected tool names", () => {
103
+ for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
104
+ expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
105
+ }
106
+ });
107
+
108
+ test("does not contain bash or mcp", () => {
109
+ expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
110
+ expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
115
+ test("contains read, find, grep, ls", () => {
116
+ for (const tool of ["read", "find", "grep", "ls"]) {
117
+ expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
118
+ }
119
+ });
120
+
121
+ test("does not contain write or edit", () => {
122
+ expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
123
+ expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe("SAFE_SYSTEM_PATHS", () => {
128
+ test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
129
+ expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
130
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
131
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
132
+ expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe("isSafeSystemPath", () => {
137
+ test("returns true for /dev/null", () => {
138
+ expect(isSafeSystemPath("/dev/null")).toBe(true);
139
+ });
140
+
141
+ test("returns true for /dev/stdin", () => {
142
+ expect(isSafeSystemPath("/dev/stdin")).toBe(true);
143
+ });
144
+
145
+ test("returns true for /dev/stdout", () => {
146
+ expect(isSafeSystemPath("/dev/stdout")).toBe(true);
147
+ });
148
+
149
+ test("returns true for /dev/stderr", () => {
150
+ expect(isSafeSystemPath("/dev/stderr")).toBe(true);
151
+ });
152
+
153
+ test("returns false for an arbitrary absolute path", () => {
154
+ expect(isSafeSystemPath("/etc/passwd")).toBe(false);
155
+ });
156
+
157
+ test("returns false for a path prefixed with a safe system path", () => {
158
+ expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
159
+ });
160
+
161
+ test("returns false for an empty string", () => {
162
+ expect(isSafeSystemPath("")).toBe(false);
163
+ });
164
+
165
+ test("returns false for a relative path", () => {
166
+ expect(isSafeSystemPath("dev/null")).toBe(false);
167
+ });
168
+ });
169
+
182
170
  describe("getPathBearingToolPath", () => {
183
171
  test("returns path for a path-bearing tool", () => {
184
172
  expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
@@ -249,177 +237,57 @@ describe("isPathOutsideWorkingDirectory", () => {
249
237
  });
250
238
  });
251
239
 
252
- describe("formatExternalDirectoryHardStopHint", () => {
253
- test("returns the hard stop instruction string", () => {
254
- const hint = formatExternalDirectoryHardStopHint();
255
- expect(hint).toContain("Hard stop");
256
- expect(hint).toContain("external directory");
257
- });
258
- });
259
-
260
- describe("formatExternalDirectoryAskPrompt", () => {
261
- test("uses 'Current agent' when no agent name provided", () => {
262
- const result = formatExternalDirectoryAskPrompt(
263
- "read",
264
- "/etc/passwd",
265
- "/projects/my-app",
266
- );
267
- expect(result).toContain("Current agent");
268
- expect(result).toContain("read");
269
- expect(result).toContain("/etc/passwd");
270
- expect(result).toContain("/projects/my-app");
271
- });
272
-
273
- test("uses agent name when provided", () => {
274
- const result = formatExternalDirectoryAskPrompt(
275
- "write",
276
- "/tmp/out.txt",
277
- "/projects/my-app",
278
- "my-agent",
279
- );
280
- expect(result).toContain("Agent 'my-agent'");
281
- expect(result).toContain("write");
282
- expect(result).toContain("/tmp/out.txt");
283
- });
284
- });
285
-
286
- describe("formatExternalDirectoryDenyReason", () => {
287
- test("includes tool name, path, cwd, agent name, and hard stop hint", () => {
288
- const result = formatExternalDirectoryDenyReason(
289
- "read",
290
- "/etc/passwd",
291
- "/projects/my-app",
292
- "sec-agent",
293
- );
294
- expect(result).toContain("Agent 'sec-agent'");
295
- expect(result).toContain("read");
296
- expect(result).toContain("/etc/passwd");
297
- expect(result).toContain("/projects/my-app");
298
- expect(result).toContain("Hard stop");
299
- });
300
-
301
- test("uses 'Current agent' without agent name", () => {
302
- const result = formatExternalDirectoryDenyReason(
303
- "read",
304
- "/etc",
305
- "/projects",
306
- );
307
- expect(result).toContain("Current agent");
308
- });
309
- });
310
-
311
- describe("formatExternalDirectoryUserDeniedReason", () => {
312
- test("includes tool name and path", () => {
313
- const result = formatExternalDirectoryUserDeniedReason(
314
- "edit",
315
- "/etc/hosts",
316
- );
317
- expect(result).toContain("edit");
318
- expect(result).toContain("/etc/hosts");
319
- expect(result).toContain("Hard stop");
320
- });
321
-
322
- test("appends denial reason when provided", () => {
323
- const result = formatExternalDirectoryUserDeniedReason(
324
- "edit",
325
- "/etc/hosts",
326
- "too risky",
327
- );
328
- expect(result).toContain("Reason: too risky");
329
- });
330
-
331
- test("omits reason suffix when not provided", () => {
332
- const result = formatExternalDirectoryUserDeniedReason(
333
- "edit",
334
- "/etc/hosts",
335
- );
336
- expect(result).not.toContain("Reason:");
337
- });
338
- });
339
-
340
- describe("discoverGlobalNodeModulesRoot", () => {
341
- // The walk-up-from-self strategy uses import.meta.url which resolves to a
342
- // path inside the source tree during tests — there is no node_modules
343
- // ancestor. So the fallback path is exercised naturally here.
344
- //
345
- // For the "walk-up succeeds" case, we verify the subprocess is NOT called
346
- // by confirming spawnSync call count stays at zero when the URL has a
347
- // node_modules ancestor.
348
-
349
- beforeEach(() => {
350
- mockSpawnSync.mockReset();
351
- mockExistsSync.mockReset();
352
- });
353
-
354
- test("returns node_modules root when URL is inside a node_modules tree", () => {
355
- // Simulate a URL whose file path contains a node_modules ancestor.
356
- const fakeUrl =
357
- "file:///opt/homebrew/lib/node_modules/@gotgenes/pi-permission-system/dist/external-directory.js";
358
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
359
- expect(result).toBe("/opt/homebrew/lib/node_modules");
360
- // Subprocess should NOT have been invoked — walk-up succeeds.
361
- expect(mockSpawnSync).not.toHaveBeenCalled();
362
- });
363
-
364
- test("calls npm root -g as fallback when walk-up finds no node_modules ancestor", () => {
365
- const npmRootPath = "/opt/homebrew/lib/node_modules";
366
- mockSpawnSync.mockReturnValue({
367
- status: 0,
368
- stdout: `${npmRootPath}\n`,
369
- });
370
- mockExistsSync.mockReturnValue(true);
371
-
372
- // Use a file URL with no node_modules ancestor.
373
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
374
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
375
-
376
- expect(mockSpawnSync).toHaveBeenCalledWith(
377
- "npm",
378
- ["root", "-g"],
379
- expect.objectContaining({ encoding: "utf-8" }),
240
+ describe("isPiInfrastructureRead", () => {
241
+ const cwd = "/projects/my-app";
242
+ const infraDirs = ["/mock/home/.pi/agent"];
243
+
244
+ test("returns true for read-only tool reading from infra dir", () => {
245
+ expect(
246
+ isPiInfrastructureRead(
247
+ "read",
248
+ "/mock/home/.pi/agent/config.json",
249
+ infraDirs,
250
+ cwd,
251
+ ),
252
+ ).toBe(true);
253
+ });
254
+
255
+ test("returns false for write tool even in infra dir", () => {
256
+ expect(
257
+ isPiInfrastructureRead(
258
+ "write",
259
+ "/mock/home/.pi/agent/config.json",
260
+ infraDirs,
261
+ cwd,
262
+ ),
263
+ ).toBe(false);
264
+ });
265
+
266
+ test("returns true for read-only tool reading from project .pi/npm", () => {
267
+ expect(
268
+ isPiInfrastructureRead(
269
+ "read",
270
+ "/projects/my-app/.pi/npm/package.json",
271
+ [],
272
+ cwd,
273
+ ),
274
+ ).toBe(true);
275
+ });
276
+
277
+ test("returns true for read-only tool reading from project .pi/git", () => {
278
+ expect(
279
+ isPiInfrastructureRead(
280
+ "grep",
281
+ "/projects/my-app/.pi/git/some-file",
282
+ [],
283
+ cwd,
284
+ ),
285
+ ).toBe(true);
286
+ });
287
+
288
+ test("returns false for path outside all infra dirs and project dirs", () => {
289
+ expect(isPiInfrastructureRead("read", "/etc/passwd", infraDirs, cwd)).toBe(
290
+ false,
380
291
  );
381
- expect(result).toBe(npmRootPath);
382
- });
383
-
384
- test("returns null when walk-up fails and npm root -g returns non-zero exit", () => {
385
- mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
386
-
387
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
388
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
389
-
390
- expect(result).toBeNull();
391
- });
392
-
393
- test("returns null when walk-up fails and spawnSync throws", () => {
394
- mockSpawnSync.mockImplementation(() => {
395
- throw new Error("ENOENT");
396
- });
397
-
398
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
399
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
400
-
401
- expect(result).toBeNull();
402
- });
403
-
404
- test("returns null when walk-up fails and npm root -g returns non-existent path", () => {
405
- mockSpawnSync.mockReturnValue({
406
- status: 0,
407
- stdout: "/some/nonexistent/node_modules\n",
408
- });
409
- mockExistsSync.mockReturnValue(false);
410
-
411
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
412
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
413
-
414
- expect(result).toBeNull();
415
- });
416
-
417
- test("returns null when walk-up fails and npm root -g returns empty stdout", () => {
418
- mockSpawnSync.mockReturnValue({ status: 0, stdout: " " });
419
-
420
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
421
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
422
-
423
- expect(result).toBeNull();
424
292
  });
425
293
  });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { mergeFlatPermissions } from "../src/permission-merge";
4
+
5
+ describe("mergeFlatPermissions", () => {
6
+ test("string replaces string", () => {
7
+ const result = mergeFlatPermissions({ tools: "ask" }, { tools: "allow" });
8
+ expect(result).toEqual({ tools: "allow" });
9
+ });
10
+
11
+ test("both objects → shallow-merge pattern maps", () => {
12
+ const result = mergeFlatPermissions(
13
+ { bash: { "rm *": "deny", "git *": "ask" } },
14
+ { bash: { "rm *": "allow", "npm *": "allow" } },
15
+ );
16
+ expect(result).toEqual({
17
+ bash: { "rm *": "allow", "git *": "ask", "npm *": "allow" },
18
+ });
19
+ });
20
+
21
+ test("object replaces string", () => {
22
+ const result = mergeFlatPermissions(
23
+ { tools: "ask" },
24
+ { tools: { Write: "deny" } },
25
+ );
26
+ expect(result).toEqual({ tools: { Write: "deny" } });
27
+ });
28
+
29
+ test("string replaces object", () => {
30
+ const result = mergeFlatPermissions(
31
+ { tools: { Write: "deny" } },
32
+ { tools: "allow" },
33
+ );
34
+ expect(result).toEqual({ tools: "allow" });
35
+ });
36
+
37
+ test("empty override returns base unchanged", () => {
38
+ const base = { tools: "ask" as const, bash: { "rm *": "deny" as const } };
39
+ const result = mergeFlatPermissions(base, {});
40
+ expect(result).toEqual(base);
41
+ });
42
+
43
+ test("empty base returns override", () => {
44
+ const override = { tools: "allow" as const };
45
+ const result = mergeFlatPermissions({}, override);
46
+ expect(result).toEqual(override);
47
+ });
48
+
49
+ test("preserves keys only in base", () => {
50
+ const result = mergeFlatPermissions(
51
+ { tools: "ask", bash: "deny" },
52
+ { tools: "allow" },
53
+ );
54
+ expect(result).toEqual({ tools: "allow", bash: "deny" });
55
+ });
56
+
57
+ test("adds keys only in override", () => {
58
+ const result = mergeFlatPermissions({ tools: "ask" }, { bash: "allow" });
59
+ expect(result).toEqual({ tools: "ask", bash: "allow" });
60
+ });
61
+ });