@gotgenes/pi-permission-system 10.0.0 → 10.2.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 (68) hide show
  1. package/CHANGELOG.md +33 -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 +53 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-manager.ts +69 -3
  26. package/src/permission-prompter.ts +7 -58
  27. package/src/permission-resolver.ts +17 -0
  28. package/src/permission-session.ts +83 -27
  29. package/src/permissions-service.ts +53 -0
  30. package/src/runtime.ts +1 -37
  31. package/src/service-lifecycle.ts +49 -0
  32. package/src/session-approval-recorder.ts +6 -0
  33. package/src/session-lifecycle-session.ts +24 -0
  34. package/src/tool-input-preview.ts +0 -62
  35. package/src/tool-input-prompt-formatters.ts +63 -0
  36. package/src/tool-preview-formatter.ts +6 -4
  37. package/test/decision-reporter.test.ts +112 -0
  38. package/test/denial-messages.test.ts +62 -0
  39. package/test/forwarding-manager.test.ts +26 -44
  40. package/test/handlers/before-agent-start.test.ts +45 -21
  41. package/test/handlers/external-directory-integration.test.ts +83 -114
  42. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  43. package/test/handlers/gates/bash-command.test.ts +49 -90
  44. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  45. package/test/handlers/gates/bash-path.test.ts +54 -157
  46. package/test/handlers/gates/path.test.ts +38 -105
  47. package/test/handlers/gates/runner.test.ts +151 -186
  48. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  49. package/test/handlers/gates/skill-input.test.ts +128 -0
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  51. package/test/handlers/input.test.ts +1 -2
  52. package/test/handlers/lifecycle.test.ts +49 -33
  53. package/test/handlers/tool-call-events.test.ts +1 -1
  54. package/test/handlers/tool-call.test.ts +44 -153
  55. package/test/helpers/gate-fixtures.ts +212 -17
  56. package/test/helpers/handler-fixtures.ts +226 -29
  57. package/test/mcp-targets.test.ts +55 -0
  58. package/test/permission-forwarder.test.ts +295 -0
  59. package/test/permission-forwarding.test.ts +0 -282
  60. package/test/permission-manager-unified.test.ts +159 -1
  61. package/test/permission-prompter.test.ts +33 -44
  62. package/test/permission-session.test.ts +211 -105
  63. package/test/permissions-service.test.ts +151 -0
  64. package/test/runtime.test.ts +2 -86
  65. package/test/service-lifecycle.test.ts +162 -0
  66. package/test/tool-input-preview.test.ts +0 -111
  67. package/test/tool-input-prompt-formatters.test.ts +115 -0
  68. package/src/forwarded-permissions/polling.ts +0 -411
