@gotgenes/pi-permission-system 2.0.0 → 3.0.1

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -35
  3. package/config/config.example.json +6 -0
  4. package/package.json +1 -1
  5. package/schemas/permissions.schema.json +114 -16
  6. package/src/active-agent.ts +58 -0
  7. package/src/config-loader.ts +398 -0
  8. package/src/config-paths.ts +34 -0
  9. package/src/config-reporter.ts +16 -8
  10. package/src/external-directory.ts +113 -0
  11. package/src/forwarded-permissions/io.ts +328 -0
  12. package/src/forwarded-permissions/polling.ts +334 -0
  13. package/src/index.ts +153 -1095
  14. package/src/permission-manager.ts +25 -111
  15. package/src/permission-prompts.ts +131 -0
  16. package/src/subagent-context.ts +52 -0
  17. package/src/tool-input-preview.ts +206 -0
  18. package/tests/active-agent.test.ts +160 -0
  19. package/tests/bash-filter.test.ts +137 -0
  20. package/tests/common.test.ts +189 -0
  21. package/tests/config-loader.test.ts +364 -0
  22. package/tests/config-paths.test.ts +78 -0
  23. package/tests/config-reporter.test.ts +42 -33
  24. package/tests/extension-config.test.ts +51 -0
  25. package/tests/external-directory.test.ts +250 -0
  26. package/tests/permission-prompts.test.ts +301 -0
  27. package/tests/permission-system.test.ts +9 -26
  28. package/tests/session-start.test.ts +8 -33
  29. package/tests/skill-prompt-sanitizer.test.ts +244 -0
  30. package/tests/subagent-context.test.ts +124 -0
  31. package/tests/system-prompt-sanitizer.test.ts +186 -0
  32. package/tests/tool-input-preview.test.ts +452 -0
  33. package/tests/tool-registry.test.ts +155 -0
  34. package/tests/wildcard-matcher.test.ts +180 -0
  35. package/tests/yolo-mode.test.ts +110 -0
