@gotgenes/pi-permission-system 5.6.2 → 5.7.0

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,346 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
-
3
- // Hoisted stubs for mocks that reference them in vi.mock factories.
4
- const { mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
5
- mockSpawnSync: vi.fn(),
6
- mockExistsSync: vi.fn(),
7
- }));
8
-
9
- // Mock node:child_process so tests don't spawn real subprocesses.
10
- vi.mock("node:child_process", () => ({
11
- spawnSync: mockSpawnSync,
12
- default: { spawnSync: mockSpawnSync },
13
- }));
14
-
15
- // Mock node:fs so existsSync is controllable.
16
- vi.mock("node:fs", () => ({
17
- existsSync: mockExistsSync,
18
- default: { existsSync: mockExistsSync },
19
- }));
20
-
21
- // Mock node:os so tilde-expansion is deterministic across platforms.
22
- vi.mock("node:os", () => {
23
- const homedir = vi.fn(() => "/mock/home");
24
- return {
25
- homedir,
26
- default: { homedir },
27
- };
28
- });
29
-
30
- import {
31
- discoverGlobalNodeModulesRoot,
32
- formatExternalDirectoryAskPrompt,
33
- formatExternalDirectoryDenyReason,
34
- formatExternalDirectoryHardStopHint,
35
- formatExternalDirectoryUserDeniedReason,
36
- getPathBearingToolPath,
37
- isPathOutsideWorkingDirectory,
38
- isSafeSystemPath,
39
- PATH_BEARING_TOOLS,
40
- SAFE_SYSTEM_PATHS,
41
- } from "../src/external-directory";
42
-
43
- afterEach(() => {
44
- vi.restoreAllMocks();
45
- });
46
-
47
- describe("PATH_BEARING_TOOLS", () => {
48
- test("contains the expected tool names", () => {
49
- for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
50
- expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
51
- }
52
- });
53
-
54
- test("does not contain bash or mcp", () => {
55
- expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
56
- expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
57
- });
58
- });
59
-
60
- describe("SAFE_SYSTEM_PATHS", () => {
61
- test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
62
- expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
63
- expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
64
- expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
65
- expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
66
- });
67
- });
68
-
69
- describe("isSafeSystemPath", () => {
70
- test("returns true for /dev/null", () => {
71
- expect(isSafeSystemPath("/dev/null")).toBe(true);
72
- });
73
-
74
- test("returns true for /dev/stdin", () => {
75
- expect(isSafeSystemPath("/dev/stdin")).toBe(true);
76
- });
77
-
78
- test("returns true for /dev/stdout", () => {
79
- expect(isSafeSystemPath("/dev/stdout")).toBe(true);
80
- });
81
-
82
- test("returns true for /dev/stderr", () => {
83
- expect(isSafeSystemPath("/dev/stderr")).toBe(true);
84
- });
85
-
86
- test("returns false for an arbitrary absolute path", () => {
87
- expect(isSafeSystemPath("/etc/passwd")).toBe(false);
88
- });
89
-
90
- test("returns false for a path prefixed with a safe system path", () => {
91
- expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
92
- });
93
-
94
- test("returns false for an empty string", () => {
95
- expect(isSafeSystemPath("")).toBe(false);
96
- });
97
-
98
- test("returns false for a relative path", () => {
99
- expect(isSafeSystemPath("dev/null")).toBe(false);
100
- });
101
- });
102
-
103
- describe("getPathBearingToolPath", () => {
104
- test("returns path for a path-bearing tool", () => {
105
- expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
106
- "/src/foo.ts",
107
- );
108
- });
109
-
110
- test("returns null for a non-path-bearing tool", () => {
111
- expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
112
- expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
113
- expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
114
- });
115
-
116
- test("returns null when input has no path", () => {
117
- expect(getPathBearingToolPath("read", {})).toBeNull();
118
- expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
119
- expect(getPathBearingToolPath("read", null)).toBeNull();
120
- });
121
- });
122
-
123
- describe("isPathOutsideWorkingDirectory", () => {
124
- const cwd = "/projects/my-app";
125
-
126
- test("returns false when path is inside cwd", () => {
127
- expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
128
- false,
129
- );
130
- });
131
-
132
- test("returns false when path equals cwd", () => {
133
- expect(isPathOutsideWorkingDirectory("/projects/my-app", cwd)).toBe(false);
134
- });
135
-
136
- test("returns true when path is outside cwd", () => {
137
- expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd)).toBe(true);
138
- });
139
-
140
- test("returns true for home directory when outside cwd", () => {
141
- expect(isPathOutsideWorkingDirectory("~/secrets", cwd)).toBe(true);
142
- });
143
-
144
- test("returns false for relative path resolving inside cwd", () => {
145
- expect(isPathOutsideWorkingDirectory("src/index.ts", cwd)).toBe(false);
146
- });
147
-
148
- test("returns false for empty path (normalizes to empty string)", () => {
149
- expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
150
- });
151
-
152
- test("returns false for /dev/null regardless of cwd", () => {
153
- expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
154
- });
155
-
156
- test("returns false for /dev/stdin regardless of cwd", () => {
157
- expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
158
- });
159
-
160
- test("returns false for /dev/stdout regardless of cwd", () => {
161
- expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
162
- });
163
-
164
- test("returns false for /dev/stderr regardless of cwd", () => {
165
- expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
166
- });
167
-
168
- test("returns true for /dev/null/subdir (not a safe path)", () => {
169
- expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
170
- });
171
- });
172
-
173
- describe("formatExternalDirectoryHardStopHint", () => {
174
- test("returns the hard stop instruction string", () => {
175
- const hint = formatExternalDirectoryHardStopHint();
176
- expect(hint).toContain("Hard stop");
177
- expect(hint).toContain("external directory");
178
- });
179
- });
180
-
181
- describe("formatExternalDirectoryAskPrompt", () => {
182
- test("uses 'Current agent' when no agent name provided", () => {
183
- const result = formatExternalDirectoryAskPrompt(
184
- "read",
185
- "/etc/passwd",
186
- "/projects/my-app",
187
- );
188
- expect(result).toContain("Current agent");
189
- expect(result).toContain("read");
190
- expect(result).toContain("/etc/passwd");
191
- expect(result).toContain("/projects/my-app");
192
- });
193
-
194
- test("uses agent name when provided", () => {
195
- const result = formatExternalDirectoryAskPrompt(
196
- "write",
197
- "/tmp/out.txt",
198
- "/projects/my-app",
199
- "my-agent",
200
- );
201
- expect(result).toContain("Agent 'my-agent'");
202
- expect(result).toContain("write");
203
- expect(result).toContain("/tmp/out.txt");
204
- });
205
- });
206
-
207
- describe("formatExternalDirectoryDenyReason", () => {
208
- test("includes tool name, path, cwd, agent name, and hard stop hint", () => {
209
- const result = formatExternalDirectoryDenyReason(
210
- "read",
211
- "/etc/passwd",
212
- "/projects/my-app",
213
- "sec-agent",
214
- );
215
- expect(result).toContain("Agent 'sec-agent'");
216
- expect(result).toContain("read");
217
- expect(result).toContain("/etc/passwd");
218
- expect(result).toContain("/projects/my-app");
219
- expect(result).toContain("Hard stop");
220
- });
221
-
222
- test("uses 'Current agent' without agent name", () => {
223
- const result = formatExternalDirectoryDenyReason(
224
- "read",
225
- "/etc",
226
- "/projects",
227
- );
228
- expect(result).toContain("Current agent");
229
- });
230
- });
231
-
232
- describe("formatExternalDirectoryUserDeniedReason", () => {
233
- test("includes tool name and path", () => {
234
- const result = formatExternalDirectoryUserDeniedReason(
235
- "edit",
236
- "/etc/hosts",
237
- );
238
- expect(result).toContain("edit");
239
- expect(result).toContain("/etc/hosts");
240
- expect(result).toContain("Hard stop");
241
- });
242
-
243
- test("appends denial reason when provided", () => {
244
- const result = formatExternalDirectoryUserDeniedReason(
245
- "edit",
246
- "/etc/hosts",
247
- "too risky",
248
- );
249
- expect(result).toContain("Reason: too risky");
250
- });
251
-
252
- test("omits reason suffix when not provided", () => {
253
- const result = formatExternalDirectoryUserDeniedReason(
254
- "edit",
255
- "/etc/hosts",
256
- );
257
- expect(result).not.toContain("Reason:");
258
- });
259
- });
260
-
261
- describe("discoverGlobalNodeModulesRoot", () => {
262
- // The walk-up-from-self strategy uses import.meta.url which resolves to a
263
- // path inside the source tree during tests — there is no node_modules
264
- // ancestor. So the fallback path is exercised naturally here.
265
- //
266
- // For the "walk-up succeeds" case, we verify the subprocess is NOT called
267
- // by confirming spawnSync call count stays at zero when the URL has a
268
- // node_modules ancestor.
269
-
270
- beforeEach(() => {
271
- mockSpawnSync.mockReset();
272
- mockExistsSync.mockReset();
273
- });
274
-
275
- test("returns node_modules root when URL is inside a node_modules tree", () => {
276
- // Simulate a URL whose file path contains a node_modules ancestor.
277
- const fakeUrl =
278
- "file:///opt/homebrew/lib/node_modules/@gotgenes/pi-permission-system/dist/external-directory.js";
279
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
280
- expect(result).toBe("/opt/homebrew/lib/node_modules");
281
- // Subprocess should NOT have been invoked — walk-up succeeds.
282
- expect(mockSpawnSync).not.toHaveBeenCalled();
283
- });
284
-
285
- test("calls npm root -g as fallback when walk-up finds no node_modules ancestor", () => {
286
- const npmRootPath = "/opt/homebrew/lib/node_modules";
287
- mockSpawnSync.mockReturnValue({
288
- status: 0,
289
- stdout: `${npmRootPath}\n`,
290
- });
291
- mockExistsSync.mockReturnValue(true);
292
-
293
- // Use a file URL with no node_modules ancestor.
294
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
295
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
296
-
297
- expect(mockSpawnSync).toHaveBeenCalledWith(
298
- "npm",
299
- ["root", "-g"],
300
- expect.objectContaining({ encoding: "utf-8" }),
301
- );
302
- expect(result).toBe(npmRootPath);
303
- });
304
-
305
- test("returns null when walk-up fails and npm root -g returns non-zero exit", () => {
306
- mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
307
-
308
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
309
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
310
-
311
- expect(result).toBeNull();
312
- });
313
-
314
- test("returns null when walk-up fails and spawnSync throws", () => {
315
- mockSpawnSync.mockImplementation(() => {
316
- throw new Error("ENOENT");
317
- });
318
-
319
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
320
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
321
-
322
- expect(result).toBeNull();
323
- });
324
-
325
- test("returns null when walk-up fails and npm root -g returns non-existent path", () => {
326
- mockSpawnSync.mockReturnValue({
327
- status: 0,
328
- stdout: "/some/nonexistent/node_modules\n",
329
- });
330
- mockExistsSync.mockReturnValue(false);
331
-
332
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
333
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
334
-
335
- expect(result).toBeNull();
336
- });
337
-
338
- test("returns null when walk-up fails and npm root -g returns empty stdout", () => {
339
- mockSpawnSync.mockReturnValue({ status: 0, stdout: " " });
340
-
341
- const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
342
- const result = discoverGlobalNodeModulesRoot(fakeUrl);
343
-
344
- expect(result).toBeNull();
345
- });
346
- });