@gotgenes/pi-permission-system 10.0.0 → 10.1.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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +49 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-prompter.ts +7 -58
  26. package/src/permission-resolver.ts +17 -0
  27. package/src/permission-session.ts +77 -9
  28. package/src/permissions-service.ts +53 -0
  29. package/src/service-lifecycle.ts +49 -0
  30. package/src/session-approval-recorder.ts +6 -0
  31. package/src/session-lifecycle-session.ts +24 -0
  32. package/src/tool-input-preview.ts +0 -62
  33. package/src/tool-input-prompt-formatters.ts +63 -0
  34. package/src/tool-preview-formatter.ts +6 -4
  35. package/test/decision-reporter.test.ts +112 -0
  36. package/test/denial-messages.test.ts +62 -0
  37. package/test/forwarding-manager.test.ts +26 -44
  38. package/test/handlers/before-agent-start.test.ts +45 -21
  39. package/test/handlers/external-directory-integration.test.ts +86 -22
  40. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  41. package/test/handlers/gates/bash-command.test.ts +49 -90
  42. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  43. package/test/handlers/gates/bash-path.test.ts +63 -148
  44. package/test/handlers/gates/path.test.ts +38 -105
  45. package/test/handlers/gates/runner.test.ts +150 -93
  46. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  47. package/test/handlers/gates/skill-input.test.ts +128 -0
  48. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  49. package/test/handlers/input.test.ts +1 -2
  50. package/test/handlers/lifecycle.test.ts +49 -33
  51. package/test/handlers/tool-call-events.test.ts +1 -1
  52. package/test/helpers/gate-fixtures.ts +147 -16
  53. package/test/helpers/handler-fixtures.ts +143 -27
  54. package/test/mcp-targets.test.ts +55 -0
  55. package/test/permission-forwarder.test.ts +295 -0
  56. package/test/permission-forwarding.test.ts +0 -282
  57. package/test/permission-prompter.test.ts +33 -44
  58. package/test/permission-session.test.ts +160 -27
  59. package/test/permissions-service.test.ts +151 -0
  60. package/test/runtime.test.ts +0 -4
  61. package/test/service-lifecycle.test.ts +162 -0
  62. package/test/tool-input-preview.test.ts +0 -111
  63. package/test/tool-input-prompt-formatters.test.ts +115 -0
  64. package/src/forwarded-permissions/polling.ts +0 -411
