@gotgenes/pi-permission-system 7.1.4 → 7.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +2 -2
  3. package/src/active-agent.ts +1 -1
  4. package/src/bash-arity.ts +1 -0
  5. package/src/config-modal.ts +2 -0
  6. package/src/forwarded-permissions/io.ts +4 -2
  7. package/src/forwarded-permissions/polling.ts +22 -9
  8. package/src/forwarding-manager.ts +3 -1
  9. package/src/handlers/before-agent-start.ts +7 -6
  10. package/src/handlers/gates/bash-path-extractor.ts +3 -5
  11. package/src/handlers/gates/bash-path.ts +1 -1
  12. package/src/handlers/gates/runner.ts +3 -0
  13. package/src/handlers/lifecycle.ts +9 -8
  14. package/src/handlers/permission-gate-handler.ts +12 -7
  15. package/src/index.ts +19 -1
  16. package/src/logging.ts +3 -0
  17. package/src/node-modules-discovery.ts +1 -1
  18. package/src/normalize.ts +1 -0
  19. package/src/permission-event-rpc.ts +2 -0
  20. package/src/permission-forwarding.ts +15 -0
  21. package/src/permission-manager.ts +7 -6
  22. package/src/permission-merge.ts +4 -2
  23. package/src/permission-prompter.ts +7 -0
  24. package/src/permission-prompts.ts +1 -1
  25. package/src/policy-loader.ts +5 -5
  26. package/src/service.ts +37 -1
  27. package/src/skill-prompt-sanitizer.ts +3 -3
  28. package/src/subagent-context.ts +14 -1
  29. package/src/subagent-registry.ts +60 -0
  30. package/src/tool-registry.ts +1 -1
  31. package/src/yolo-mode.ts +2 -1
  32. package/test/config-modal.test.ts +6 -8
  33. package/test/forwarding-manager.test.ts +1 -0
  34. package/test/handlers/before-agent-start.test.ts +1 -1
  35. package/test/handlers/external-directory-integration.test.ts +1 -1
  36. package/test/handlers/gates/skill-read.test.ts +8 -10
  37. package/test/handlers/gates/tool.test.ts +1 -1
  38. package/test/handlers/input-events.test.ts +1 -1
  39. package/test/handlers/input.test.ts +1 -1
  40. package/test/handlers/tool-call-events.test.ts +1 -1
  41. package/test/handlers/tool-call.test.ts +1 -1
  42. package/test/permission-event-rpc.test.ts +1 -0
  43. package/test/permission-events.test.ts +2 -0
  44. package/test/permission-forwarding.test.ts +98 -0
  45. package/test/permission-manager-unified.test.ts +4 -2
  46. package/test/permission-session.test.ts +2 -2
  47. package/test/permission-system.test.ts +8 -8
  48. package/test/service.test.ts +100 -6
  49. package/test/subagent-context.test.ts +65 -0
  50. package/test/subagent-registry.test.ts +94 -0
