@gotgenes/pi-permission-system 19.0.0 → 20.0.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.
@@ -0,0 +1,285 @@
1
+ import { join } from "node:path";
2
+ import {
3
+ type ForwarderContext,
4
+ getSessionId,
5
+ } from "#src/authority/forwarder-context";
6
+ import type { ConfigReader } from "#src/config-store";
7
+ import { isYoloModeEnabled } from "#src/extension-config";
8
+ import type {
9
+ PermissionDecisionUi,
10
+ PermissionPromptDecision,
11
+ RequestPermissionOptions,
12
+ } from "#src/permission-dialog";
13
+ import {
14
+ emitUiPromptEvent,
15
+ type PermissionEventBus,
16
+ } from "#src/permission-events";
17
+ import {
18
+ type ForwardedPermissionRequest,
19
+ type ForwardedPermissionResponse,
20
+ isForwardedPermissionRequestForSession,
21
+ type PermissionForwardingLocation,
22
+ } from "#src/permission-forwarding";
23
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
24
+ import type { DebugReviewLogger } from "#src/session-logger";
25
+
26
+ import {
27
+ cleanupPermissionForwardingLocationIfEmpty,
28
+ ensureDirectoryExists,
29
+ getExistingPermissionForwardingLocation,
30
+ listRequestFiles,
31
+ logPermissionForwardingError,
32
+ logPermissionForwardingWarning,
33
+ readForwardedPermissionRequest,
34
+ safeDeleteFile,
35
+ writeJsonFileAtomic,
36
+ } from "./forwarding-io";
37
+
38
+ /**
39
+ * Narrow seam describing what `ForwardingManager` needs from the server: a
40
+ * single method that drains this session's forwarded-permission inbox.
41
+ *
42
+ * Depending on the interface (not the concrete `ForwardedRequestServer`)
43
+ * keeps the manager's unit tests free of casts — they inject a plain
44
+ * `{ processInbox: vi.fn() }` mock.
45
+ */
46
+ export interface InboxProcessor {
47
+ processInbox(ctx: ForwarderContext): Promise<void>;
48
+ }
49
+
50
+ /** Constructor config for `ForwardedRequestServer`. */
51
+ export interface ForwardedRequestServerDeps {
52
+ forwardingDir: string;
53
+ logger: DebugReviewLogger;
54
+ /** Event bus used for UI prompt broadcasts. */
55
+ events?: PermissionEventBus;
56
+ requestPermissionDecisionFromUi: (
57
+ ui: PermissionDecisionUi,
58
+ title: string,
59
+ message: string,
60
+ options?: RequestPermissionOptions,
61
+ ) => Promise<PermissionPromptDecision>;
62
+ /** Read current config for the retained forwarded-inbox yolo auto-approve check. */
63
+ config: ConfigReader;
64
+ }
65
+
66
+ // ── Module-private helpers ────────────────────────────────────────────────
67
+
68
+ function formatForwardedPermissionPrompt(
69
+ request: ForwardedPermissionRequest,
70
+ ): string {
71
+ const agentName = request.requesterAgentName || "unknown";
72
+ const sessionId = request.requesterSessionId || "unknown";
73
+ return [
74
+ `Subagent '${agentName}' requested permission.`,
75
+ `Session ID: ${sessionId}`,
76
+ "",
77
+ request.message,
78
+ ].join("\n");
79
+ }
80
+
81
+ // ── ForwardedRequestServer ────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Owner of the serving-down role of the forwarded-permission behavior:
85
+ * draining this session's forwarded-permission inbox and answering each
86
+ * request (auto-approve under yolo, or prompt via the injected UI).
87
+ */
88
+ export class ForwardedRequestServer implements InboxProcessor {
89
+ private readonly forwardingDir: string;
90
+ private readonly events: PermissionEventBus | undefined;
91
+ private readonly logger: DebugReviewLogger;
92
+ private readonly requestPermissionDecisionFromUi: (
93
+ ui: PermissionDecisionUi,
94
+ title: string,
95
+ message: string,
96
+ options?: RequestPermissionOptions,
97
+ ) => Promise<PermissionPromptDecision>;
98
+ private readonly config: ConfigReader;
99
+
100
+ constructor(deps: ForwardedRequestServerDeps) {
101
+ this.forwardingDir = deps.forwardingDir;
102
+ this.events = deps.events;
103
+ this.logger = deps.logger;
104
+ this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
105
+ this.config = deps.config;
106
+ }
107
+
108
+ /** Drain and respond to this session's forwarded-permission inbox. */
109
+ async processInbox(ctx: ForwarderContext): Promise<void> {
110
+ if (!ctx.hasUI) {
111
+ return;
112
+ }
113
+
114
+ const currentSessionId = getSessionId(ctx);
115
+ const location = getExistingPermissionForwardingLocation(
116
+ this.forwardingDir,
117
+ currentSessionId,
118
+ );
119
+ if (!location) {
120
+ return;
121
+ }
122
+
123
+ const requestFiles = listRequestFiles(this.logger, location.requestsDir);
124
+ if (requestFiles.length === 0) {
125
+ return;
126
+ }
127
+
128
+ // Defensively recreate responses/ before writing any response — a
129
+ // concurrent cleanup pass may have removed it between the requestsDir
130
+ // existence check above and the write inside processSingleForwardedRequest
131
+ // (the ENOENT write loop reported in issue #398).
132
+ if (
133
+ !ensureDirectoryExists(
134
+ this.logger,
135
+ location.responsesDir,
136
+ "permission forwarding responses",
137
+ )
138
+ ) {
139
+ return;
140
+ }
141
+
142
+ for (const fileName of requestFiles) {
143
+ const requestPath = join(location.requestsDir, fileName);
144
+ const request = readForwardedPermissionRequest(this.logger, requestPath);
145
+ if (!request) {
146
+ safeDeleteFile(
147
+ this.logger,
148
+ requestPath,
149
+ `${location.label} forwarded permission request`,
150
+ );
151
+ continue;
152
+ }
153
+
154
+ await this.processSingleForwardedRequest(
155
+ ctx,
156
+ request,
157
+ location,
158
+ requestPath,
159
+ currentSessionId,
160
+ );
161
+ }
162
+
163
+ cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
164
+ }
165
+
166
+ // ── Private methods ────────────────────────────────────────────────────
167
+
168
+ private async processSingleForwardedRequest(
169
+ ctx: ForwarderContext,
170
+ request: ForwardedPermissionRequest,
171
+ location: PermissionForwardingLocation,
172
+ requestPath: string,
173
+ currentSessionId: string,
174
+ ): Promise<void> {
175
+ if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
176
+ logPermissionForwardingWarning(
177
+ this.logger,
178
+ `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
179
+ );
180
+ safeDeleteFile(
181
+ this.logger,
182
+ requestPath,
183
+ `${location.label} forwarded permission request`,
184
+ );
185
+ return;
186
+ }
187
+
188
+ const forwardedPermissionLogDetails = {
189
+ requestId: request.id,
190
+ source: location.label,
191
+ requesterAgentName: request.requesterAgentName,
192
+ requesterSessionId: request.requesterSessionId,
193
+ targetSessionId: request.targetSessionId,
194
+ requestPath,
195
+ };
196
+
197
+ let decision: PermissionPromptDecision = {
198
+ approved: false,
199
+ state: "denied",
200
+ };
201
+ // Last yolo check outside the composed ruleset: dissolves when
202
+ // processInbox is refactored onto evaluate() + Authorizer selection in
203
+ // the Phase 9 spine work.
204
+ if (isYoloModeEnabled(this.config.current())) {
205
+ this.logger.review(
206
+ "forwarded_permission.auto_approved",
207
+ forwardedPermissionLogDetails,
208
+ );
209
+ decision = { approved: true, state: "approved" };
210
+ } else {
211
+ this.logger.review(
212
+ "forwarded_permission.prompted",
213
+ forwardedPermissionLogDetails,
214
+ );
215
+ try {
216
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
217
+ if (this.events) {
218
+ emitUiPromptEvent(
219
+ this.events,
220
+ buildForwardedUiPrompt({
221
+ requestId: request.id,
222
+ message: forwardedMessage,
223
+ requesterAgentName: request.requesterAgentName || null,
224
+ requesterSessionId: request.requesterSessionId || null,
225
+ source: request.source ?? null,
226
+ surface: request.surface ?? null,
227
+ value: request.value ?? null,
228
+ }),
229
+ );
230
+ }
231
+ decision = await this.requestPermissionDecisionFromUi(
232
+ ctx.ui,
233
+ "Permission Required (Subagent)",
234
+ forwardedMessage,
235
+ );
236
+ } catch (error) {
237
+ logPermissionForwardingError(
238
+ this.logger,
239
+ "Failed to show forwarded permission confirmation dialog",
240
+ error,
241
+ );
242
+ decision = { approved: false, state: "denied" };
243
+ }
244
+ }
245
+
246
+ const responsePath = join(location.responsesDir, `${request.id}.json`);
247
+ this.logger.review(
248
+ decision.approved
249
+ ? "forwarded_permission.approved"
250
+ : "forwarded_permission.denied",
251
+ {
252
+ requestId: request.id,
253
+ source: location.label,
254
+ requesterAgentName: request.requesterAgentName,
255
+ requesterSessionId: request.requesterSessionId,
256
+ targetSessionId: request.targetSessionId,
257
+ responsePath,
258
+ resolution: decision.state,
259
+ denialReason: decision.denialReason ?? null,
260
+ },
261
+ );
262
+ try {
263
+ writeJsonFileAtomic(this.logger, responsePath, {
264
+ approved: decision.approved,
265
+ state: decision.state,
266
+ denialReason: decision.denialReason,
267
+ responderSessionId: currentSessionId,
268
+ respondedAt: Date.now(),
269
+ } satisfies ForwardedPermissionResponse);
270
+ } catch (error) {
271
+ logPermissionForwardingError(
272
+ this.logger,
273
+ `Failed to write ${location.label} forwarded permission response '${responsePath}'`,
274
+ error,
275
+ );
276
+ return;
277
+ }
278
+
279
+ safeDeleteFile(
280
+ this.logger,
281
+ requestPath,
282
+ `${location.label} forwarded permission request`,
283
+ );
284
+ }
285
+ }
@@ -0,0 +1,32 @@
1
+ import type { SessionEntryView } from "#src/active-agent";
2
+ import type { PermissionDecisionUi } from "#src/permission-dialog";
3
+
4
+ /**
5
+ * Narrow context the forwarding subsystem reads: the UI gate (`hasUI`), the
6
+ * dialog UI surface, and the three session-manager readers `getSessionId`
7
+ * and the `active-agent` helpers use.
8
+ *
9
+ * A full `ExtensionContext` satisfies this structurally, so production
10
+ * callers pass `ctx` unchanged.
11
+ */
12
+ export interface ForwarderContext {
13
+ hasUI: boolean;
14
+ ui: PermissionDecisionUi;
15
+ sessionManager: {
16
+ getSessionId(): string;
17
+ getSessionDir(): string;
18
+ getEntries(): readonly SessionEntryView[];
19
+ };
20
+ }
21
+
22
+ /** Reads the current session id off `ctx`, falling back to `"unknown"`. */
23
+ export function getSessionId(ctx: ForwarderContext): string {
24
+ try {
25
+ const sessionId = ctx.sessionManager.getSessionId();
26
+ if (typeof sessionId === "string" && sessionId.trim()) {
27
+ return sessionId.trim();
28
+ }
29
+ } catch {}
30
+
31
+ return "unknown";
32
+ }
@@ -24,7 +24,6 @@ const UI_PROMPT_SOURCES = [
24
24
  "tool_call",
25
25
  "skill_input",
26
26
  "skill_read",
27
- "rpc_prompt",
28
27
  ] as const satisfies readonly PermissionUiPromptSource[];
29
28
 
30
29
  /** Narrow an unknown value to a valid prompt source, or `undefined`. */
@@ -1,7 +1,7 @@
1
1
  import { posix as posixPath, win32 as winPath } from "node:path";
2
2
 
3
- import { SUBAGENT_ENV_HINT_KEYS } from "./permission-forwarding";
4
- import type { SubagentSessionRegistry } from "./subagent-registry";
3
+ import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
4
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
5
5
 
6
6
  /**
7
7
  * Narrow context for subagent detection — the only session-manager readers
@@ -0,0 +1,65 @@
1
+ import {
2
+ isRegisteredSubagentChild,
3
+ isSubagentExecutionContext,
4
+ type SubagentDetectionContext,
5
+ } from "#src/authority/subagent-context";
6
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
7
+
8
+ /**
9
+ * Narrow seam for the ask-path consumers: "is the current session a subagent?"
10
+ *
11
+ * `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
12
+ * this single-method view so their unit tests inject a one-field fake without
13
+ * casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
14
+ */
15
+ export interface SubagentDetector {
16
+ isSubagent(ctx: SubagentDetectionContext): boolean;
17
+ }
18
+
19
+ /**
20
+ * Narrow seam for the service-publication guard (#302): "is the current
21
+ * session a registered in-process child?"
22
+ *
23
+ * `PermissionServiceLifecycle` depends on this single-method view so a
24
+ * registered child never publishes over its parent's process-global slot.
25
+ */
26
+ export interface RegisteredChildDetector {
27
+ isRegisteredChild(ctx: SubagentDetectionContext): boolean;
28
+ }
29
+
30
+ /** Composition-root inputs for {@link SubagentDetection}. */
31
+ export interface SubagentDetectionDeps {
32
+ subagentSessionsDir: string;
33
+ platform: NodeJS.Platform;
34
+ registry?: SubagentSessionRegistry;
35
+ }
36
+
37
+ /**
38
+ * Single owner of subagent detection.
39
+ *
40
+ * Constructed once in the composition root with the detection inputs
41
+ * (`subagentSessionsDir`, `platform`, `registry`) and shared across every
42
+ * consumer, replacing the dep triple those consumers previously threaded
43
+ * individually. Delegates to the pure detection functions in
44
+ * {@link ./subagent-context}, holding only the deps.
45
+ */
46
+ export class SubagentDetection
47
+ implements SubagentDetector, RegisteredChildDetector
48
+ {
49
+ constructor(private readonly deps: SubagentDetectionDeps) {}
50
+
51
+ isSubagent(ctx: SubagentDetectionContext): boolean {
52
+ return isSubagentExecutionContext(
53
+ ctx,
54
+ this.deps.subagentSessionsDir,
55
+ this.deps.platform,
56
+ this.deps.registry,
57
+ );
58
+ }
59
+
60
+ isRegisteredChild(ctx: SubagentDetectionContext): boolean {
61
+ return this.deps.registry
62
+ ? isRegisteredSubagentChild(ctx, this.deps.registry)
63
+ : false;
64
+ }
65
+ }
@@ -1,9 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- import type { InboxProcessor } from "./forwarded-permissions/permission-forwarder";
2
+ import type { InboxProcessor } from "./authority/forwarded-request-server";
3
+ import type { SubagentDetector } from "./authority/subagent-detection";
4
4
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
5
- import { isSubagentExecutionContext } from "./subagent-context";
6
- import type { SubagentSessionRegistry } from "./subagent-registry";
7
5
 
8
6
  /**
9
7
  * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
@@ -28,10 +26,8 @@ export class ForwardingManager {
28
26
  private processing = false;
29
27
 
30
28
  constructor(
31
- private readonly subagentSessionsDir: string,
29
+ private readonly detection: SubagentDetector,
32
30
  private readonly forwarder: InboxProcessor,
33
- private readonly platform: NodeJS.Platform,
34
- private readonly registry?: SubagentSessionRegistry,
35
31
  ) {}
36
32
 
37
33
  /**
@@ -41,15 +37,7 @@ export class ForwardingManager {
41
37
  * Stops any existing poll when the context does not qualify for forwarding.
42
38
  */
43
39
  start(ctx: ExtensionContext): void {
44
- if (
45
- !ctx.hasUI ||
46
- isSubagentExecutionContext(
47
- ctx,
48
- this.subagentSessionsDir,
49
- this.platform,
50
- this.registry,
51
- )
52
- ) {
40
+ if (!ctx.hasUI || this.detection.isSubagent(ctx)) {
53
41
  this.stop();
54
42
  return;
55
43
  }
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ ApprovalEscalator,
5
+ type ApprovalEscalatorDeps,
6
+ } from "./authority/approval-escalator";
7
+ import {
8
+ ForwardedRequestServer,
9
+ type ForwardedRequestServerDeps,
10
+ } from "./authority/forwarded-request-server";
11
+ import { SubagentDetection } from "./authority/subagent-detection";
3
12
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
4
13
  import { registerPermissionSystemCommand } from "./config-modal";
5
14
  import { getGlobalConfigPath } from "./config-paths";
@@ -8,10 +17,6 @@ import { DecisionAudit } from "./decision-audit";
8
17
  import { GateDecisionReporter } from "./decision-reporter";
9
18
  import { isYoloModeEnabled } from "./extension-config";
10
19
  import { computeExtensionPaths } from "./extension-paths";
11
- import {
12
- PermissionForwarder,
13
- type PermissionForwarderDeps,
14
- } from "./forwarded-permissions/permission-forwarder";
15
20
  import { ForwardingManager } from "./forwarding-manager";
16
21
  import {
17
22
  AgentPrepHandler,
@@ -23,7 +28,6 @@ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeli
23
28
  import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
24
29
  import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
25
30
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
26
- import { registerPermissionRpcHandlers } from "./permission-event-rpc";
27
31
  import { PermissionManager } from "./permission-manager";
28
32
  import { PermissionPrompter } from "./permission-prompter";
29
33
  import { PermissionResolver } from "./permission-resolver";
@@ -49,6 +53,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
49
53
  const hostPlatform = process.platform;
50
54
  const sessionRules = new SessionRules();
51
55
  const subagentRegistry = getSubagentSessionRegistry();
56
+ // Single owner of subagent detection, shared across every consumer instead of
57
+ // threading the (subagentSessionsDir, platform, registry) triple into each.
58
+ const subagentDetection = new SubagentDetection({
59
+ subagentSessionsDir: paths.subagentSessionsDir,
60
+ platform: hostPlatform,
61
+ registry: subagentRegistry,
62
+ });
52
63
  const formatterRegistry = new ToolInputFormatterRegistry();
53
64
  registerBuiltinToolInputFormatters(formatterRegistry);
54
65
  const accessExtractorRegistry = new ToolAccessExtractorRegistry();
@@ -83,39 +94,38 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
83
94
  logger,
84
95
  });
85
96
 
86
- const forwardingDeps: PermissionForwarderDeps = {
97
+ const escalatorDeps: ApprovalEscalatorDeps = {
87
98
  forwardingDir: paths.forwardingDir,
88
- subagentSessionsDir: paths.subagentSessionsDir,
89
- platform: hostPlatform,
99
+ detection: subagentDetection,
90
100
  registry: subagentRegistry,
91
- events: pi.events,
92
101
  logger,
93
102
  requestPermissionDecisionFromUi,
103
+ };
104
+ const escalator = new ApprovalEscalator(escalatorDeps);
105
+
106
+ const requestServerDeps: ForwardedRequestServerDeps = {
107
+ forwardingDir: paths.forwardingDir,
108
+ logger,
109
+ events: pi.events,
110
+ requestPermissionDecisionFromUi,
94
111
  config: configStore,
95
112
  };
96
- const forwarder = new PermissionForwarder(forwardingDeps);
113
+ const requestServer = new ForwardedRequestServer(requestServerDeps);
97
114
 
98
115
  const prompter = new PermissionPrompter({
99
116
  logger,
100
117
  events: pi.events,
101
- forwarder,
118
+ forwarder: escalator,
102
119
  });
103
120
 
104
121
  const gateway = new PromptingGateway({
105
- subagentSessionsDir: paths.subagentSessionsDir,
106
- platform: hostPlatform,
107
- registry: subagentRegistry,
122
+ detection: subagentDetection,
108
123
  prompter,
109
124
  });
110
125
 
111
126
  session = new PermissionSession(
112
127
  paths,
113
- new ForwardingManager(
114
- paths.subagentSessionsDir,
115
- forwarder,
116
- hostPlatform,
117
- subagentRegistry,
118
- ),
128
+ new ForwardingManager(subagentDetection, requestServer),
119
129
  permissionManager,
120
130
  sessionRules,
121
131
  configStore,
@@ -139,17 +149,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
139
149
  });
140
150
 
141
151
  // Resolver composes the manager + session ruleset and owns the
142
- // access-path → path-values unwrap; the RPC and service route their policy
143
- // queries through it, so it is constructed before both.
152
+ // access-path → path-values unwrap; the service routes its policy
153
+ // queries through it, so it is constructed before it.
144
154
  const resolver = new PermissionResolver(permissionManager, sessionRules);
145
155
 
146
- const rpcHandles = registerPermissionRpcHandlers(pi.events, {
147
- resolver,
148
- session,
149
- requestPermissionDecisionFromUi,
150
- logger,
151
- });
152
-
153
156
  const permissionsService = new LocalPermissionsService(
154
157
  resolver,
155
158
  session,
@@ -171,9 +174,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
171
174
  // requires the session id from ctx, unavailable at factory-init time.
172
175
  const serviceLifecycle = new PermissionServiceLifecycle(
173
176
  permissionsService,
174
- subagentRegistry,
177
+ subagentDetection,
175
178
  pi.events,
176
- [rpcHandles.unsubCheck, rpcHandles.unsubPrompt, unsubSubagentLifecycle],
179
+ [unsubSubagentLifecycle],
177
180
  );
178
181
 
179
182
  const toolRegistry = {