@@ -1,411 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
-
5
- import {
6
- getActiveAgentName,
7
- getActiveAgentNameFromSystemPrompt,
8
- } from "#src/active-agent";
9
- import { toRecord } from "#src/common";
10
- import type {
11
- PermissionPromptDecision,
12
- RequestPermissionOptions,
13
- } from "#src/permission-dialog";
14
- import {
15
- emitUiPromptEvent,
16
- type PermissionEventBus,
17
- } from "#src/permission-events";
18
- import {
19
- type ForwardedPermissionRequest,
20
- type ForwardedPermissionResponse,
21
- type ForwardedPromptDisplay,
22
- isForwardedPermissionRequestForSession,
23
- PERMISSION_FORWARDING_POLL_INTERVAL_MS,
24
- PERMISSION_FORWARDING_TIMEOUT_MS,
25
- resolvePermissionForwardingTargetSessionId,
26
- SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
27
- } from "#src/permission-forwarding";
28
- import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
29
- import { isSubagentExecutionContext } from "#src/subagent-context";
30
- import type { SubagentSessionRegistry } from "#src/subagent-registry";
31
-
32
- import {
33
- cleanupPermissionForwardingLocationIfEmpty,
34
- ensurePermissionForwardingLocation,
35
- type ForwardedPermissionLogger,
36
- getExistingPermissionForwardingLocation,
37
- listRequestFiles,
38
- logPermissionForwardingError,
39
- logPermissionForwardingWarning,
40
- readForwardedPermissionRequest,
41
- readForwardedPermissionResponse,
42
- safeDeleteFile,
43
- sleep,
44
- writeJsonFileAtomic,
45
- } from "./io";
46
-
47
- export interface PermissionForwardingDeps {
48
- forwardingDir: string;
49
- subagentSessionsDir: string;
50
- /** In-process subagent session registry for detection and forwarding target resolution. */
51
- registry?: SubagentSessionRegistry;
52
- /** Event bus used for UI prompt broadcasts. */
53
- events?: PermissionEventBus;
54
- logger: ForwardedPermissionLogger;
55
- writeReviewLog: (event: string, details: Record<string, unknown>) => void;
56
- requestPermissionDecisionFromUi: (
57
- ui: ExtensionContext["ui"],
58
- title: string,
59
- message: string,
60
- options?: RequestPermissionOptions,
61
- ) => Promise<PermissionPromptDecision>;
62
- shouldAutoApprove: () => boolean;
63
- }
64
-
65
- export function getSessionId(ctx: ExtensionContext): string {
66
- try {
67
- const sessionId = ctx.sessionManager.getSessionId();
68
- if (typeof sessionId === "string" && sessionId.trim()) {
69
- return sessionId.trim();
70
- }
71
- } catch {}
72
-
73
- return "unknown";
74
- }
75
-
76
- function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
77
- const getSystemPrompt = toRecord(ctx).getSystemPrompt;
78
- if (typeof getSystemPrompt !== "function") {
79
- return undefined;
80
- }
81
-
82
- try {
83
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
84
- const systemPrompt = getSystemPrompt.call(ctx);
85
- return typeof systemPrompt === "string" ? systemPrompt : undefined;
86
- } catch (error) {
87
- // No deps available in this helper — warning silently dropped.
88
- logPermissionForwardingWarning(
89
- null,
90
- "Failed to read context system prompt for forwarded permission metadata",
91
- error,
92
- );
93
- return undefined;
94
- }
95
- }
96
-
97
- export function formatForwardedPermissionPrompt(
98
- request: ForwardedPermissionRequest,
99
- ): string {
100
- const agentName = request.requesterAgentName || "unknown";
101
- const sessionId = request.requesterSessionId || "unknown";
102
- return [
103
- `Subagent '${agentName}' requested permission.`,
104
- `Session ID: ${sessionId}`,
105
- "",
106
- request.message,
107
- ].join("\n");
108
- }
109
-
110
- export async function waitForForwardedPermissionApproval(
111
- ctx: ExtensionContext,
112
- message: string,
113
- deps: PermissionForwardingDeps,
114
- forwarded?: ForwardedPromptDisplay,
115
- ): Promise<PermissionPromptDecision> {
116
- const requesterSessionId = getSessionId(ctx);
117
- const targetSessionId = resolvePermissionForwardingTargetSessionId({
118
- hasUI: ctx.hasUI,
119
- isSubagent: isSubagentExecutionContext(
120
- ctx,
121
- deps.subagentSessionsDir,
122
- deps.registry,
123
- ),
124
- currentSessionId: requesterSessionId,
125
- env: process.env,
126
- sessionId: requesterSessionId,
127
- registry: deps.registry,
128
- });
129
-
130
- if (!targetSessionId) {
131
- logPermissionForwardingError(
132
- deps.logger,
133
- `Permission forwarding target session could not be resolved. ` +
134
- `Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
135
- `If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
136
- `ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
137
- `(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
138
- );
139
- return { approved: false, state: "denied" };
140
- }
141
-
142
- const location = ensurePermissionForwardingLocation(
143
- deps.logger,
144
- deps.forwardingDir,
145
- targetSessionId,
146
- );
147
- if (!location) {
148
- logPermissionForwardingError(
149
- deps.logger,
150
- `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
151
- );
152
- return { approved: false, state: "denied" };
153
- }
154
-
155
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
156
- const requesterAgentName =
157
- getActiveAgentName(ctx) ??
158
- getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
159
- "unknown";
160
- const request: ForwardedPermissionRequest = {
161
- id: requestId,
162
- createdAt: Date.now(),
163
- requesterSessionId,
164
- targetSessionId,
165
- requesterAgentName,
166
- message,
167
- ...(forwarded
168
- ? {
169
- source: forwarded.source,
170
- surface: forwarded.surface,
171
- value: forwarded.value,
172
- }
173
- : {}),
174
- };
175
-
176
- const requestPath = join(location.requestsDir, `${requestId}.json`);
177
- const responsePath = join(location.responsesDir, `${requestId}.json`);
178
-
179
- deps.writeReviewLog("forwarded_permission.request_created", {
180
- requestId,
181
- requesterAgentName,
182
- requesterSessionId: request.requesterSessionId,
183
- targetSessionId,
184
- requestPath,
185
- responsePath,
186
- });
187
-
188
- try {
189
- writeJsonFileAtomic(deps.logger, requestPath, request);
190
- } catch (error) {
191
- logPermissionForwardingError(
192
- deps.logger,
193
- `Failed to write forwarded permission request '${requestPath}'`,
194
- error,
195
- );
196
- return { approved: false, state: "denied" };
197
- }
198
-
199
- const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
200
- while (Date.now() < deadline) {
201
- if (existsSync(responsePath)) {
202
- const response = readForwardedPermissionResponse(
203
- deps.logger,
204
- responsePath,
205
- );
206
- deps.writeReviewLog("forwarded_permission.response_received", {
207
- requestId,
208
- approved: response?.approved ?? null,
209
- state: response?.state ?? null,
210
- denialReason: response?.denialReason ?? null,
211
- responderSessionId: response?.responderSessionId ?? null,
212
- targetSessionId,
213
- responsePath,
214
- });
215
- safeDeleteFile(
216
- deps.logger,
217
- responsePath,
218
- "forwarded permission response",
219
- );
220
- safeDeleteFile(deps.logger, requestPath, "forwarded permission request");
221
- cleanupPermissionForwardingLocationIfEmpty(deps.logger, location);
222
- return response ?? { approved: false, state: "denied" };
223
- }
224
-
225
- await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
226
- }
227
-
228
- logPermissionForwardingWarning(
229
- deps.logger,
230
- `Timed out waiting for forwarded permission response '${responsePath}'`,
231
- );
232
- deps.writeReviewLog("forwarded_permission.response_timed_out", {
233
- requestId,
234
- requesterAgentName,
235
- targetSessionId,
236
- responsePath,
237
- });
238
- safeDeleteFile(deps.logger, requestPath, "forwarded permission request");
239
- cleanupPermissionForwardingLocationIfEmpty(deps.logger, location);
240
- return { approved: false, state: "denied" };
241
- }
242
-
243
- export async function processForwardedPermissionRequests(
244
- ctx: ExtensionContext,
245
- deps: PermissionForwardingDeps,
246
- ): Promise<void> {
247
- if (!ctx.hasUI) {
248
- return;
249
- }
250
-
251
- const currentSessionId = getSessionId(ctx);
252
- const location = getExistingPermissionForwardingLocation(
253
- deps.forwardingDir,
254
- currentSessionId,
255
- );
256
- if (!location) {
257
- return;
258
- }
259
-
260
- const requestFiles = listRequestFiles(deps.logger, location.requestsDir);
261
- if (requestFiles.length === 0) {
262
- return;
263
- }
264
-
265
- for (const fileName of requestFiles) {
266
- const requestPath = join(location.requestsDir, fileName);
267
- const request = readForwardedPermissionRequest(deps.logger, requestPath);
268
- if (!request) {
269
- safeDeleteFile(
270
- deps.logger,
271
- requestPath,
272
- `${location.label} forwarded permission request`,
273
- );
274
- continue;
275
- }
276
-
277
- if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
278
- logPermissionForwardingWarning(
279
- deps.logger,
280
- `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
281
- );
282
- safeDeleteFile(
283
- deps.logger,
284
- requestPath,
285
- `${location.label} forwarded permission request`,
286
- );
287
- continue;
288
- }
289
-
290
- const forwardedPermissionLogDetails = {
291
- requestId: request.id,
292
- source: location.label,
293
- requesterAgentName: request.requesterAgentName,
294
- requesterSessionId: request.requesterSessionId,
295
- targetSessionId: request.targetSessionId,
296
- requestPath,
297
- };
298
-
299
- let decision: PermissionPromptDecision = {
300
- approved: false,
301
- state: "denied",
302
- };
303
- if (deps.shouldAutoApprove()) {
304
- deps.writeReviewLog(
305
- "forwarded_permission.auto_approved",
306
- forwardedPermissionLogDetails,
307
- );
308
- decision = { approved: true, state: "approved" };
309
- } else {
310
- deps.writeReviewLog(
311
- "forwarded_permission.prompted",
312
- forwardedPermissionLogDetails,
313
- );
314
- try {
315
- const forwardedMessage = formatForwardedPermissionPrompt(request);
316
- if (deps.events) {
317
- emitUiPromptEvent(
318
- deps.events,
319
- buildForwardedUiPrompt({
320
- requestId: request.id,
321
- message: forwardedMessage,
322
- requesterAgentName: request.requesterAgentName || null,
323
- requesterSessionId: request.requesterSessionId || null,
324
- source: request.source ?? null,
325
- surface: request.surface ?? null,
326
- value: request.value ?? null,
327
- }),
328
- );
329
- }
330
- decision = await deps.requestPermissionDecisionFromUi(
331
- ctx.ui,
332
- "Permission Required (Subagent)",
333
- forwardedMessage,
334
- );
335
- } catch (error) {
336
- logPermissionForwardingError(
337
- deps.logger,
338
- "Failed to show forwarded permission confirmation dialog",
339
- error,
340
- );
341
- decision = { approved: false, state: "denied" };
342
- }
343
- }
344
-
345
- const responsePath = join(location.responsesDir, `${request.id}.json`);
346
- deps.writeReviewLog(
347
- decision.approved
348
- ? "forwarded_permission.approved"
349
- : "forwarded_permission.denied",
350
- {
351
- requestId: request.id,
352
- source: location.label,
353
- requesterAgentName: request.requesterAgentName,
354
- requesterSessionId: request.requesterSessionId,
355
- targetSessionId: request.targetSessionId,
356
- responsePath,
357
- resolution: decision.state,
358
- denialReason: decision.denialReason ?? null,
359
- },
360
- );
361
- try {
362
- writeJsonFileAtomic(deps.logger, responsePath, {
363
- approved: decision.approved,
364
- state: decision.state,
365
- denialReason: decision.denialReason,
366
- responderSessionId: currentSessionId,
367
- respondedAt: Date.now(),
368
- } satisfies ForwardedPermissionResponse);
369
- } catch (error) {
370
- logPermissionForwardingError(
371
- deps.logger,
372
- `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
373
- error,
374
- );
375
- continue;
376
- }
377
-
378
- safeDeleteFile(
379
- deps.logger,
380
- requestPath,
381
- `${location.label} forwarded permission request`,
382
- );
383
- }
384
-
385
- cleanupPermissionForwardingLocationIfEmpty(deps.logger, location);
386
- }
387
-
388
- export async function confirmPermission(
389
- ctx: ExtensionContext,
390
- message: string,
391
- deps: PermissionForwardingDeps,
392
- options?: RequestPermissionOptions,
393
- forwarded?: ForwardedPromptDisplay,
394
- ): Promise<PermissionPromptDecision> {
395
- if (ctx.hasUI) {
396
- return deps.requestPermissionDecisionFromUi(
397
- ctx.ui,
398
- "Permission Required",
399
- message,
400
- options,
401
- );
402
- }
403
-
404
- if (
405
- !isSubagentExecutionContext(ctx, deps.subagentSessionsDir, deps.registry)
406
- ) {
407
- return { approved: false, state: "denied" };
408
- }
409
-
410
- return waitForForwardedPermissionApproval(ctx, message, deps, forwarded);
411
- }