@@ -0,0 +1,250 @@
1
+ import { join } from "node:path";
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+
4
+ // Mock node:os so tilde-expansion is deterministic across platforms.
5
+ vi.mock("node:os", () => ({
6
+ homedir: vi.fn(() => "/mock/home"),
7
+ }));
8
+
9
+ import {
10
+ formatExternalDirectoryAskPrompt,
11
+ formatExternalDirectoryDenyReason,
12
+ formatExternalDirectoryHardStopHint,
13
+ formatExternalDirectoryUserDeniedReason,
14
+ getPathBearingToolPath,
15
+ isPathOutsideWorkingDirectory,
16
+ isPathWithinDirectory,
17
+ normalizePathForComparison,
18
+ PATH_BEARING_TOOLS,
19
+ } from "../src/external-directory.js";
20
+
21
+ afterEach(() => {
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ describe("PATH_BEARING_TOOLS", () => {
26
+ test("contains the expected tool names", () => {
27
+ for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
28
+ expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
29
+ }
30
+ });
31
+
32
+ test("does not contain bash or mcp", () => {
33
+ expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
34
+ expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("normalizePathForComparison", () => {
39
+ const cwd = "/projects/my-app";
40
+
41
+ test("resolves absolute path unchanged", () => {
42
+ expect(normalizePathForComparison("/usr/local/bin", cwd)).toBe(
43
+ "/usr/local/bin",
44
+ );
45
+ });
46
+
47
+ test("resolves relative path against cwd", () => {
48
+ expect(normalizePathForComparison("src/foo.ts", cwd)).toBe(
49
+ "/projects/my-app/src/foo.ts",
50
+ );
51
+ });
52
+
53
+ test("expands bare ~ to homedir", () => {
54
+ expect(normalizePathForComparison("~", cwd)).toBe("/mock/home");
55
+ });
56
+
57
+ test("expands ~/... to homedir-relative path", () => {
58
+ expect(normalizePathForComparison("~/docs/readme.md", cwd)).toBe(
59
+ join("/mock/home", "docs/readme.md"),
60
+ );
61
+ });
62
+
63
+ test("strips leading @ before resolving", () => {
64
+ expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
65
+ "/usr/local/bin",
66
+ );
67
+ });
68
+
69
+ test("strips surrounding quotes", () => {
70
+ expect(normalizePathForComparison("'/usr/local/bin'", cwd)).toBe(
71
+ "/usr/local/bin",
72
+ );
73
+ expect(normalizePathForComparison('"/usr/local/bin"', cwd)).toBe(
74
+ "/usr/local/bin",
75
+ );
76
+ });
77
+
78
+ test("returns empty string for blank/whitespace-only path", () => {
79
+ expect(normalizePathForComparison("", cwd)).toBe("");
80
+ expect(normalizePathForComparison(" ", cwd)).toBe("");
81
+ });
82
+ });
83
+
84
+ describe("isPathWithinDirectory", () => {
85
+ test("returns true when path equals directory", () => {
86
+ expect(isPathWithinDirectory("/a/b", "/a/b")).toBe(true);
87
+ });
88
+
89
+ test("returns true when path is a direct child", () => {
90
+ expect(isPathWithinDirectory("/a/b/c", "/a/b")).toBe(true);
91
+ });
92
+
93
+ test("returns true when path is a deep descendant", () => {
94
+ expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b")).toBe(true);
95
+ });
96
+
97
+ test("returns false when path is a sibling directory", () => {
98
+ expect(isPathWithinDirectory("/a/bc", "/a/b")).toBe(false);
99
+ });
100
+
101
+ test("returns false when path is outside the directory", () => {
102
+ expect(isPathWithinDirectory("/other/path", "/a/b")).toBe(false);
103
+ });
104
+
105
+ test("returns false for empty path", () => {
106
+ expect(isPathWithinDirectory("", "/a/b")).toBe(false);
107
+ });
108
+
109
+ test("returns false for empty directory", () => {
110
+ expect(isPathWithinDirectory("/a/b", "")).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("getPathBearingToolPath", () => {
115
+ test("returns path for a path-bearing tool", () => {
116
+ expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
117
+ "/src/foo.ts",
118
+ );
119
+ });
120
+
121
+ test("returns null for a non-path-bearing tool", () => {
122
+ expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
123
+ expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
124
+ expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
125
+ });
126
+
127
+ test("returns null when input has no path", () => {
128
+ expect(getPathBearingToolPath("read", {})).toBeNull();
129
+ expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
130
+ expect(getPathBearingToolPath("read", null)).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("isPathOutsideWorkingDirectory", () => {
135
+ const cwd = "/projects/my-app";
136
+
137
+ test("returns false when path is inside cwd", () => {
138
+ expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
139
+ false,
140
+ );
141
+ });
142
+
143
+ test("returns false when path equals cwd", () => {
144
+ expect(isPathOutsideWorkingDirectory("/projects/my-app", cwd)).toBe(false);
145
+ });
146
+
147
+ test("returns true when path is outside cwd", () => {
148
+ expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd)).toBe(true);
149
+ });
150
+
151
+ test("returns true for home directory when outside cwd", () => {
152
+ expect(isPathOutsideWorkingDirectory("~/secrets", cwd)).toBe(true);
153
+ });
154
+
155
+ test("returns false for relative path resolving inside cwd", () => {
156
+ expect(isPathOutsideWorkingDirectory("src/index.ts", cwd)).toBe(false);
157
+ });
158
+
159
+ test("returns false for empty path (normalizes to empty string)", () => {
160
+ expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
161
+ });
162
+ });
163
+
164
+ describe("formatExternalDirectoryHardStopHint", () => {
165
+ test("returns the hard stop instruction string", () => {
166
+ const hint = formatExternalDirectoryHardStopHint();
167
+ expect(hint).toContain("Hard stop");
168
+ expect(hint).toContain("external directory");
169
+ });
170
+ });
171
+
172
+ describe("formatExternalDirectoryAskPrompt", () => {
173
+ test("uses 'Current agent' when no agent name provided", () => {
174
+ const result = formatExternalDirectoryAskPrompt(
175
+ "read",
176
+ "/etc/passwd",
177
+ "/projects/my-app",
178
+ );
179
+ expect(result).toContain("Current agent");
180
+ expect(result).toContain("read");
181
+ expect(result).toContain("/etc/passwd");
182
+ expect(result).toContain("/projects/my-app");
183
+ });
184
+
185
+ test("uses agent name when provided", () => {
186
+ const result = formatExternalDirectoryAskPrompt(
187
+ "write",
188
+ "/tmp/out.txt",
189
+ "/projects/my-app",
190
+ "my-agent",
191
+ );
192
+ expect(result).toContain("Agent 'my-agent'");
193
+ expect(result).toContain("write");
194
+ expect(result).toContain("/tmp/out.txt");
195
+ });
196
+ });
197
+
198
+ describe("formatExternalDirectoryDenyReason", () => {
199
+ test("includes tool name, path, cwd, agent name, and hard stop hint", () => {
200
+ const result = formatExternalDirectoryDenyReason(
201
+ "read",
202
+ "/etc/passwd",
203
+ "/projects/my-app",
204
+ "sec-agent",
205
+ );
206
+ expect(result).toContain("Agent 'sec-agent'");
207
+ expect(result).toContain("read");
208
+ expect(result).toContain("/etc/passwd");
209
+ expect(result).toContain("/projects/my-app");
210
+ expect(result).toContain("Hard stop");
211
+ });
212
+
213
+ test("uses 'Current agent' without agent name", () => {
214
+ const result = formatExternalDirectoryDenyReason(
215
+ "read",
216
+ "/etc",
217
+ "/projects",
218
+ );
219
+ expect(result).toContain("Current agent");
220
+ });
221
+ });
222
+
223
+ describe("formatExternalDirectoryUserDeniedReason", () => {
224
+ test("includes tool name and path", () => {
225
+ const result = formatExternalDirectoryUserDeniedReason(
226
+ "edit",
227
+ "/etc/hosts",
228
+ );
229
+ expect(result).toContain("edit");
230
+ expect(result).toContain("/etc/hosts");
231
+ expect(result).toContain("Hard stop");
232
+ });
233
+
234
+ test("appends denial reason when provided", () => {
235
+ const result = formatExternalDirectoryUserDeniedReason(
236
+ "edit",
237
+ "/etc/hosts",
238
+ "too risky",
239
+ );
240
+ expect(result).toContain("Reason: too risky");
241
+ });
242
+
243
+ test("omits reason suffix when not provided", () => {
244
+ const result = formatExternalDirectoryUserDeniedReason(
245
+ "edit",
246
+ "/etc/hosts",
247
+ );
248
+ expect(result).not.toContain("Reason:");
249
+ });
250
+ });
@@ -0,0 +1,301 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock tool-input-preview collaborator before importing the module under test.
4
+ vi.mock("../src/tool-input-preview.js", () => ({
5
+ formatToolInputForPrompt: vi.fn(() => "mocked preview"),
6
+ }));
7
+
8
+ import {
9
+ formatAskPrompt,
10
+ formatDenyReason,
11
+ formatMissingToolNameReason,
12
+ formatPermissionHardStopHint,
13
+ formatSkillAskPrompt,
14
+ formatSkillPathAskPrompt,
15
+ formatSkillPathDenyReason,
16
+ formatUnknownToolReason,
17
+ formatUserDeniedReason,
18
+ } from "../src/permission-prompts.js";
19
+ import type { SkillPromptEntry } from "../src/skill-prompt-sanitizer.js";
20
+ import { formatToolInputForPrompt } from "../src/tool-input-preview.js";
21
+ import type { PermissionCheckResult } from "../src/types.js";
22
+
23
+ const mockedFormatToolInput = vi.mocked(formatToolInputForPrompt);
24
+
25
+ afterEach(() => {
26
+ vi.clearAllMocks();
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ function toolResult(
31
+ toolName: string,
32
+ overrides: Partial<PermissionCheckResult> = {},
33
+ ): PermissionCheckResult {
34
+ return { toolName, state: "ask", source: "tool", ...overrides };
35
+ }
36
+
37
+ function mcpResult(
38
+ target: string,
39
+ overrides: Partial<PermissionCheckResult> = {},
40
+ ): PermissionCheckResult {
41
+ return {
42
+ toolName: "mcp",
43
+ target,
44
+ state: "ask",
45
+ source: "tool",
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function skillEntry(name: string): SkillPromptEntry {
51
+ return {
52
+ name,
53
+ description: "A skill",
54
+ location: `/skills/${name}/SKILL.md`,
55
+ state: "ask",
56
+ normalizedLocation: `/skills/${name}/SKILL.md`,
57
+ normalizedBaseDir: `/skills/${name}`,
58
+ };
59
+ }
60
+
61
+ describe("formatMissingToolNameReason", () => {
62
+ test("mentions missing tool name and pi.getAllTools()", () => {
63
+ const result = formatMissingToolNameReason();
64
+ expect(result).toContain("no tool name");
65
+ expect(result).toContain("pi.getAllTools()");
66
+ });
67
+ });
68
+
69
+ describe("formatUnknownToolReason", () => {
70
+ test("mentions the unknown tool name and lists available tools", () => {
71
+ const result = formatUnknownToolReason("phantom", ["read", "write"]);
72
+ expect(result).toContain("phantom");
73
+ expect(result).toContain("read");
74
+ expect(result).toContain("write");
75
+ });
76
+
77
+ test("includes MCP hint for non-mcp tool names", () => {
78
+ const result = formatUnknownToolReason("my-server:tool", ["mcp"]);
79
+ expect(result).toContain("mcp");
80
+ });
81
+
82
+ test("omits MCP hint when tool name is 'mcp'", () => {
83
+ const result = formatUnknownToolReason("mcp", []);
84
+ expect(result).not.toContain("call the registered 'mcp' tool");
85
+ });
86
+
87
+ test("shows 'none' when no tools are registered", () => {
88
+ const result = formatUnknownToolReason("ghost", []);
89
+ expect(result).toContain("none");
90
+ });
91
+
92
+ test("caps preview at 10 tools and appends ellipsis for longer lists", () => {
93
+ const tools = Array.from({ length: 15 }, (_, i) => `tool${i}`);
94
+ const result = formatUnknownToolReason("ghost", tools);
95
+ expect(result).toContain("...");
96
+ });
97
+ });
98
+
99
+ describe("formatPermissionHardStopHint", () => {
100
+ test("returns MCP-specific message for mcp tool with target", () => {
101
+ const result = formatPermissionHardStopHint(mcpResult("server:tool"));
102
+ expect(result).toContain("MCP permission denial");
103
+ });
104
+
105
+ test("returns MCP-specific message for mcp source with target", () => {
106
+ const result = formatPermissionHardStopHint(
107
+ toolResult("anything", { source: "mcp", target: "server:tool" }),
108
+ );
109
+ expect(result).toContain("MCP permission denial");
110
+ });
111
+
112
+ test("returns generic message for non-MCP tools", () => {
113
+ const result = formatPermissionHardStopHint(toolResult("read"));
114
+ expect(result).toContain("Hard stop");
115
+ expect(result).not.toContain("MCP");
116
+ });
117
+ });
118
+
119
+ describe("formatDenyReason", () => {
120
+ test("includes tool name and hard stop hint", () => {
121
+ const result = formatDenyReason(toolResult("read"));
122
+ expect(result).toContain("read");
123
+ expect(result).toContain("Hard stop");
124
+ });
125
+
126
+ test("includes agent name when provided", () => {
127
+ const result = formatDenyReason(toolResult("write"), "my-agent");
128
+ expect(result).toContain("Agent 'my-agent'");
129
+ });
130
+
131
+ test("includes MCP target for mcp results", () => {
132
+ const result = formatDenyReason(mcpResult("server:do-thing"));
133
+ expect(result).toContain("server:do-thing");
134
+ expect(result).toContain("MCP");
135
+ });
136
+
137
+ test("includes bash command when present", () => {
138
+ const result = formatDenyReason(
139
+ toolResult("bash", { command: "rm -rf /" }),
140
+ );
141
+ expect(result).toContain("rm -rf /");
142
+ });
143
+
144
+ test("includes matched pattern when present", () => {
145
+ const result = formatDenyReason(
146
+ toolResult("bash", { command: "rm -rf /", matchedPattern: "rm *" }),
147
+ );
148
+ expect(result).toContain("matched 'rm *'");
149
+ });
150
+ });
151
+
152
+ describe("formatUserDeniedReason", () => {
153
+ test("mentions tool name for generic tools", () => {
154
+ const result = formatUserDeniedReason(toolResult("read"));
155
+ expect(result).toContain("read");
156
+ expect(result).toContain("Hard stop");
157
+ });
158
+
159
+ test("mentions bash command for bash results", () => {
160
+ const result = formatUserDeniedReason(
161
+ toolResult("bash", { command: "ls -la" }),
162
+ );
163
+ expect(result).toContain("ls -la");
164
+ });
165
+
166
+ test("mentions MCP target for mcp results", () => {
167
+ const result = formatUserDeniedReason(mcpResult("server:query"));
168
+ expect(result).toContain("server:query");
169
+ });
170
+
171
+ test("appends denial reason when provided", () => {
172
+ const result = formatUserDeniedReason(toolResult("read"), "too sensitive");
173
+ expect(result).toContain("Reason: too sensitive");
174
+ });
175
+
176
+ test("omits reason suffix when not provided", () => {
177
+ const result = formatUserDeniedReason(toolResult("read"));
178
+ expect(result).not.toContain("Reason:");
179
+ });
180
+ });
181
+
182
+ describe("formatAskPrompt", () => {
183
+ test("uses 'Current agent' when no agent name given", () => {
184
+ const result = formatAskPrompt(toolResult("read"), undefined, {
185
+ path: "/src",
186
+ });
187
+ expect(result).toContain("Current agent");
188
+ });
189
+
190
+ test("uses agent name when provided", () => {
191
+ const result = formatAskPrompt(toolResult("read"), "my-agent", {
192
+ path: "/src",
193
+ });
194
+ expect(result).toContain("Agent 'my-agent'");
195
+ });
196
+
197
+ test("formats bash prompt with command and no tool-input-preview call", () => {
198
+ const result = formatAskPrompt(
199
+ toolResult("bash", { command: "git status" }),
200
+ );
201
+ expect(result).toContain("git status");
202
+ expect(result).toContain("Allow this command?");
203
+ expect(mockedFormatToolInput).not.toHaveBeenCalled();
204
+ });
205
+
206
+ test("formats bash prompt with matched pattern", () => {
207
+ const result = formatAskPrompt(
208
+ toolResult("bash", { command: "git push", matchedPattern: "git *" }),
209
+ );
210
+ expect(result).toContain("matched 'git *'");
211
+ });
212
+
213
+ test("formats MCP prompt with target", () => {
214
+ const result = formatAskPrompt(mcpResult("server:query"));
215
+ expect(result).toContain("server:query");
216
+ expect(result).toContain("Allow this call?");
217
+ expect(mockedFormatToolInput).not.toHaveBeenCalled();
218
+ });
219
+
220
+ test("formats MCP prompt with matched pattern", () => {
221
+ const result = formatAskPrompt(
222
+ mcpResult("server:query", { matchedPattern: "server:*" }),
223
+ );
224
+ expect(result).toContain("matched 'server:*'");
225
+ });
226
+
227
+ test("calls formatToolInputForPrompt for non-bash non-mcp tools", () => {
228
+ mockedFormatToolInput.mockReturnValue("for '/src/foo.ts'");
229
+ const result = formatAskPrompt(toolResult("read"), undefined, {
230
+ path: "/src/foo.ts",
231
+ });
232
+ expect(mockedFormatToolInput).toHaveBeenCalledWith("read", {
233
+ path: "/src/foo.ts",
234
+ });
235
+ expect(result).toContain("for '/src/foo.ts'");
236
+ expect(result).toContain("Allow this call?");
237
+ });
238
+
239
+ test("omits input suffix when formatToolInputForPrompt returns empty string", () => {
240
+ mockedFormatToolInput.mockReturnValue("");
241
+ const result = formatAskPrompt(toolResult("task"));
242
+ expect(result).toContain("task");
243
+ expect(result).not.toContain("undefined");
244
+ });
245
+ });
246
+
247
+ describe("formatSkillAskPrompt", () => {
248
+ test("includes skill name and agent name", () => {
249
+ const result = formatSkillAskPrompt("librarian", "my-agent");
250
+ expect(result).toContain("librarian");
251
+ expect(result).toContain("Agent 'my-agent'");
252
+ });
253
+
254
+ test("uses 'Current agent' without agent name", () => {
255
+ const result = formatSkillAskPrompt("librarian");
256
+ expect(result).toContain("Current agent");
257
+ expect(result).toContain("librarian");
258
+ });
259
+ });
260
+
261
+ describe("formatSkillPathAskPrompt", () => {
262
+ test("includes skill name, read path, and agent name", () => {
263
+ const result = formatSkillPathAskPrompt(
264
+ skillEntry("librarian"),
265
+ "/skills/librarian/SKILL.md",
266
+ "my-agent",
267
+ );
268
+ expect(result).toContain("librarian");
269
+ expect(result).toContain("/skills/librarian/SKILL.md");
270
+ expect(result).toContain("Agent 'my-agent'");
271
+ });
272
+
273
+ test("uses 'Current agent' without agent name", () => {
274
+ const result = formatSkillPathAskPrompt(
275
+ skillEntry("librarian"),
276
+ "/skills/librarian/SKILL.md",
277
+ );
278
+ expect(result).toContain("Current agent");
279
+ });
280
+ });
281
+
282
+ describe("formatSkillPathDenyReason", () => {
283
+ test("includes skill name, read path, and agent name", () => {
284
+ const result = formatSkillPathDenyReason(
285
+ skillEntry("librarian"),
286
+ "/skills/librarian/SKILL.md",
287
+ "my-agent",
288
+ );
289
+ expect(result).toContain("librarian");
290
+ expect(result).toContain("/skills/librarian/SKILL.md");
291
+ expect(result).toContain("Agent 'my-agent'");
292
+ });
293
+
294
+ test("uses 'Current agent' without agent name", () => {
295
+ const result = formatSkillPathDenyReason(
296
+ skillEntry("librarian"),
297
+ "/skills/librarian/SKILL.md",
298
+ );
299
+ expect(result).toContain("Current agent");
300
+ });
301
+ });
@@ -5,11 +5,10 @@ import {
5
5
  mkdtempSync,
6
6
  readFileSync,
7
7
  rmSync,
8
- unlinkSync,
9
8
  writeFileSync,
10
9
  } from "node:fs";
11
10
  import { tmpdir } from "node:os";
12
- import { join, resolve } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
13
12
  import { test } from "vitest";
14
13
  import { BashFilter } from "../src/bash-filter.js";
15
14
  import {
@@ -17,8 +16,8 @@ import {
17
16
  createBeforeAgentStartPromptStateKey,
18
17
  shouldApplyCachedAgentStartState,
19
18
  } from "../src/before-agent-start-cache.js";
19
+ import { getGlobalConfigPath } from "../src/config-paths.js";
20
20
  import {
21
- CONFIG_PATH,
22
21
  DEFAULT_EXTENSION_CONFIG,
23
22
  loadPermissionSystemConfig,
24
23
  savePermissionSystemConfig,
@@ -156,20 +155,13 @@ function createToolCallHarness(
156
155
  const prompts: string[] = [];
157
156
  const handlers: Record<string, MockHandler> = {};
158
157
  const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
159
- const originalExtensionConfig = existsSync(CONFIG_PATH)
160
- ? readFileSync(CONFIG_PATH, "utf8")
161
- : null;
162
-
158
+ const globalConfigPath = getGlobalConfigPath(baseDir);
163
159
  mkdirSync(join(baseDir, "agents"), { recursive: true });
160
+ mkdirSync(dirname(globalConfigPath), { recursive: true });
164
161
  mkdirSync(cwd, { recursive: true });
165
162
  writeFileSync(
166
- join(baseDir, "pi-permissions.jsonc"),
167
- `${JSON.stringify(config, null, 2)}\n`,
168
- "utf8",
169
- );
170
- writeFileSync(
171
- CONFIG_PATH,
172
- `${JSON.stringify(DEFAULT_EXTENSION_CONFIG, null, 2)}\n`,
163
+ globalConfigPath,
164
+ `${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
173
165
  "utf8",
174
166
  );
175
167
 
@@ -208,13 +200,6 @@ function createToolCallHarness(
208
200
  createMockContext(cwd, prompts, options),
209
201
  ),
210
202
  );
211
- if (originalExtensionConfig === null) {
212
- if (existsSync(CONFIG_PATH)) {
213
- unlinkSync(CONFIG_PATH);
214
- }
215
- } else {
216
- writeFileSync(CONFIG_PATH, originalExtensionConfig, "utf8");
217
- }
218
203
  rmSync(baseDir, { recursive: true, force: true });
219
204
  },
220
205
  };
@@ -1818,7 +1803,9 @@ permission:
1818
1803
  test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
1819
1804
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
1820
1805
  const agentsDir = join(baseDir, "agents");
1806
+ const newConfigPath = getGlobalConfigPath(baseDir);
1821
1807
  mkdirSync(agentsDir, { recursive: true });
1808
+ mkdirSync(dirname(newConfigPath), { recursive: true });
1822
1809
 
1823
1810
  const config: GlobalPermissionConfig = {
1824
1811
  defaultPolicy: {
@@ -1834,11 +1821,7 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
1834
1821
  skills: {},
1835
1822
  special: {},
1836
1823
  };
1837
- writeFileSync(
1838
- join(baseDir, "pi-permissions.jsonc"),
1839
- JSON.stringify(config),
1840
- "utf8",
1841
- );
1824
+ writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
1842
1825
 
1843
1826
  const original = process.env.PI_CODING_AGENT_DIR;
1844
1827
  process.env.PI_CODING_AGENT_DIR = baseDir;