@@ -0,0 +1,549 @@
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
+ type PermissionForwardingLocation,
26
+ resolvePermissionForwardingTargetSessionId,
27
+ SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
28
+ } from "#src/permission-forwarding";
29
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
30
+ import { isSubagentExecutionContext } from "#src/subagent-context";
31
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
32
+
33
+ import {
34
+ cleanupPermissionForwardingLocationIfEmpty,
35
+ ensurePermissionForwardingLocation,
36
+ type ForwardedPermissionLogger,
37
+ getExistingPermissionForwardingLocation,
38
+ listRequestFiles,
39
+ logPermissionForwardingError,
40
+ logPermissionForwardingWarning,
41
+ readForwardedPermissionRequest,
42
+ readForwardedPermissionResponse,
43
+ safeDeleteFile,
44
+ sleep,
45
+ writeJsonFileAtomic,
46
+ } from "./io";
47
+
48
+ /**
49
+ * Constructor config for `PermissionForwarder`.
50
+ *
51
+ * Replaces the `PermissionForwardingDeps` interface that was previously
52
+ * threaded into free functions in `polling.ts`. The forwarder consumes it
53
+ * once at construction and stores each member as a private readonly field.
54
+ */
55
+ export interface PermissionForwarderDeps {
56
+ forwardingDir: string;
57
+ subagentSessionsDir: string;
58
+ /** In-process subagent session registry for detection and forwarding target resolution. */
59
+ registry?: SubagentSessionRegistry;
60
+ /** Event bus used for UI prompt broadcasts. */
61
+ events?: PermissionEventBus;
62
+ logger: ForwardedPermissionLogger;
63
+ writeReviewLog: (event: string, details: Record<string, unknown>) => void;
64
+ requestPermissionDecisionFromUi: (
65
+ ui: ExtensionContext["ui"],
66
+ title: string,
67
+ message: string,
68
+ options?: RequestPermissionOptions,
69
+ ) => Promise<PermissionPromptDecision>;
70
+ shouldAutoApprove: () => boolean;
71
+ }
72
+
73
+ // ── Module-private helpers ────────────────────────────────────────────────
74
+
75
+ function getSessionId(ctx: ExtensionContext): string {
76
+ try {
77
+ const sessionId = ctx.sessionManager.getSessionId();
78
+ if (typeof sessionId === "string" && sessionId.trim()) {
79
+ return sessionId.trim();
80
+ }
81
+ } catch {}
82
+
83
+ return "unknown";
84
+ }
85
+
86
+ function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
87
+ const getSystemPrompt = toRecord(ctx).getSystemPrompt;
88
+ if (typeof getSystemPrompt !== "function") {
89
+ return undefined;
90
+ }
91
+
92
+ try {
93
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
94
+ const systemPrompt = getSystemPrompt.call(ctx);
95
+ return typeof systemPrompt === "string" ? systemPrompt : undefined;
96
+ } catch (error) {
97
+ // No deps available in this helper — warning silently dropped.
98
+ logPermissionForwardingWarning(
99
+ null,
100
+ "Failed to read context system prompt for forwarded permission metadata",
101
+ error,
102
+ );
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ function formatForwardedPermissionPrompt(
108
+ request: ForwardedPermissionRequest,
109
+ ): string {
110
+ const agentName = request.requesterAgentName || "unknown";
111
+ const sessionId = request.requesterSessionId || "unknown";
112
+ return [
113
+ `Subagent '${agentName}' requested permission.`,
114
+ `Session ID: ${sessionId}`,
115
+ "",
116
+ request.message,
117
+ ].join("\n");
118
+ }
119
+
120
+ // ── Public seam interfaces ────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Narrow seam describing what `PermissionPrompter` needs from the forwarder:
124
+ * a single method that resolves a permission decision for the current context
125
+ * (prompt directly when the session has UI, otherwise forward to the parent).
126
+ *
127
+ * Depending on the interface (not the concrete `PermissionForwarder`) keeps
128
+ * the prompter's unit tests free of casts — they inject a plain
129
+ * `{ requestApproval: vi.fn() }` mock.
130
+ */
131
+ export interface ApprovalRequester {
132
+ requestApproval(
133
+ ctx: ExtensionContext,
134
+ message: string,
135
+ options?: RequestPermissionOptions,
136
+ forwarded?: ForwardedPromptDisplay,
137
+ ): Promise<PermissionPromptDecision>;
138
+ }
139
+
140
+ /**
141
+ * Narrow seam describing what `ForwardingManager` needs from the forwarder:
142
+ * a single method that drains this session's forwarded-permission inbox.
143
+ *
144
+ * Depending on the interface (not the concrete `PermissionForwarder`) keeps
145
+ * the manager's unit tests free of casts — they inject a plain
146
+ * `{ processInbox: vi.fn() }` mock.
147
+ */
148
+ export interface InboxProcessor {
149
+ processInbox(ctx: ExtensionContext): Promise<void>;
150
+ }
151
+
152
+ // ── PermissionForwarder ───────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Owner of the forwarded-permission behavior.
156
+ *
157
+ * Holds all forwarding state as private readonly fields and provides two
158
+ * public methods (`requestApproval`, `processInbox`) that together encapsulate
159
+ * the full forwarding lifecycle: deciding whether to prompt directly or
160
+ * forward to the parent, building and persisting request files, polling for
161
+ * responses, and processing the parent-session inbox.
162
+ */
163
+ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
164
+ private readonly forwardingDir: string;
165
+ private readonly subagentSessionsDir: string;
166
+ private readonly registry: SubagentSessionRegistry | undefined;
167
+ private readonly events: PermissionEventBus | undefined;
168
+ private readonly logger: ForwardedPermissionLogger;
169
+ private readonly writeReviewLog: (
170
+ event: string,
171
+ details: Record<string, unknown>,
172
+ ) => void;
173
+ private readonly requestPermissionDecisionFromUi: (
174
+ ui: ExtensionContext["ui"],
175
+ title: string,
176
+ message: string,
177
+ options?: RequestPermissionOptions,
178
+ ) => Promise<PermissionPromptDecision>;
179
+ private readonly shouldAutoApprove: () => boolean;
180
+
181
+ constructor(deps: PermissionForwarderDeps) {
182
+ this.forwardingDir = deps.forwardingDir;
183
+ this.subagentSessionsDir = deps.subagentSessionsDir;
184
+ this.registry = deps.registry;
185
+ this.events = deps.events;
186
+ this.logger = deps.logger;
187
+ this.writeReviewLog = deps.writeReviewLog;
188
+ this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
189
+ this.shouldAutoApprove = deps.shouldAutoApprove;
190
+ }
191
+
192
+ // ── Public seam methods ────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Resolve a permission decision for the current context: prompt directly
196
+ * when this session has UI, otherwise forward to the parent session.
197
+ */
198
+ requestApproval(
199
+ ctx: ExtensionContext,
200
+ message: string,
201
+ options?: RequestPermissionOptions,
202
+ forwarded?: ForwardedPromptDisplay,
203
+ ): Promise<PermissionPromptDecision> {
204
+ if (ctx.hasUI) {
205
+ return this.requestPermissionDecisionFromUi(
206
+ ctx.ui,
207
+ "Permission Required",
208
+ message,
209
+ options,
210
+ );
211
+ }
212
+
213
+ if (
214
+ !isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
215
+ ) {
216
+ return Promise.resolve({ approved: false, state: "denied" });
217
+ }
218
+
219
+ return this.waitForForwardedApproval(ctx, message, forwarded);
220
+ }
221
+
222
+ /** Drain and respond to this session's forwarded-permission inbox. */
223
+ async processInbox(ctx: ExtensionContext): Promise<void> {
224
+ if (!ctx.hasUI) {
225
+ return;
226
+ }
227
+
228
+ const currentSessionId = getSessionId(ctx);
229
+ const location = getExistingPermissionForwardingLocation(
230
+ this.forwardingDir,
231
+ currentSessionId,
232
+ );
233
+ if (!location) {
234
+ return;
235
+ }
236
+
237
+ const requestFiles = listRequestFiles(this.logger, location.requestsDir);
238
+ if (requestFiles.length === 0) {
239
+ return;
240
+ }
241
+
242
+ for (const fileName of requestFiles) {
243
+ const requestPath = join(location.requestsDir, fileName);
244
+ const request = readForwardedPermissionRequest(this.logger, requestPath);
245
+ if (!request) {
246
+ safeDeleteFile(
247
+ this.logger,
248
+ requestPath,
249
+ `${location.label} forwarded permission request`,
250
+ );
251
+ continue;
252
+ }
253
+
254
+ await this.processSingleForwardedRequest(
255
+ ctx,
256
+ request,
257
+ location,
258
+ requestPath,
259
+ currentSessionId,
260
+ );
261
+ }
262
+
263
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
264
+ }
265
+
266
+ // ── Private methods ────────────────────────────────────────────────────
267
+
268
+ private async waitForForwardedApproval(
269
+ ctx: ExtensionContext,
270
+ message: string,
271
+ forwarded?: ForwardedPromptDisplay,
272
+ ): Promise<PermissionPromptDecision> {
273
+ const requesterSessionId = getSessionId(ctx);
274
+ const targetSessionId = resolvePermissionForwardingTargetSessionId({
275
+ hasUI: ctx.hasUI,
276
+ isSubagent: isSubagentExecutionContext(
277
+ ctx,
278
+ this.subagentSessionsDir,
279
+ this.registry,
280
+ ),
281
+ currentSessionId: requesterSessionId,
282
+ env: process.env,
283
+ sessionId: requesterSessionId,
284
+ registry: this.registry,
285
+ });
286
+
287
+ if (!targetSessionId) {
288
+ logPermissionForwardingError(
289
+ this.logger,
290
+ `Permission forwarding target session could not be resolved. ` +
291
+ `Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
292
+ `If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
293
+ `ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
294
+ `(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
295
+ );
296
+ return { approved: false, state: "denied" };
297
+ }
298
+
299
+ const location = ensurePermissionForwardingLocation(
300
+ this.logger,
301
+ this.forwardingDir,
302
+ targetSessionId,
303
+ );
304
+ if (!location) {
305
+ logPermissionForwardingError(
306
+ this.logger,
307
+ `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
308
+ );
309
+ return { approved: false, state: "denied" };
310
+ }
311
+
312
+ const request = this.buildForwardedRequest(
313
+ ctx,
314
+ message,
315
+ requesterSessionId,
316
+ targetSessionId,
317
+ forwarded,
318
+ );
319
+ const requestPath = join(location.requestsDir, `${request.id}.json`);
320
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
321
+
322
+ this.writeReviewLog("forwarded_permission.request_created", {
323
+ requestId: request.id,
324
+ requesterAgentName: request.requesterAgentName,
325
+ requesterSessionId: request.requesterSessionId,
326
+ targetSessionId,
327
+ requestPath,
328
+ responsePath,
329
+ });
330
+
331
+ try {
332
+ writeJsonFileAtomic(this.logger, requestPath, request);
333
+ } catch (error) {
334
+ logPermissionForwardingError(
335
+ this.logger,
336
+ `Failed to write forwarded permission request '${requestPath}'`,
337
+ error,
338
+ );
339
+ return { approved: false, state: "denied" };
340
+ }
341
+
342
+ return this.pollForForwardedResponse(
343
+ location,
344
+ request,
345
+ requestPath,
346
+ responsePath,
347
+ );
348
+ }
349
+
350
+ private buildForwardedRequest(
351
+ ctx: ExtensionContext,
352
+ message: string,
353
+ requesterSessionId: string,
354
+ targetSessionId: string,
355
+ forwarded?: ForwardedPromptDisplay,
356
+ ): ForwardedPermissionRequest {
357
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
358
+ const requesterAgentName =
359
+ getActiveAgentName(ctx) ??
360
+ getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
361
+ "unknown";
362
+ return {
363
+ id: requestId,
364
+ createdAt: Date.now(),
365
+ requesterSessionId,
366
+ targetSessionId,
367
+ requesterAgentName,
368
+ message,
369
+ ...(forwarded
370
+ ? {
371
+ source: forwarded.source,
372
+ surface: forwarded.surface,
373
+ value: forwarded.value,
374
+ }
375
+ : {}),
376
+ };
377
+ }
378
+
379
+ private async pollForForwardedResponse(
380
+ location: PermissionForwardingLocation,
381
+ request: ForwardedPermissionRequest,
382
+ requestPath: string,
383
+ responsePath: string,
384
+ ): Promise<PermissionPromptDecision> {
385
+ const { id: requestId, requesterAgentName, targetSessionId } = request;
386
+ const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
387
+
388
+ while (Date.now() < deadline) {
389
+ if (existsSync(responsePath)) {
390
+ const response = readForwardedPermissionResponse(
391
+ this.logger,
392
+ responsePath,
393
+ );
394
+ this.writeReviewLog("forwarded_permission.response_received", {
395
+ requestId,
396
+ approved: response?.approved ?? null,
397
+ state: response?.state ?? null,
398
+ denialReason: response?.denialReason ?? null,
399
+ responderSessionId: response?.responderSessionId ?? null,
400
+ targetSessionId,
401
+ responsePath,
402
+ });
403
+ safeDeleteFile(
404
+ this.logger,
405
+ responsePath,
406
+ "forwarded permission response",
407
+ );
408
+ safeDeleteFile(
409
+ this.logger,
410
+ requestPath,
411
+ "forwarded permission request",
412
+ );
413
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
414
+ return response ?? { approved: false, state: "denied" };
415
+ }
416
+
417
+ await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
418
+ }
419
+
420
+ logPermissionForwardingWarning(
421
+ this.logger,
422
+ `Timed out waiting for forwarded permission response '${responsePath}'`,
423
+ );
424
+ this.writeReviewLog("forwarded_permission.response_timed_out", {
425
+ requestId,
426
+ requesterAgentName,
427
+ targetSessionId,
428
+ responsePath,
429
+ });
430
+ safeDeleteFile(this.logger, requestPath, "forwarded permission request");
431
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
432
+ return { approved: false, state: "denied" };
433
+ }
434
+
435
+ private async processSingleForwardedRequest(
436
+ ctx: ExtensionContext,
437
+ request: ForwardedPermissionRequest,
438
+ location: PermissionForwardingLocation,
439
+ requestPath: string,
440
+ currentSessionId: string,
441
+ ): Promise<void> {
442
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
443
+ logPermissionForwardingWarning(
444
+ this.logger,
445
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
446
+ );
447
+ safeDeleteFile(
448
+ this.logger,
449
+ requestPath,
450
+ `${location.label} forwarded permission request`,
451
+ );
452
+ return;
453
+ }
454
+
455
+ const forwardedPermissionLogDetails = {
456
+ requestId: request.id,
457
+ source: location.label,
458
+ requesterAgentName: request.requesterAgentName,
459
+ requesterSessionId: request.requesterSessionId,
460
+ targetSessionId: request.targetSessionId,
461
+ requestPath,
462
+ };
463
+
464
+ let decision: PermissionPromptDecision = {
465
+ approved: false,
466
+ state: "denied",
467
+ };
468
+ if (this.shouldAutoApprove()) {
469
+ this.writeReviewLog(
470
+ "forwarded_permission.auto_approved",
471
+ forwardedPermissionLogDetails,
472
+ );
473
+ decision = { approved: true, state: "approved" };
474
+ } else {
475
+ this.writeReviewLog(
476
+ "forwarded_permission.prompted",
477
+ forwardedPermissionLogDetails,
478
+ );
479
+ try {
480
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
481
+ if (this.events) {
482
+ emitUiPromptEvent(
483
+ this.events,
484
+ buildForwardedUiPrompt({
485
+ requestId: request.id,
486
+ message: forwardedMessage,
487
+ requesterAgentName: request.requesterAgentName || null,
488
+ requesterSessionId: request.requesterSessionId || null,
489
+ source: request.source ?? null,
490
+ surface: request.surface ?? null,
491
+ value: request.value ?? null,
492
+ }),
493
+ );
494
+ }
495
+ decision = await this.requestPermissionDecisionFromUi(
496
+ ctx.ui,
497
+ "Permission Required (Subagent)",
498
+ forwardedMessage,
499
+ );
500
+ } catch (error) {
501
+ logPermissionForwardingError(
502
+ this.logger,
503
+ "Failed to show forwarded permission confirmation dialog",
504
+ error,
505
+ );
506
+ decision = { approved: false, state: "denied" };
507
+ }
508
+ }
509
+
510
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
511
+ this.writeReviewLog(
512
+ decision.approved
513
+ ? "forwarded_permission.approved"
514
+ : "forwarded_permission.denied",
515
+ {
516
+ requestId: request.id,
517
+ source: location.label,
518
+ requesterAgentName: request.requesterAgentName,
519
+ requesterSessionId: request.requesterSessionId,
520
+ targetSessionId: request.targetSessionId,
521
+ responsePath,
522
+ resolution: decision.state,
523
+ denialReason: decision.denialReason ?? null,
524
+ },
525
+ );
526
+ try {
527
+ writeJsonFileAtomic(this.logger, responsePath, {
528
+ approved: decision.approved,
529
+ state: decision.state,
530
+ denialReason: decision.denialReason,
531
+ responderSessionId: currentSessionId,
532
+ respondedAt: Date.now(),
533
+ } satisfies ForwardedPermissionResponse);
534
+ } catch (error) {
535
+ logPermissionForwardingError(
536
+ this.logger,
537
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
538
+ error,
539
+ );
540
+ return;
541
+ }
542
+
543
+ safeDeleteFile(
544
+ this.logger,
545
+ requestPath,
546
+ `${location.label} forwarded permission request`,
547
+ );
548
+ }
549
+ }
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
4
- import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
3
+ import type { InboxProcessor } from "./forwarded-permissions/permission-forwarder";
5
4
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
6
5
  import { isSubagentExecutionContext } from "./subagent-context";
7
6
  import type { SubagentSessionRegistry } from "./subagent-registry";
@@ -30,7 +29,7 @@ export class ForwardingManager {
30
29
 
31
30
  constructor(
32
31
  private readonly subagentSessionsDir: string,
33
- private readonly forwardingDeps: PermissionForwardingDeps,
32
+ private readonly forwarder: InboxProcessor,
34
33
  private readonly registry?: SubagentSessionRegistry,
35
34
  ) {}
36
35
 
@@ -57,10 +56,7 @@ export class ForwardingManager {
57
56
  return;
58
57
  }
59
58
  this.processing = true;
60
- void processForwardedPermissionRequests(
61
- this.context,
62
- this.forwardingDeps,
63
- ).finally(() => {
59
+ void this.forwarder.processInbox(this.context).finally(() => {
64
60
  this.processing = false;
65
61
  });
66
62
  }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
@@ -0,0 +1,13 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * The session surface `PermissionGateHandler` invokes directly: bind the
5
+ * per-event context and identify the agent.
6
+ *
7
+ * This is the two-method context role both entry points share after [#329]
8
+ * extracted `SkillInputGatePipeline` to own the skill-input gate assembly.
9
+ */
10
+ export interface GateHandlerSession {
11
+ activate(ctx: ExtensionContext): void;
12
+ resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
13
+ }
@@ -0,0 +1,14 @@
1
+ import type { PermissionPromptDecision } from "./permission-dialog";
2
+ import type { PromptPermissionDetails } from "./permission-prompter";
3
+
4
+ /**
5
+ * The prompting role the gate runner needs: a yes/no on whether an
6
+ * interactive confirmation is possible, and the prompt itself. The context
7
+ * is bound by the implementor, not threaded per call.
8
+ */
9
+ export interface GatePrompter {
10
+ canConfirm(): boolean;
11
+ promptPermission(
12
+ details: PromptPermissionDetails,
13
+ ): Promise<PermissionPromptDecision>;
14
+ }
@@ -2,12 +2,11 @@ import type {
2
2
  BeforeAgentStartEventResult,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
-
5
+ import type { AgentPrepSession } from "#src/agent-prep-session";
6
6
  import {
7
7
  createActiveToolsCacheKey,
8
8
  createBeforeAgentStartPromptStateKey,
9
9
  } from "#src/before-agent-start-cache";
10
- import type { PermissionSession } from "#src/permission-session";
11
10
  import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
12
11
  import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
13
12
  import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
@@ -41,7 +40,7 @@ export function shouldExposeTool(
41
40
  */
42
41
  export class AgentPrepHandler {
43
42
  constructor(
44
- private readonly session: PermissionSession,
43
+ private readonly session: AgentPrepSession,
45
44
  private readonly toolRegistry: ToolRegistry,
46
45
  ) {}
47
46
 
@@ -1,16 +1,8 @@
1
1
  import type { BashCommand } from "#src/handlers/gates/bash-program";
2
2
  import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
- import type { Rule } from "#src/rule";
3
+ import type { PermissionResolver } from "#src/permission-resolver";
4
4
  import type { PermissionCheckResult } from "#src/types";
5
5
 
6
- /** Function type for checkPermission used by the resolver. */
7
- type CheckPermissionFn = (
8
- surface: string,
9
- input: unknown,
10
- agentName?: string,
11
- sessionRules?: Rule[],
12
- ) => PermissionCheckResult;
13
-
14
6
  /**
15
7
  * Resolve the bash command-pattern decision for a (possibly chained) command.
16
8
  *
@@ -38,20 +30,14 @@ export function resolveBashCommandCheck(
38
30
  command: string,
39
31
  commands: BashCommand[],
40
32
  agentName: string | undefined,
41
- sessionRules: Rule[],
42
- checkPermission: CheckPermissionFn,
33
+ resolver: PermissionResolver,
43
34
  ): PermissionCheckResult {
44
35
  const results = commands.map((cmd) => {
45
- const result = checkPermission(
46
- "bash",
47
- { command: cmd.text },
48
- agentName,
49
- sessionRules,
50
- );
36
+ const result = resolver.resolve("bash", { command: cmd.text }, agentName);
51
37
  return cmd.context ? { ...result, commandContext: cmd.context } : result;
52
38
  });
53
39
  return (
54
40
  pickMostRestrictive(results) ??
55
- checkPermission("bash", { command }, agentName, sessionRules)
41
+ resolver.resolve("bash", { command }, agentName)
56
42
  );
57
43
  }