@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.
package/src/runtime.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type ExtensionContext,
12
12
  getAgentDir,
13
13
  } from "@mariozechner/pi-coding-agent";
14
+
14
15
  import {
15
16
  getActiveAgentName,
16
17
  getActiveAgentNameFromSystemPrompt,
@@ -19,7 +20,6 @@ import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
19
20
  import {
20
21
  DEBUG_LOG_FILENAME,
21
22
  getGlobalConfigPath,
22
- getGlobalLogsDir,
23
23
  getLegacyExtensionConfigPath,
24
24
  getLegacyGlobalPolicyPath,
25
25
  getLegacyProjectPolicyPath,
@@ -34,7 +34,10 @@ import {
34
34
  normalizePermissionSystemConfig,
35
35
  type PermissionSystemExtensionConfig,
36
36
  } from "./extension-config";
37
- import { discoverGlobalNodeModulesRoot } from "./external-directory";
37
+ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
38
+
39
+ export type { ExtensionPaths } from "./extension-paths";
40
+
38
41
  import {
39
42
  type PermissionForwardingDeps,
40
43
  processForwardedPermissionRequests,
@@ -73,22 +76,7 @@ export interface SessionState {
73
76
  * Tests construct this via `createExtensionRuntime({ agentDir: tmpDir })`
74
77
  * without timing issues around `PI_CODING_AGENT_DIR`.
75
78
  */
76
- export interface ExtensionRuntime extends SessionState {
77
- // ── Immutable paths (derived from agentDir at construction) ───────────
78
- readonly agentDir: string;
79
- readonly sessionsDir: string;
80
- readonly subagentSessionsDir: string;
81
- readonly forwardingDir: string;
82
- readonly globalLogsDir: string;
83
- /**
84
- * Static Pi infrastructure directories used for external-directory
85
- * read auto-allow. Computed once at construction from `agentDir` and
86
- * `discoverGlobalNodeModulesRoot()`. Config-based extras
87
- * (`piInfrastructureReadPaths`) are read from `runtime.config` at
88
- * call time in the handler so they pick up config reloads.
89
- */
90
- readonly piInfrastructureDirs: string[];
91
-
79
+ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
92
80
  // ── Mutable state (beyond SessionState) ───────────────────────────────────
93
81
  config: PermissionSystemExtensionConfig;
94
82
  lastConfigWarning: string | null;
@@ -353,27 +341,12 @@ export function createExtensionRuntime(options?: {
353
341
  agentDir?: string;
354
342
  }): ExtensionRuntime {
355
343
  const agentDir = options?.agentDir ?? getAgentDir();
356
- const sessionsDir = join(agentDir, "sessions");
357
- const subagentSessionsDir = join(agentDir, "subagent-sessions");
358
- const forwardingDir = join(sessionsDir, "permission-forwarding");
359
- const globalLogsDir = getGlobalLogsDir(agentDir);
360
-
361
- const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
362
- const piInfrastructureDirs: string[] = [
363
- agentDir,
364
- join(agentDir, "git"),
365
- ...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
366
- ];
344
+ const paths = computeExtensionPaths(agentDir);
367
345
 
368
346
  // Build a plain-object runtime first so the logger's `getConfig` closure
369
347
  // can reference `runtime.config` directly (always reads current value).
370
348
  const runtime: ExtensionRuntime = {
371
- agentDir,
372
- sessionsDir,
373
- subagentSessionsDir,
374
- forwardingDir,
375
- globalLogsDir,
376
- piInfrastructureDirs,
349
+ ...paths,
377
350
  config: { ...DEFAULT_EXTENSION_CONFIG },
378
351
  runtimeContext: null,
379
352
  permissionManager: createPermissionManagerForCwd(agentDir, undefined),
@@ -395,10 +368,10 @@ export function createExtensionRuntime(options?: {
395
368
  const logger = createPermissionSystemLogger({
396
369
  // Reads runtime.config at call time — always current.
397
370
  getConfig: () => runtime.config,
398
- debugLogPath: join(globalLogsDir, DEBUG_LOG_FILENAME),
399
- reviewLogPath: join(globalLogsDir, REVIEW_LOG_FILENAME),
371
+ debugLogPath: join(paths.globalLogsDir, DEBUG_LOG_FILENAME),
372
+ reviewLogPath: join(paths.globalLogsDir, REVIEW_LOG_FILENAME),
400
373
  ensureLogsDirectory: () =>
401
- ensurePermissionSystemLogsDirectory(globalLogsDir),
374
+ ensurePermissionSystemLogsDirectory(paths.globalLogsDir),
402
375
  });
403
376
 
404
377
  const reportLoggingWarning = (message: string): void => {
@@ -9,11 +9,11 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
+ import { extractExternalPathsFromBashCommand } from "../src/handlers/gates/bash-path-extractor";
12
13
  import {
13
- extractExternalPathsFromBashCommand,
14
14
  formatBashExternalDirectoryAskPrompt,
15
15
  formatBashExternalDirectoryDenyReason,
16
- } from "../src/external-directory";
16
+ } from "../src/handlers/gates/external-directory-messages";
17
17
 
18
18
  afterEach(() => {
19
19
  vi.restoreAllMocks();
@@ -0,0 +1,89 @@
1
+ import { join } from "node:path";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const { mockDiscoverGlobalNodeModulesRoot } = vi.hoisted(() => ({
5
+ mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
6
+ }));
7
+
8
+ vi.mock("../src/node-modules-discovery", () => ({
9
+ discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
10
+ }));
11
+
12
+ import { getGlobalLogsDir } from "../src/config-paths";
13
+ import { computeExtensionPaths } from "../src/extension-paths";
14
+
15
+ describe("computeExtensionPaths", () => {
16
+ beforeEach(() => {
17
+ mockDiscoverGlobalNodeModulesRoot.mockReset();
18
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
19
+ "/mock/global/node_modules",
20
+ );
21
+ });
22
+
23
+ it("sets agentDir from argument", () => {
24
+ const paths = computeExtensionPaths("/test/agent");
25
+ expect(paths.agentDir).toBe("/test/agent");
26
+ });
27
+
28
+ it("derives sessionsDir as agentDir/sessions", () => {
29
+ const paths = computeExtensionPaths("/test/agent");
30
+ expect(paths.sessionsDir).toBe("/test/agent/sessions");
31
+ });
32
+
33
+ it("derives subagentSessionsDir as agentDir/subagent-sessions", () => {
34
+ const paths = computeExtensionPaths("/test/agent");
35
+ expect(paths.subagentSessionsDir).toBe("/test/agent/subagent-sessions");
36
+ });
37
+
38
+ it("derives forwardingDir as sessionsDir/permission-forwarding", () => {
39
+ const paths = computeExtensionPaths("/test/agent");
40
+ expect(paths.forwardingDir).toBe(
41
+ join("/test/agent/sessions", "permission-forwarding"),
42
+ );
43
+ });
44
+
45
+ it("derives globalLogsDir via getGlobalLogsDir(agentDir)", () => {
46
+ const paths = computeExtensionPaths("/test/agent");
47
+ expect(paths.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
48
+ });
49
+
50
+ it("includes agentDir in piInfrastructureDirs", () => {
51
+ const paths = computeExtensionPaths("/test/agent");
52
+ expect(paths.piInfrastructureDirs).toContain("/test/agent");
53
+ });
54
+
55
+ it("includes agentDir/git in piInfrastructureDirs", () => {
56
+ const paths = computeExtensionPaths("/test/agent");
57
+ expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
58
+ });
59
+
60
+ it("includes discovered global node_modules root in piInfrastructureDirs", () => {
61
+ const paths = computeExtensionPaths("/test/agent");
62
+ expect(paths.piInfrastructureDirs).toContain("/mock/global/node_modules");
63
+ });
64
+
65
+ it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
66
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
67
+ const paths = computeExtensionPaths("/test/agent");
68
+ expect(paths.piInfrastructureDirs).toHaveLength(2);
69
+ expect(paths.piInfrastructureDirs).toContain("/test/agent");
70
+ expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
71
+ });
72
+
73
+ it("all entries in piInfrastructureDirs are strings (no null)", () => {
74
+ mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
75
+ const paths = computeExtensionPaths("/test/agent");
76
+ for (const dir of paths.piInfrastructureDirs) {
77
+ expect(typeof dir).toBe("string");
78
+ }
79
+ });
80
+
81
+ it("two calls with different agentDirs produce independent results", () => {
82
+ const a = computeExtensionPaths("/agent/a");
83
+ const b = computeExtensionPaths("/agent/b");
84
+ expect(a.agentDir).toBe("/agent/a");
85
+ expect(b.agentDir).toBe("/agent/b");
86
+ expect(a.sessionsDir).toBe("/agent/a/sessions");
87
+ expect(b.sessionsDir).toBe("/agent/b/sessions");
88
+ });
89
+ });
@@ -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/handlers/gates/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
+ });
@@ -14,10 +14,8 @@ vi.mock("node:child_process", () => ({
14
14
  default: { spawnSync: mockSpawnSync },
15
15
  }));
16
16
 
17
- import {
18
- discoverGlobalNodeModulesRoot,
19
- isPiInfrastructureRead,
20
- } from "../src/external-directory";
17
+ import { discoverGlobalNodeModulesRoot } from "../src/node-modules-discovery";
18
+ import { isPiInfrastructureRead } from "../src/path-utils";
21
19
 
22
20
  // ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
23
21
 
@@ -67,7 +67,7 @@ vi.mock("../src/subagent-context", () => ({
67
67
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
68
68
  }));
69
69
 
70
- vi.mock("../src/external-directory", () => ({
70
+ vi.mock("../src/node-modules-discovery", () => ({
71
71
  discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
72
72
  }));
73
73