@komarspn/pi-permission-system 16.0.2

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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,580 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ getActiveAgentName,
5
+ getActiveAgentNameFromSystemPrompt,
6
+ type SessionEntryView,
7
+ } from "#src/active-agent";
8
+ import { toRecord } from "#src/common";
9
+ import type { ConfigReader } from "#src/config-store";
10
+ import type {
11
+ PermissionDecisionUi,
12
+ PermissionPromptDecision,
13
+ RequestPermissionOptions,
14
+ } from "#src/permission-dialog";
15
+ import {
16
+ emitUiPromptEvent,
17
+ type PermissionEventBus,
18
+ } from "#src/permission-events";
19
+ import {
20
+ type ForwardedPermissionRequest,
21
+ type ForwardedPermissionResponse,
22
+ type ForwardedPromptDisplay,
23
+ isForwardedPermissionRequestForSession,
24
+ PERMISSION_FORWARDING_POLL_INTERVAL_MS,
25
+ PERMISSION_FORWARDING_TIMEOUT_MS,
26
+ type PermissionForwardingLocation,
27
+ resolvePermissionForwardingTargetSessionId,
28
+ SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
29
+ } from "#src/permission-forwarding";
30
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
31
+ import type { DebugReviewLogger } from "#src/session-logger";
32
+ import { isSubagentExecutionContext } from "#src/subagent-context";
33
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
34
+ import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
35
+
36
+ import {
37
+ cleanupPermissionForwardingLocationIfEmpty,
38
+ ensureDirectoryExists,
39
+ ensurePermissionForwardingLocation,
40
+ getExistingPermissionForwardingLocation,
41
+ listRequestFiles,
42
+ logPermissionForwardingError,
43
+ logPermissionForwardingWarning,
44
+ readForwardedPermissionRequest,
45
+ readForwardedPermissionResponse,
46
+ safeDeleteFile,
47
+ sleep,
48
+ writeJsonFileAtomic,
49
+ } from "./io";
50
+
51
+ /**
52
+ * Narrow context the forwarder reads: the UI gate (`hasUI`), the dialog UI
53
+ * surface, and the three session-manager readers it uses directly or via
54
+ * {@link isSubagentExecutionContext} / {@link getActiveAgentName}.
55
+ *
56
+ * `getSystemPrompt` is read reflectively (see `getContextSystemPrompt`), so it
57
+ * is intentionally not a typed member. A full `ExtensionContext` satisfies this
58
+ * structurally, so production callers pass `ctx` unchanged.
59
+ */
60
+ export interface ForwarderContext {
61
+ hasUI: boolean;
62
+ ui: PermissionDecisionUi;
63
+ sessionManager: {
64
+ getSessionId(): string;
65
+ getSessionDir(): string;
66
+ getEntries(): readonly SessionEntryView[];
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Constructor config for `PermissionForwarder`.
72
+ *
73
+ * Replaces the `PermissionForwardingDeps` interface that was previously
74
+ * threaded into free functions in `polling.ts`. The forwarder consumes it
75
+ * once at construction and stores each member as a private readonly field.
76
+ */
77
+ export interface PermissionForwarderDeps {
78
+ forwardingDir: string;
79
+ subagentSessionsDir: string;
80
+ /** In-process subagent session registry for detection and forwarding target resolution. */
81
+ registry?: SubagentSessionRegistry;
82
+ /** Event bus used for UI prompt broadcasts. */
83
+ events?: PermissionEventBus;
84
+ logger: DebugReviewLogger;
85
+ requestPermissionDecisionFromUi: (
86
+ ui: PermissionDecisionUi,
87
+ title: string,
88
+ message: string,
89
+ options?: RequestPermissionOptions,
90
+ ) => Promise<PermissionPromptDecision>;
91
+ /** Read current config for yolo-mode auto-approve check (called at prompt time). */
92
+ config: ConfigReader;
93
+ }
94
+
95
+ // ── Module-private helpers ────────────────────────────────────────────────
96
+
97
+ function getSessionId(ctx: ForwarderContext): string {
98
+ try {
99
+ const sessionId = ctx.sessionManager.getSessionId();
100
+ if (typeof sessionId === "string" && sessionId.trim()) {
101
+ return sessionId.trim();
102
+ }
103
+ } catch {}
104
+
105
+ return "unknown";
106
+ }
107
+
108
+ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
109
+ const getSystemPrompt = toRecord(ctx).getSystemPrompt;
110
+ if (typeof getSystemPrompt !== "function") {
111
+ return undefined;
112
+ }
113
+
114
+ try {
115
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
116
+ const systemPrompt = getSystemPrompt.call(ctx);
117
+ return typeof systemPrompt === "string" ? systemPrompt : undefined;
118
+ } catch (error) {
119
+ // No deps available in this helper — warning silently dropped.
120
+ logPermissionForwardingWarning(
121
+ null,
122
+ "Failed to read context system prompt for forwarded permission metadata",
123
+ error,
124
+ );
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ function formatForwardedPermissionPrompt(
130
+ request: ForwardedPermissionRequest,
131
+ ): string {
132
+ const agentName = request.requesterAgentName || "unknown";
133
+ const sessionId = request.requesterSessionId || "unknown";
134
+ return [
135
+ `Subagent '${agentName}' requested permission.`,
136
+ `Session ID: ${sessionId}`,
137
+ "",
138
+ request.message,
139
+ ].join("\n");
140
+ }
141
+
142
+ // ── Public seam interfaces ────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Narrow seam describing what `PermissionPrompter` needs from the forwarder:
146
+ * a single method that resolves a permission decision for the current context
147
+ * (prompt directly when the session has UI, otherwise forward to the parent).
148
+ *
149
+ * Depending on the interface (not the concrete `PermissionForwarder`) keeps
150
+ * the prompter's unit tests free of casts — they inject a plain
151
+ * `{ requestApproval: vi.fn() }` mock.
152
+ */
153
+ export interface ApprovalRequester {
154
+ requestApproval(
155
+ ctx: ForwarderContext,
156
+ message: string,
157
+ options?: RequestPermissionOptions,
158
+ forwarded?: ForwardedPromptDisplay,
159
+ ): Promise<PermissionPromptDecision>;
160
+ }
161
+
162
+ /**
163
+ * Narrow seam describing what `ForwardingManager` needs from the forwarder:
164
+ * a single method that drains this session's forwarded-permission inbox.
165
+ *
166
+ * Depending on the interface (not the concrete `PermissionForwarder`) keeps
167
+ * the manager's unit tests free of casts — they inject a plain
168
+ * `{ processInbox: vi.fn() }` mock.
169
+ */
170
+ export interface InboxProcessor {
171
+ processInbox(ctx: ForwarderContext): Promise<void>;
172
+ }
173
+
174
+ // ── PermissionForwarder ───────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Owner of the forwarded-permission behavior.
178
+ *
179
+ * Holds all forwarding state as private readonly fields and provides two
180
+ * public methods (`requestApproval`, `processInbox`) that together encapsulate
181
+ * the full forwarding lifecycle: deciding whether to prompt directly or
182
+ * forward to the parent, building and persisting request files, polling for
183
+ * responses, and processing the parent-session inbox.
184
+ */
185
+ export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
186
+ private readonly forwardingDir: string;
187
+ private readonly subagentSessionsDir: string;
188
+ private readonly registry: SubagentSessionRegistry | undefined;
189
+ private readonly events: PermissionEventBus | undefined;
190
+ private readonly logger: DebugReviewLogger;
191
+ private readonly requestPermissionDecisionFromUi: (
192
+ ui: PermissionDecisionUi,
193
+ title: string,
194
+ message: string,
195
+ options?: RequestPermissionOptions,
196
+ ) => Promise<PermissionPromptDecision>;
197
+ private readonly config: ConfigReader;
198
+
199
+ constructor(deps: PermissionForwarderDeps) {
200
+ this.forwardingDir = deps.forwardingDir;
201
+ this.subagentSessionsDir = deps.subagentSessionsDir;
202
+ this.registry = deps.registry;
203
+ this.events = deps.events;
204
+ this.logger = deps.logger;
205
+ this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
206
+ this.config = deps.config;
207
+ }
208
+
209
+ // ── Public seam methods ────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Resolve a permission decision for the current context: prompt directly
213
+ * when this session has UI, otherwise forward to the parent session.
214
+ */
215
+ requestApproval(
216
+ ctx: ForwarderContext,
217
+ message: string,
218
+ options?: RequestPermissionOptions,
219
+ forwarded?: ForwardedPromptDisplay,
220
+ ): Promise<PermissionPromptDecision> {
221
+ if (ctx.hasUI) {
222
+ return this.requestPermissionDecisionFromUi(
223
+ ctx.ui,
224
+ "Permission Required",
225
+ message,
226
+ options,
227
+ );
228
+ }
229
+
230
+ if (
231
+ !isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
232
+ ) {
233
+ return Promise.resolve({ approved: false, state: "denied" });
234
+ }
235
+
236
+ return this.waitForForwardedApproval(ctx, message, forwarded);
237
+ }
238
+
239
+ /** Drain and respond to this session's forwarded-permission inbox. */
240
+ async processInbox(ctx: ForwarderContext): Promise<void> {
241
+ if (!ctx.hasUI) {
242
+ return;
243
+ }
244
+
245
+ const currentSessionId = getSessionId(ctx);
246
+ const location = getExistingPermissionForwardingLocation(
247
+ this.forwardingDir,
248
+ currentSessionId,
249
+ );
250
+ if (!location) {
251
+ return;
252
+ }
253
+
254
+ const requestFiles = listRequestFiles(this.logger, location.requestsDir);
255
+ if (requestFiles.length === 0) {
256
+ return;
257
+ }
258
+
259
+ // Defensively recreate responses/ before writing any response — a
260
+ // concurrent cleanup pass may have removed it between the requestsDir
261
+ // existence check above and the write inside processSingleForwardedRequest
262
+ // (the ENOENT write loop reported in issue #398).
263
+ if (
264
+ !ensureDirectoryExists(
265
+ this.logger,
266
+ location.responsesDir,
267
+ "permission forwarding responses",
268
+ )
269
+ ) {
270
+ return;
271
+ }
272
+
273
+ for (const fileName of requestFiles) {
274
+ const requestPath = join(location.requestsDir, fileName);
275
+ const request = readForwardedPermissionRequest(this.logger, requestPath);
276
+ if (!request) {
277
+ safeDeleteFile(
278
+ this.logger,
279
+ requestPath,
280
+ `${location.label} forwarded permission request`,
281
+ );
282
+ continue;
283
+ }
284
+
285
+ await this.processSingleForwardedRequest(
286
+ ctx,
287
+ request,
288
+ location,
289
+ requestPath,
290
+ currentSessionId,
291
+ );
292
+ }
293
+
294
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
295
+ }
296
+
297
+ // ── Private methods ────────────────────────────────────────────────────
298
+
299
+ private async waitForForwardedApproval(
300
+ ctx: ForwarderContext,
301
+ message: string,
302
+ forwarded?: ForwardedPromptDisplay,
303
+ ): Promise<PermissionPromptDecision> {
304
+ const requesterSessionId = getSessionId(ctx);
305
+ const targetSessionId = resolvePermissionForwardingTargetSessionId({
306
+ hasUI: ctx.hasUI,
307
+ isSubagent: isSubagentExecutionContext(
308
+ ctx,
309
+ this.subagentSessionsDir,
310
+ this.registry,
311
+ ),
312
+ currentSessionId: requesterSessionId,
313
+ env: process.env,
314
+ sessionId: requesterSessionId,
315
+ registry: this.registry,
316
+ });
317
+
318
+ if (!targetSessionId) {
319
+ logPermissionForwardingError(
320
+ this.logger,
321
+ `Permission forwarding target session could not be resolved. ` +
322
+ `Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
323
+ `If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
324
+ `ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
325
+ `(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
326
+ );
327
+ return { approved: false, state: "denied" };
328
+ }
329
+
330
+ const location = ensurePermissionForwardingLocation(
331
+ this.logger,
332
+ this.forwardingDir,
333
+ targetSessionId,
334
+ );
335
+ if (!location) {
336
+ logPermissionForwardingError(
337
+ this.logger,
338
+ `Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
339
+ );
340
+ return { approved: false, state: "denied" };
341
+ }
342
+
343
+ const request = this.buildForwardedRequest(
344
+ ctx,
345
+ message,
346
+ requesterSessionId,
347
+ targetSessionId,
348
+ forwarded,
349
+ );
350
+ const requestPath = join(location.requestsDir, `${request.id}.json`);
351
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
352
+
353
+ this.logger.review("forwarded_permission.request_created", {
354
+ requestId: request.id,
355
+ requesterAgentName: request.requesterAgentName,
356
+ requesterSessionId: request.requesterSessionId,
357
+ targetSessionId,
358
+ requestPath,
359
+ responsePath,
360
+ });
361
+
362
+ try {
363
+ writeJsonFileAtomic(this.logger, requestPath, request);
364
+ } catch (error) {
365
+ logPermissionForwardingError(
366
+ this.logger,
367
+ `Failed to write forwarded permission request '${requestPath}'`,
368
+ error,
369
+ );
370
+ return { approved: false, state: "denied" };
371
+ }
372
+
373
+ return this.pollForForwardedResponse(
374
+ location,
375
+ request,
376
+ requestPath,
377
+ responsePath,
378
+ );
379
+ }
380
+
381
+ private buildForwardedRequest(
382
+ ctx: ForwarderContext,
383
+ message: string,
384
+ requesterSessionId: string,
385
+ targetSessionId: string,
386
+ forwarded?: ForwardedPromptDisplay,
387
+ ): ForwardedPermissionRequest {
388
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
389
+ const requesterAgentName =
390
+ getActiveAgentName(ctx) ??
391
+ getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
392
+ "unknown";
393
+ return {
394
+ id: requestId,
395
+ createdAt: Date.now(),
396
+ requesterSessionId,
397
+ targetSessionId,
398
+ requesterAgentName,
399
+ message,
400
+ ...(forwarded
401
+ ? {
402
+ source: forwarded.source,
403
+ surface: forwarded.surface,
404
+ value: forwarded.value,
405
+ }
406
+ : {}),
407
+ };
408
+ }
409
+
410
+ private async pollForForwardedResponse(
411
+ location: PermissionForwardingLocation,
412
+ request: ForwardedPermissionRequest,
413
+ requestPath: string,
414
+ responsePath: string,
415
+ ): Promise<PermissionPromptDecision> {
416
+ const { id: requestId, requesterAgentName, targetSessionId } = request;
417
+ const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
418
+
419
+ while (Date.now() < deadline) {
420
+ if (existsSync(responsePath)) {
421
+ const response = readForwardedPermissionResponse(
422
+ this.logger,
423
+ responsePath,
424
+ );
425
+ this.logger.review("forwarded_permission.response_received", {
426
+ requestId,
427
+ approved: response?.approved ?? null,
428
+ state: response?.state ?? null,
429
+ denialReason: response?.denialReason ?? null,
430
+ responderSessionId: response?.responderSessionId ?? null,
431
+ targetSessionId,
432
+ responsePath,
433
+ });
434
+ safeDeleteFile(
435
+ this.logger,
436
+ responsePath,
437
+ "forwarded permission response",
438
+ );
439
+ safeDeleteFile(
440
+ this.logger,
441
+ requestPath,
442
+ "forwarded permission request",
443
+ );
444
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
445
+ return response ?? { approved: false, state: "denied" };
446
+ }
447
+
448
+ await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
449
+ }
450
+
451
+ logPermissionForwardingWarning(
452
+ this.logger,
453
+ `Timed out waiting for forwarded permission response '${responsePath}'`,
454
+ );
455
+ this.logger.review("forwarded_permission.response_timed_out", {
456
+ requestId,
457
+ requesterAgentName,
458
+ targetSessionId,
459
+ responsePath,
460
+ });
461
+ safeDeleteFile(this.logger, requestPath, "forwarded permission request");
462
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
463
+ return { approved: false, state: "denied" };
464
+ }
465
+
466
+ private async processSingleForwardedRequest(
467
+ ctx: ForwarderContext,
468
+ request: ForwardedPermissionRequest,
469
+ location: PermissionForwardingLocation,
470
+ requestPath: string,
471
+ currentSessionId: string,
472
+ ): Promise<void> {
473
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
474
+ logPermissionForwardingWarning(
475
+ this.logger,
476
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
477
+ );
478
+ safeDeleteFile(
479
+ this.logger,
480
+ requestPath,
481
+ `${location.label} forwarded permission request`,
482
+ );
483
+ return;
484
+ }
485
+
486
+ const forwardedPermissionLogDetails = {
487
+ requestId: request.id,
488
+ source: location.label,
489
+ requesterAgentName: request.requesterAgentName,
490
+ requesterSessionId: request.requesterSessionId,
491
+ targetSessionId: request.targetSessionId,
492
+ requestPath,
493
+ };
494
+
495
+ let decision: PermissionPromptDecision = {
496
+ approved: false,
497
+ state: "denied",
498
+ };
499
+ if (shouldAutoApprovePermissionState("ask", this.config.current())) {
500
+ this.logger.review(
501
+ "forwarded_permission.auto_approved",
502
+ forwardedPermissionLogDetails,
503
+ );
504
+ decision = { approved: true, state: "approved" };
505
+ } else {
506
+ this.logger.review(
507
+ "forwarded_permission.prompted",
508
+ forwardedPermissionLogDetails,
509
+ );
510
+ try {
511
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
512
+ if (this.events) {
513
+ emitUiPromptEvent(
514
+ this.events,
515
+ buildForwardedUiPrompt({
516
+ requestId: request.id,
517
+ message: forwardedMessage,
518
+ requesterAgentName: request.requesterAgentName || null,
519
+ requesterSessionId: request.requesterSessionId || null,
520
+ source: request.source ?? null,
521
+ surface: request.surface ?? null,
522
+ value: request.value ?? null,
523
+ }),
524
+ );
525
+ }
526
+ decision = await this.requestPermissionDecisionFromUi(
527
+ ctx.ui,
528
+ "Permission Required (Subagent)",
529
+ forwardedMessage,
530
+ );
531
+ } catch (error) {
532
+ logPermissionForwardingError(
533
+ this.logger,
534
+ "Failed to show forwarded permission confirmation dialog",
535
+ error,
536
+ );
537
+ decision = { approved: false, state: "denied" };
538
+ }
539
+ }
540
+
541
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
542
+ this.logger.review(
543
+ decision.approved
544
+ ? "forwarded_permission.approved"
545
+ : "forwarded_permission.denied",
546
+ {
547
+ requestId: request.id,
548
+ source: location.label,
549
+ requesterAgentName: request.requesterAgentName,
550
+ requesterSessionId: request.requesterSessionId,
551
+ targetSessionId: request.targetSessionId,
552
+ responsePath,
553
+ resolution: decision.state,
554
+ denialReason: decision.denialReason ?? null,
555
+ },
556
+ );
557
+ try {
558
+ writeJsonFileAtomic(this.logger, responsePath, {
559
+ approved: decision.approved,
560
+ state: decision.state,
561
+ denialReason: decision.denialReason,
562
+ responderSessionId: currentSessionId,
563
+ respondedAt: Date.now(),
564
+ } satisfies ForwardedPermissionResponse);
565
+ } catch (error) {
566
+ logPermissionForwardingError(
567
+ this.logger,
568
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
569
+ error,
570
+ );
571
+ return;
572
+ }
573
+
574
+ safeDeleteFile(
575
+ this.logger,
576
+ requestPath,
577
+ `${location.label} forwarded permission request`,
578
+ );
579
+ }
580
+ }
@@ -0,0 +1,74 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { InboxProcessor } from "./forwarded-permissions/permission-forwarder";
4
+ import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
5
+ import { isSubagentExecutionContext } from "./subagent-context";
6
+ import type { SubagentSessionRegistry } from "./subagent-registry";
7
+
8
+ /**
9
+ * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
10
+ * `ForwardingManager` satisfies it; tests can provide a plain object mock.
11
+ */
12
+ export interface ForwardingController {
13
+ start(ctx: ExtensionContext): void;
14
+ stop(): void;
15
+ }
16
+
17
+ /**
18
+ * Encapsulates the forwarded-permission polling lifecycle.
19
+ *
20
+ * Owns the timer, current context, and processing-lock state that previously
21
+ * lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
22
+ * session event that may activate forwarding; call `stop()` on session
23
+ * shutdown.
24
+ */
25
+ export class ForwardingManager {
26
+ private timer: NodeJS.Timeout | null = null;
27
+ private context: ExtensionContext | null = null;
28
+ private processing = false;
29
+
30
+ constructor(
31
+ private readonly subagentSessionsDir: string,
32
+ private readonly forwarder: InboxProcessor,
33
+ private readonly registry?: SubagentSessionRegistry,
34
+ ) {}
35
+
36
+ /**
37
+ * Start polling if `ctx` has UI and is not a subagent execution context.
38
+ * No-op (timer stays running) if already polling — updates the stored
39
+ * context so the next tick uses the latest session.
40
+ * Stops any existing poll when the context does not qualify for forwarding.
41
+ */
42
+ start(ctx: ExtensionContext): void {
43
+ if (
44
+ !ctx.hasUI ||
45
+ isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
46
+ ) {
47
+ this.stop();
48
+ return;
49
+ }
50
+ this.context = ctx;
51
+ if (this.timer) {
52
+ return;
53
+ }
54
+ this.timer = setInterval(() => {
55
+ if (!this.context || this.processing) {
56
+ return;
57
+ }
58
+ this.processing = true;
59
+ void this.forwarder.processInbox(this.context).finally(() => {
60
+ this.processing = false;
61
+ });
62
+ }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
63
+ }
64
+
65
+ /** Stop polling and clear all internal state. */
66
+ stop(): void {
67
+ if (this.timer) {
68
+ clearInterval(this.timer);
69
+ this.timer = null;
70
+ }
71
+ this.context = null;
72
+ this.processing = false;
73
+ }
74
+ }
@@ -0,0 +1,12 @@
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
+ prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
12
+ }