@@ -112,6 +112,7 @@ type ExtensionHarnessOptions = {
112
112
 
113
113
  const INHERITED_SUBAGENT_ENV_KEYS = [
114
114
  ...SUBAGENT_ENV_HINT_KEYS,
115
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- test uses deprecated alias intentionally
115
116
  SUBAGENT_PARENT_SESSION_ENV_KEY,
116
117
  ] as const;
117
118
 
@@ -121,6 +122,7 @@ async function withIsolatedSubagentEnv<T>(
121
122
  const originalValues = new Map<string, string | undefined>();
122
123
  for (const key of INHERITED_SUBAGENT_ENV_KEYS) {
123
124
  originalValues.set(key, process.env[key]);
125
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- process.env cleanup requires dynamic delete
124
126
  delete process.env[key];
125
127
  }
126
128
 
@@ -129,6 +131,7 @@ async function withIsolatedSubagentEnv<T>(
129
131
  } finally {
130
132
  for (const [key, value] of originalValues.entries()) {
131
133
  if (value === undefined) {
134
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- process.env cleanup requires dynamic delete
132
135
  delete process.env[key];
133
136
  } else {
134
137
  process.env[key] = value;
@@ -143,7 +146,7 @@ function createToolCallHarness(
143
146
  options: ExtensionHarnessOptions = {},
144
147
  ): ExtensionHarness {
145
148
  const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-runtime-"));
146
- const cwd = options.cwd || baseDir;
149
+ const cwd = options.cwd ?? baseDir;
147
150
  const prompts: string[] = [];
148
151
  const handlers: Record<string, MockHandler> = {};
149
152
  const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
@@ -188,10 +191,7 @@ function createToolCallHarness(
188
191
  prompts,
189
192
  cleanup: async (): Promise<void> => {
190
193
  await Promise.resolve(
191
- handlers.session_shutdown?.(
192
- {},
193
- createMockContext(cwd, prompts, options),
194
- ),
194
+ handlers.session_shutdown({}, createMockContext(cwd, prompts, options)),
195
195
  );
196
196
  rmSync(baseDir, { recursive: true, force: true });
197
197
  },
@@ -236,7 +236,7 @@ async function runToolCall(
236
236
  handler(event, createMockContext(harness.cwd, harness.prompts, options)),
237
237
  ),
238
238
  );
239
- return (result ?? {}) as Record<string, unknown>;
239
+ return result ?? {};
240
240
  }
241
241
 
242
242
  test("Yolo mode only auto-approves ask-state permissions", () => {
@@ -1515,7 +1515,7 @@ test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills blo
1515
1515
 
1516
1516
  expect(result.prompt).not.toContain("denied-skill");
1517
1517
  expect(result.prompt).toContain("visible-skill");
1518
- expect((result.prompt.match(/<available_skills>/g) || []).length).toBe(1);
1518
+ expect((result.prompt.match(/<available_skills>/g) ?? []).length).toBe(1);
1519
1519
  expect(result.entries.map((entry) => entry.name)).toEqual([
1520
1520
  "visible-skill",
1521
1521
  ]);
@@ -2371,7 +2371,7 @@ test("session approval: session_shutdown clears session approvals", async () =>
2371
2371
  hasUI: true,
2372
2372
  selectResponse: "Yes",
2373
2373
  });
2374
- await Promise.resolve(harness.handlers.session_shutdown?.({}, shutdownCtx));
2374
+ await Promise.resolve(harness.handlers.session_shutdown({}, shutdownCtx));
2375
2375
 
2376
2376
  // Access same path again — should prompt because cache was cleared
2377
2377
  const result = await runToolCall(
@@ -6,6 +6,7 @@ import {
6
6
  publishPermissionsService,
7
7
  unpublishPermissionsService,
8
8
  } from "#src/service";
9
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
9
10
  import type { PermissionCheckResult } from "#src/types";
10
11
 
11
12
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -15,6 +16,9 @@ function makeService(
15
16
  ): PermissionsService {
16
17
  return {
17
18
  checkPermission: vi.fn(),
19
+ registerSubagentSession: vi.fn(),
20
+ unregisterSubagentSession: vi.fn(),
21
+ getToolPermission: vi.fn(),
18
22
  ...overrides,
19
23
  };
20
24
  }
@@ -85,12 +89,12 @@ describe("service adapter delegation", () => {
85
89
  ];
86
90
 
87
91
  // Build the adapter the same way index.ts will
88
- const service: PermissionsService = {
92
+ const service = makeService({
89
93
  checkPermission(surface, value, agentName) {
90
94
  const input = buildInputForSurface(surface, value);
91
95
  return checkPermission(surface, input, agentName, sessionRules);
92
96
  },
93
- };
97
+ });
94
98
 
95
99
  publishPermissionsService(service);
96
100
  const retrieved = getPermissionsService()!;
@@ -108,12 +112,12 @@ describe("service adapter delegation", () => {
108
112
  it("checkPermission passes agentName through", () => {
109
113
  const checkPermission = vi.fn().mockReturnValue(fakeResult);
110
114
 
111
- const service: PermissionsService = {
115
+ const service = makeService({
112
116
  checkPermission(surface, value, agentName) {
113
117
  const input = buildInputForSurface(surface, value);
114
118
  return checkPermission(surface, input, agentName, []);
115
119
  },
116
- };
120
+ });
117
121
 
118
122
  publishPermissionsService(service);
119
123
  getPermissionsService()!.checkPermission("skill", "my-skill", "Explore");
@@ -126,15 +130,105 @@ describe("service adapter delegation", () => {
126
130
  );
127
131
  });
128
132
 
133
+ it("registerSubagentSession delegates to the registry", () => {
134
+ const registry = new SubagentSessionRegistry();
135
+ const service: PermissionsService = {
136
+ checkPermission: vi.fn(),
137
+ registerSubagentSession(key, info) {
138
+ registry.register(key, info);
139
+ },
140
+ unregisterSubagentSession(key) {
141
+ registry.unregister(key);
142
+ },
143
+ getToolPermission: vi.fn((): "allow" => "allow"),
144
+ };
145
+
146
+ publishPermissionsService(service);
147
+ getPermissionsService()!.registerSubagentSession("/sessions/task-1", {
148
+ agentName: "Explore",
149
+ parentSessionId: "parent-abc",
150
+ });
151
+
152
+ expect(registry.has("/sessions/task-1")).toBe(true);
153
+ expect(registry.get("/sessions/task-1")).toEqual({
154
+ agentName: "Explore",
155
+ parentSessionId: "parent-abc",
156
+ });
157
+ });
158
+
159
+ it("unregisterSubagentSession delegates to the registry", () => {
160
+ const registry = new SubagentSessionRegistry();
161
+ const service: PermissionsService = {
162
+ checkPermission: vi.fn(),
163
+ registerSubagentSession(key, info) {
164
+ registry.register(key, info);
165
+ },
166
+ unregisterSubagentSession(key) {
167
+ registry.unregister(key);
168
+ },
169
+ getToolPermission: vi.fn((): "allow" => "allow"),
170
+ };
171
+
172
+ publishPermissionsService(service);
173
+ const svc = getPermissionsService()!;
174
+ svc.registerSubagentSession("/sessions/task-1", { agentName: "Explore" });
175
+ svc.unregisterSubagentSession("/sessions/task-1");
176
+
177
+ expect(registry.has("/sessions/task-1")).toBe(false);
178
+ });
179
+
180
+ it("getToolPermission delegates to the permission manager", () => {
181
+ const getToolPermissionFn = vi.fn(
182
+ (_t: string, _a?: string): "deny" => "deny",
183
+ );
184
+ const service: PermissionsService = {
185
+ checkPermission: vi.fn(),
186
+ registerSubagentSession: vi.fn(),
187
+ unregisterSubagentSession: vi.fn(),
188
+ getToolPermission(toolName, agentName) {
189
+ return getToolPermissionFn(toolName, agentName);
190
+ },
191
+ };
192
+
193
+ publishPermissionsService(service);
194
+ const result = getPermissionsService()!.getToolPermission(
195
+ "bash",
196
+ "Explore",
197
+ );
198
+
199
+ expect(result).toBe("deny");
200
+ expect(getToolPermissionFn).toHaveBeenCalledWith("bash", "Explore");
201
+ });
202
+
203
+ it("getToolPermission works without agentName", () => {
204
+ const getToolPermissionFn = vi.fn(
205
+ (_t: string, _a?: string): "ask" => "ask",
206
+ );
207
+ const service: PermissionsService = {
208
+ checkPermission: vi.fn(),
209
+ registerSubagentSession: vi.fn(),
210
+ unregisterSubagentSession: vi.fn(),
211
+ getToolPermission(toolName, agentName) {
212
+ return getToolPermissionFn(toolName, agentName);
213
+ },
214
+ };
215
+
216
+ publishPermissionsService(service);
217
+ const result = getPermissionsService()!.getToolPermission("write");
218
+
219
+ expect(result).toBe("ask");
220
+ expect(getToolPermissionFn).toHaveBeenCalledWith("write", undefined);
221
+ });
222
+
129
223
  it("checkPermission uses empty object for unknown surfaces", () => {
130
224
  const checkPermission = vi.fn().mockReturnValue(fakeResult);
131
225
 
132
- const service: PermissionsService = {
226
+ const service = makeService({
133
227
  checkPermission(surface, value, agentName) {
134
228
  const input = buildInputForSurface(surface, value);
135
229
  return checkPermission(surface, input, agentName, []);
136
230
  },
137
- };
231
+ });
138
232
 
139
233
  publishPermissionsService(service);
140
234
  getPermissionsService()!.checkPermission("read", "/tmp/file");
@@ -5,6 +5,7 @@ import {
5
5
  isSubagentExecutionContext,
6
6
  normalizeFilesystemPath,
7
7
  } from "#src/subagent-context";
8
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
8
9
 
9
10
  afterEach(() => {
10
11
  vi.unstubAllEnvs();
@@ -197,3 +198,67 @@ describe("isSubagentExecutionContext — session dir detection", () => {
197
198
  expect(isSubagentExecutionContext(makeCtx(""), subagentRoot)).toBe(false);
198
199
  });
199
200
  });
201
+
202
+ describe("isSubagentExecutionContext — registry detection", () => {
203
+ const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
204
+ const outsideDir =
205
+ "/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
206
+
207
+ test("returns true when session dir is registered (no env vars, outside filesystem root)", () => {
208
+ const registry = new SubagentSessionRegistry();
209
+ registry.register(outsideDir, { agentName: "Explore" });
210
+ expect(
211
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
212
+ ).toBe(true);
213
+ });
214
+
215
+ test("returns true when registered session has a parentSessionId", () => {
216
+ const registry = new SubagentSessionRegistry();
217
+ registry.register(outsideDir, {
218
+ agentName: "Plan",
219
+ parentSessionId: "parent-123",
220
+ });
221
+ expect(
222
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
223
+ ).toBe(true);
224
+ });
225
+
226
+ test("returns false when registry is provided but session dir is not registered", () => {
227
+ const registry = new SubagentSessionRegistry();
228
+ expect(
229
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
230
+ ).toBe(false);
231
+ });
232
+
233
+ test("returns false when session dir is null and registry has no matching entry", () => {
234
+ const registry = new SubagentSessionRegistry();
235
+ expect(
236
+ isSubagentExecutionContext(makeCtx(null), subagentRoot, registry),
237
+ ).toBe(false);
238
+ });
239
+
240
+ test("registry check takes priority over env var detection", () => {
241
+ // Registry says registered; env var not set — should still return true.
242
+ const registry = new SubagentSessionRegistry();
243
+ registry.register(outsideDir, { agentName: "Explore" });
244
+ // Confirm no env var is set
245
+ expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
246
+ expect(
247
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
248
+ ).toBe(true);
249
+ });
250
+
251
+ test("unregistered session falls through to env var detection", () => {
252
+ vi.stubEnv("PI_IS_SUBAGENT", "true");
253
+ const registry = new SubagentSessionRegistry(); // empty — outsideDir not registered
254
+ // Env var present → still true even without registry entry
255
+ expect(
256
+ isSubagentExecutionContext(makeCtx(outsideDir), subagentRoot, registry),
257
+ ).toBe(true);
258
+ });
259
+
260
+ test("no registry passed — existing behaviour unchanged", () => {
261
+ // Ensure the parameter is truly optional (no registry arg)
262
+ expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
263
+ });
264
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ type SubagentSessionInfo,
4
+ SubagentSessionRegistry,
5
+ } from "#src/subagent-registry";
6
+
7
+ function makeInfo(
8
+ overrides: Partial<SubagentSessionInfo> = {},
9
+ ): SubagentSessionInfo {
10
+ return {
11
+ agentName: "Explore",
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe("SubagentSessionRegistry", () => {
17
+ test("has() returns false for an unregistered key", () => {
18
+ const registry = new SubagentSessionRegistry();
19
+ expect(registry.has("/sessions/task-abc")).toBe(false);
20
+ });
21
+
22
+ test("get() returns undefined for an unregistered key", () => {
23
+ const registry = new SubagentSessionRegistry();
24
+ expect(registry.get("/sessions/task-abc")).toBeUndefined();
25
+ });
26
+
27
+ test("has() returns true after register()", () => {
28
+ const registry = new SubagentSessionRegistry();
29
+ registry.register("/sessions/task-abc", makeInfo());
30
+ expect(registry.has("/sessions/task-abc")).toBe(true);
31
+ });
32
+
33
+ test("get() returns the registered info after register()", () => {
34
+ const registry = new SubagentSessionRegistry();
35
+ const info = makeInfo({ parentSessionId: "parent-123" });
36
+ registry.register("/sessions/task-abc", info);
37
+ expect(registry.get("/sessions/task-abc")).toEqual(info);
38
+ });
39
+
40
+ test("register() stores agentName without parentSessionId", () => {
41
+ const registry = new SubagentSessionRegistry();
42
+ registry.register("/sessions/task-abc", makeInfo());
43
+ expect(registry.get("/sessions/task-abc")).toEqual({
44
+ agentName: "Explore",
45
+ });
46
+ });
47
+
48
+ test("has() returns false after unregister()", () => {
49
+ const registry = new SubagentSessionRegistry();
50
+ registry.register("/sessions/task-abc", makeInfo());
51
+ registry.unregister("/sessions/task-abc");
52
+ expect(registry.has("/sessions/task-abc")).toBe(false);
53
+ });
54
+
55
+ test("get() returns undefined after unregister()", () => {
56
+ const registry = new SubagentSessionRegistry();
57
+ registry.register("/sessions/task-abc", makeInfo());
58
+ registry.unregister("/sessions/task-abc");
59
+ expect(registry.get("/sessions/task-abc")).toBeUndefined();
60
+ });
61
+
62
+ test("unregister() is a no-op for an unknown key", () => {
63
+ const registry = new SubagentSessionRegistry();
64
+ expect(() => registry.unregister("/sessions/nonexistent")).not.toThrow();
65
+ });
66
+
67
+ test("register() overwrites a previous entry for the same key", () => {
68
+ const registry = new SubagentSessionRegistry();
69
+ registry.register(
70
+ "/sessions/task-abc",
71
+ makeInfo({ parentSessionId: "parent-1" }),
72
+ );
73
+ registry.register(
74
+ "/sessions/task-abc",
75
+ makeInfo({ parentSessionId: "parent-2" }),
76
+ );
77
+ expect(registry.get("/sessions/task-abc")?.parentSessionId).toBe(
78
+ "parent-2",
79
+ );
80
+ });
81
+
82
+ test("multiple keys are independent", () => {
83
+ const registry = new SubagentSessionRegistry();
84
+ registry.register("/sessions/task-1", makeInfo({ agentName: "Explore" }));
85
+ registry.register("/sessions/task-2", makeInfo({ agentName: "Plan" }));
86
+
87
+ expect(registry.get("/sessions/task-1")?.agentName).toBe("Explore");
88
+ expect(registry.get("/sessions/task-2")?.agentName).toBe("Plan");
89
+
90
+ registry.unregister("/sessions/task-1");
91
+ expect(registry.has("/sessions/task-1")).toBe(false);
92
+ expect(registry.has("/sessions/task-2")).toBe(true);
93
+ });
94
+ });