@gotgenes/pi-permission-system 5.6.2 → 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.
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Walk up the directory tree from the given file URL until a directory
8
+ * literally named `node_modules` is found.
9
+ *
10
+ * Returns the `node_modules` path, or `null` if the URL cannot be parsed or
11
+ * no `node_modules` ancestor exists.
12
+ */
13
+ function walkUpToNodeModules(fromUrl: string): string | null {
14
+ try {
15
+ const thisFile = fileURLToPath(fromUrl);
16
+ let dir = dirname(thisFile);
17
+ while (dir !== dirname(dir)) {
18
+ if (basename(dir) === "node_modules") {
19
+ return dir;
20
+ }
21
+ dir = dirname(dir);
22
+ }
23
+ return null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Run `npm root -g` synchronously and return the trimmed output, or `null` on
31
+ * any failure (non-zero exit, ENOENT, timeout, non-existent path).
32
+ *
33
+ * Only called when the walk-up-from-self strategy fails (i.e. the extension is
34
+ * running from a local development checkout, not a global install).
35
+ */
36
+ function discoverGlobalNodeModulesViaSubprocess(): string | null {
37
+ try {
38
+ const result = spawnSync("npm", ["root", "-g"], {
39
+ encoding: "utf-8",
40
+ timeout: 5000,
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ });
43
+ const root = result.stdout?.trim();
44
+ if (result.status === 0 && root && existsSync(root)) {
45
+ return root;
46
+ }
47
+ return null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Discover the global node_modules root.
55
+ *
56
+ * Strategy 1 (zero-cost, covers all global installs): walk up from
57
+ * `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
58
+ * directory named `node_modules`. This works whenever the extension is
59
+ * installed inside a `node_modules` tree.
60
+ *
61
+ * Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
62
+ * because the extension is running from a local development checkout with no
63
+ * `node_modules` ancestor, run `npm root -g` to discover the global root.
64
+ * Pi installs skills and extensions via `npm` by default, so `npm root -g`
65
+ * returns the correct root regardless of the user's own project package
66
+ * manager.
67
+ *
68
+ * Returns `null` when both strategies fail — callers must degrade gracefully.
69
+ */
70
+ export function discoverGlobalNodeModulesRoot(
71
+ fromUrl = import.meta.url,
72
+ ): string | null {
73
+ const fromSelf = walkUpToNodeModules(fromUrl);
74
+ if (fromSelf) return fromSelf;
75
+ return discoverGlobalNodeModulesViaSubprocess();
76
+ }
package/src/path-utils.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, normalize, resolve, sep } from "node:path";
3
3
 
4
+ import { getNonEmptyString, toRecord } from "./common";
5
+
4
6
  export function normalizePathForComparison(
5
7
  pathValue: string,
6
8
  cwd: string,
@@ -43,3 +45,111 @@ export function isPathWithinDirectory(
43
45
  const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
44
46
  return pathValue.startsWith(prefix);
45
47
  }
48
+
49
+ /**
50
+ * Paths that are universally safe and should never trigger external-directory checks.
51
+ * These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
52
+ */
53
+ export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
54
+ "/dev/null",
55
+ "/dev/stdin",
56
+ "/dev/stdout",
57
+ "/dev/stderr",
58
+ ]);
59
+
60
+ /**
61
+ * Returns true if the given normalized path is a safe OS device file
62
+ * that should never trigger external-directory checks.
63
+ */
64
+ export function isSafeSystemPath(normalizedPath: string): boolean {
65
+ return SAFE_SYSTEM_PATHS.has(normalizedPath);
66
+ }
67
+
68
+ /**
69
+ * File tools that only read — never write — the filesystem.
70
+ * Only these tools are eligible for the Pi infrastructure auto-allow.
71
+ */
72
+ export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
73
+ "read",
74
+ "find",
75
+ "grep",
76
+ "ls",
77
+ ]);
78
+
79
+ export const PATH_BEARING_TOOLS = new Set([
80
+ "read",
81
+ "write",
82
+ "edit",
83
+ "find",
84
+ "grep",
85
+ "ls",
86
+ ]);
87
+
88
+ export function getPathBearingToolPath(
89
+ toolName: string,
90
+ input: unknown,
91
+ ): string | null {
92
+ if (!PATH_BEARING_TOOLS.has(toolName)) {
93
+ return null;
94
+ }
95
+
96
+ return getNonEmptyString(toRecord(input).path);
97
+ }
98
+
99
+ export function isPathOutsideWorkingDirectory(
100
+ pathValue: string,
101
+ cwd: string,
102
+ ): boolean {
103
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
104
+ const normalizedPath = normalizePathForComparison(pathValue, cwd);
105
+ if (!normalizedCwd || !normalizedPath) {
106
+ return false;
107
+ }
108
+ if (isSafeSystemPath(normalizedPath)) {
109
+ return false;
110
+ }
111
+ return !isPathWithinDirectory(normalizedPath, normalizedCwd);
112
+ }
113
+
114
+ /**
115
+ * Returns true if the given tool + normalized path combination qualifies for
116
+ * automatic allow as a Pi infrastructure read.
117
+ *
118
+ * A path qualifies when:
119
+ * 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
120
+ * 2. The normalized path is within one of the provided `infrastructureDirs`
121
+ * OR within the project-local Pi package directories
122
+ * (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
123
+ *
124
+ * `infrastructureDirs` should contain pre-expanded absolute paths (no `~`).
125
+ * Project-local paths are computed fresh from `cwd` on each call so they
126
+ * follow working-directory changes without a runtime rebuild.
127
+ */
128
+ export function isPiInfrastructureRead(
129
+ toolName: string,
130
+ normalizedPath: string,
131
+ infrastructureDirs: readonly string[],
132
+ cwd: string,
133
+ ): boolean {
134
+ if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
135
+ return false;
136
+ }
137
+
138
+ for (const dir of infrastructureDirs) {
139
+ if (isPathWithinDirectory(normalizedPath, dir)) {
140
+ return true;
141
+ }
142
+ }
143
+
144
+ // Project-local Pi packages — checked fresh every call so CWD changes work.
145
+ const projectNpmDir = join(cwd, ".pi", "npm");
146
+ const projectGitDir = join(cwd, ".pi", "git");
147
+ if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
148
+ return true;
149
+ }
150
+ if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
151
+ return true;
152
+ }
153
+
154
+ return false;
155
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ formatBashExternalDirectoryAskPrompt,
5
+ formatBashExternalDirectoryDenyReason,
6
+ formatExternalDirectoryAskPrompt,
7
+ formatExternalDirectoryDenyReason,
8
+ formatExternalDirectoryHardStopHint,
9
+ formatExternalDirectoryUserDeniedReason,
10
+ } from "../src/external-directory-messages";
11
+
12
+ describe("formatExternalDirectoryHardStopHint", () => {
13
+ test("returns the hard stop instruction string", () => {
14
+ const hint = formatExternalDirectoryHardStopHint();
15
+ expect(hint).toContain("Hard stop");
16
+ expect(hint).toContain("external directory");
17
+ });
18
+ });
19
+
20
+ describe("formatExternalDirectoryAskPrompt", () => {
21
+ test("uses 'Current agent' when no agent name provided", () => {
22
+ const result = formatExternalDirectoryAskPrompt(
23
+ "read",
24
+ "/etc/passwd",
25
+ "/projects/my-app",
26
+ );
27
+ expect(result).toContain("Current agent");
28
+ expect(result).toContain("read");
29
+ expect(result).toContain("/etc/passwd");
30
+ expect(result).toContain("/projects/my-app");
31
+ });
32
+
33
+ test("uses agent name when provided", () => {
34
+ const result = formatExternalDirectoryAskPrompt(
35
+ "write",
36
+ "/tmp/out.txt",
37
+ "/projects/my-app",
38
+ "my-agent",
39
+ );
40
+ expect(result).toContain("Agent 'my-agent'");
41
+ expect(result).toContain("write");
42
+ expect(result).toContain("/tmp/out.txt");
43
+ });
44
+ });
45
+
46
+ describe("formatExternalDirectoryDenyReason", () => {
47
+ test("includes tool name, path, cwd, agent name, and hard stop hint", () => {
48
+ const result = formatExternalDirectoryDenyReason(
49
+ "read",
50
+ "/etc/passwd",
51
+ "/projects/my-app",
52
+ "sec-agent",
53
+ );
54
+ expect(result).toContain("Agent 'sec-agent'");
55
+ expect(result).toContain("read");
56
+ expect(result).toContain("/etc/passwd");
57
+ expect(result).toContain("/projects/my-app");
58
+ expect(result).toContain("Hard stop");
59
+ });
60
+
61
+ test("uses 'Current agent' without agent name", () => {
62
+ const result = formatExternalDirectoryDenyReason(
63
+ "read",
64
+ "/etc",
65
+ "/projects",
66
+ );
67
+ expect(result).toContain("Current agent");
68
+ });
69
+ });
70
+
71
+ describe("formatExternalDirectoryUserDeniedReason", () => {
72
+ test("includes tool name and path", () => {
73
+ const result = formatExternalDirectoryUserDeniedReason(
74
+ "edit",
75
+ "/etc/hosts",
76
+ );
77
+ expect(result).toContain("edit");
78
+ expect(result).toContain("/etc/hosts");
79
+ expect(result).toContain("Hard stop");
80
+ });
81
+
82
+ test("appends denial reason when provided", () => {
83
+ const result = formatExternalDirectoryUserDeniedReason(
84
+ "edit",
85
+ "/etc/hosts",
86
+ "too risky",
87
+ );
88
+ expect(result).toContain("Reason: too risky");
89
+ });
90
+
91
+ test("omits reason suffix when not provided", () => {
92
+ const result = formatExternalDirectoryUserDeniedReason(
93
+ "edit",
94
+ "/etc/hosts",
95
+ );
96
+ expect(result).not.toContain("Reason:");
97
+ });
98
+ });
99
+
100
+ describe("formatBashExternalDirectoryAskPrompt", () => {
101
+ test("includes command, paths, cwd, and agent name", () => {
102
+ const result = formatBashExternalDirectoryAskPrompt(
103
+ "cat /etc/passwd",
104
+ ["/etc/passwd"],
105
+ "/projects/my-app",
106
+ "my-agent",
107
+ );
108
+ expect(result).toContain("Agent 'my-agent'");
109
+ expect(result).toContain("cat /etc/passwd");
110
+ expect(result).toContain("/etc/passwd");
111
+ expect(result).toContain("/projects/my-app");
112
+ });
113
+
114
+ test("uses 'Current agent' when no agent name provided", () => {
115
+ const result = formatBashExternalDirectoryAskPrompt(
116
+ "ls /tmp",
117
+ ["/tmp"],
118
+ "/projects/my-app",
119
+ );
120
+ expect(result).toContain("Current agent");
121
+ });
122
+ });
123
+
124
+ describe("formatBashExternalDirectoryDenyReason", () => {
125
+ test("includes command, paths, cwd, agent name, and hard stop hint", () => {
126
+ const result = formatBashExternalDirectoryDenyReason(
127
+ "rm /etc/hosts",
128
+ ["/etc/hosts"],
129
+ "/projects/my-app",
130
+ "sec-agent",
131
+ );
132
+ expect(result).toContain("Agent 'sec-agent'");
133
+ expect(result).toContain("rm /etc/hosts");
134
+ expect(result).toContain("/etc/hosts");
135
+ expect(result).toContain("Hard stop");
136
+ });
137
+ });
@@ -0,0 +1,97 @@
1
+ import { 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
+ import { discoverGlobalNodeModulesRoot } from "../src/node-modules-discovery";
22
+
23
+ describe("discoverGlobalNodeModulesRoot", () => {
24
+ beforeEach(() => {
25
+ mockSpawnSync.mockReset();
26
+ mockExistsSync.mockReset();
27
+ });
28
+
29
+ test("returns node_modules root when URL is inside a node_modules tree", () => {
30
+ const fakeUrl =
31
+ "file:///opt/homebrew/lib/node_modules/@gotgenes/pi-permission-system/dist/external-directory.js";
32
+ const result = discoverGlobalNodeModulesRoot(fakeUrl);
33
+ expect(result).toBe("/opt/homebrew/lib/node_modules");
34
+ expect(mockSpawnSync).not.toHaveBeenCalled();
35
+ });
36
+
37
+ test("calls npm root -g as fallback when walk-up finds no node_modules ancestor", () => {
38
+ const npmRootPath = "/opt/homebrew/lib/node_modules";
39
+ mockSpawnSync.mockReturnValue({
40
+ status: 0,
41
+ stdout: `${npmRootPath}\n`,
42
+ });
43
+ mockExistsSync.mockReturnValue(true);
44
+
45
+ const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
46
+ const result = discoverGlobalNodeModulesRoot(fakeUrl);
47
+
48
+ expect(mockSpawnSync).toHaveBeenCalledWith(
49
+ "npm",
50
+ ["root", "-g"],
51
+ expect.objectContaining({ encoding: "utf-8" }),
52
+ );
53
+ expect(result).toBe(npmRootPath);
54
+ });
55
+
56
+ test("returns null when walk-up fails and npm root -g returns non-zero exit", () => {
57
+ mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
58
+
59
+ const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
60
+ const result = discoverGlobalNodeModulesRoot(fakeUrl);
61
+
62
+ expect(result).toBeNull();
63
+ });
64
+
65
+ test("returns null when walk-up fails and spawnSync throws", () => {
66
+ mockSpawnSync.mockImplementation(() => {
67
+ throw new Error("ENOENT");
68
+ });
69
+
70
+ const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
71
+ const result = discoverGlobalNodeModulesRoot(fakeUrl);
72
+
73
+ expect(result).toBeNull();
74
+ });
75
+
76
+ test("returns null when walk-up fails and npm root -g returns non-existent path", () => {
77
+ mockSpawnSync.mockReturnValue({
78
+ status: 0,
79
+ stdout: "/some/nonexistent/node_modules\n",
80
+ });
81
+ mockExistsSync.mockReturnValue(false);
82
+
83
+ const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
84
+ const result = discoverGlobalNodeModulesRoot(fakeUrl);
85
+
86
+ expect(result).toBeNull();
87
+ });
88
+
89
+ test("returns null when walk-up fails and npm root -g returns empty stdout", () => {
90
+ mockSpawnSync.mockReturnValue({ status: 0, stdout: " " });
91
+
92
+ const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
93
+ const result = discoverGlobalNodeModulesRoot(fakeUrl);
94
+
95
+ expect(result).toBeNull();
96
+ });
97
+ });
@@ -11,8 +11,15 @@ vi.mock("node:os", () => {
11
11
  });
12
12
 
13
13
  import {
14
+ getPathBearingToolPath,
15
+ isPathOutsideWorkingDirectory,
14
16
  isPathWithinDirectory,
17
+ isPiInfrastructureRead,
18
+ isSafeSystemPath,
15
19
  normalizePathForComparison,
20
+ PATH_BEARING_TOOLS,
21
+ READ_ONLY_PATH_BEARING_TOOLS,
22
+ SAFE_SYSTEM_PATHS,
16
23
  } from "../src/path-utils";
17
24
 
18
25
  describe("normalizePathForComparison", () => {
@@ -90,3 +97,197 @@ describe("isPathWithinDirectory", () => {
90
97
  expect(isPathWithinDirectory("/a/b", "")).toBe(false);
91
98
  });
92
99
  });
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
+
170
+ describe("getPathBearingToolPath", () => {
171
+ test("returns path for a path-bearing tool", () => {
172
+ expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
173
+ "/src/foo.ts",
174
+ );
175
+ });
176
+
177
+ test("returns null for a non-path-bearing tool", () => {
178
+ expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
179
+ expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
180
+ expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
181
+ });
182
+
183
+ test("returns null when input has no path", () => {
184
+ expect(getPathBearingToolPath("read", {})).toBeNull();
185
+ expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
186
+ expect(getPathBearingToolPath("read", null)).toBeNull();
187
+ });
188
+ });
189
+
190
+ describe("isPathOutsideWorkingDirectory", () => {
191
+ const cwd = "/projects/my-app";
192
+
193
+ test("returns false when path is inside cwd", () => {
194
+ expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
195
+ false,
196
+ );
197
+ });
198
+
199
+ test("returns false when path equals cwd", () => {
200
+ expect(isPathOutsideWorkingDirectory("/projects/my-app", cwd)).toBe(false);
201
+ });
202
+
203
+ test("returns true when path is outside cwd", () => {
204
+ expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd)).toBe(true);
205
+ });
206
+
207
+ test("returns true for home directory when outside cwd", () => {
208
+ expect(isPathOutsideWorkingDirectory("~/secrets", cwd)).toBe(true);
209
+ });
210
+
211
+ test("returns false for relative path resolving inside cwd", () => {
212
+ expect(isPathOutsideWorkingDirectory("src/index.ts", cwd)).toBe(false);
213
+ });
214
+
215
+ test("returns false for empty path (normalizes to empty string)", () => {
216
+ expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
217
+ });
218
+
219
+ test("returns false for /dev/null regardless of cwd", () => {
220
+ expect(isPathOutsideWorkingDirectory("/dev/null", cwd)).toBe(false);
221
+ });
222
+
223
+ test("returns false for /dev/stdin regardless of cwd", () => {
224
+ expect(isPathOutsideWorkingDirectory("/dev/stdin", cwd)).toBe(false);
225
+ });
226
+
227
+ test("returns false for /dev/stdout regardless of cwd", () => {
228
+ expect(isPathOutsideWorkingDirectory("/dev/stdout", cwd)).toBe(false);
229
+ });
230
+
231
+ test("returns false for /dev/stderr regardless of cwd", () => {
232
+ expect(isPathOutsideWorkingDirectory("/dev/stderr", cwd)).toBe(false);
233
+ });
234
+
235
+ test("returns true for /dev/null/subdir (not a safe path)", () => {
236
+ expect(isPathOutsideWorkingDirectory("/dev/null/subdir", cwd)).toBe(true);
237
+ });
238
+ });
239
+
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,
291
+ );
292
+ });
293
+ });