@gotgenes/pi-permission-system 20.1.0 → 20.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [20.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.1.0...pi-permission-system-v20.2.0) (2026-07-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** serve forwarded permissions by resolution and Authorizer escalation ([#557](https://github.com/gotgenes/pi-packages/issues/557)) ([c5d3bcb](https://github.com/gotgenes/pi-packages/commit/c5d3bcbf4e69a568f750d6a7c0619d4b88d5b377))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** mark Phase 9 Step 3 complete ([a5a348f](https://github.com/gotgenes/pi-packages/commit/a5a348f3d3dc1835e64ecd7b6b0c1b6164085a97))
19
+
8
20
  ## [20.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.0.0...pi-permission-system-v20.1.0) (2026-07-08)
9
21
 
10
22
 
@@ -307,7 +307,8 @@ This event is for integrations such as notification extensions that should alert
307
307
  It is not a generic "permission request entered waiting state" event, and it does not imply the prompt will be approved.
308
308
  Policy decisions that resolve without an active UI prompt, such as `policy_allow`, `policy_deny`, `session_approved`, `infrastructure_auto_allowed`, or `auto_approved`, do not emit this event.
309
309
  Non-UI child sessions also do not emit this event when they create a forwarded permission request; the parent UI session emits it immediately before showing the forwarded permission dialog.
310
- Forwarded prompts are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
310
+ A forwarded request the parent's own recorded policy decides (a matching `allow` or `deny`) is answered without a prompt and emits no event; the event fires only when the parent is actually about to ask the human.
311
+ Forwarded prompts that do reach the human are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
311
312
 
312
313
  The payload is lean by design — `surface`/`value` are the normalized display projection a notification consumer reads, not a mirror of the internal review log.
313
314
  Read defensively rather than version-gating: broadcast payloads carry no `protocolVersion`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.1.0",
3
+ "version": "20.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,7 +28,7 @@ import {
28
28
  resolvePermissionForwardingTargetSessionId,
29
29
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
30
30
  } from "#src/permission-forwarding";
31
- import { buildDirectUiPrompt } from "#src/permission-ui-prompt";
31
+ import { buildUiPrompt } from "#src/permission-ui-prompt";
32
32
  import type { DebugReviewLogger } from "#src/session-logger";
33
33
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
34
34
  import { toRecord } from "#src/value-guards";
@@ -97,7 +97,7 @@ export class ParentAuthorizer implements Authorizer {
97
97
  authorize(
98
98
  details: PromptPermissionDetails,
99
99
  ): Promise<PermissionPromptDecision> {
100
- const uiPrompt = buildDirectUiPrompt(details);
100
+ const uiPrompt = buildUiPrompt(details);
101
101
  return this.waitForForwardedApproval(this.ctx, details.message, {
102
102
  source: uiPrompt.source,
103
103
  surface: uiPrompt.surface,
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { GatePrompter } from "#src/gate-prompter";
3
2
  import type { PermissionPromptDecision } from "#src/permission-dialog";
4
3
  import {
5
4
  type Authorizer,
@@ -23,23 +22,34 @@ export interface AuthorizerSelectionLifecycle {
23
22
  deactivate(): void;
24
23
  }
25
24
 
25
+ /**
26
+ * The ask-escalation seam `GateRunner` depends on: escalate a single ask to
27
+ * the session's selected `Authorizer` and return its decision.
28
+ *
29
+ * Replaces the two-method `GatePrompter` role (#556). There is no
30
+ * "can anyone answer" pre-check: absent authority is the `DenyingAuthorizer`,
31
+ * which answers by denying with a `confirmationUnavailable` marker.
32
+ */
33
+ export interface AskEscalator {
34
+ escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
35
+ }
36
+
26
37
  /**
27
38
  * Context-owning selection root for the Authorizer spine.
28
39
  *
29
40
  * The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
30
- * `selectAuthorizer` once per activation, and implements `GatePrompter` by
41
+ * `selectAuthorizer` once per activation, and implements `AskEscalator` by
31
42
  * delegating to the selected `Authorizer` via `PermissionPrompter`.
32
43
  *
33
- * `canConfirm()` survives this step (dissolved in #556): it is recomputed
34
- * transitionally alongside `selectAuthorizer`'s own branch, rather than
35
- * derived from the selected authorizer, to keep the ask path byte-identical
36
- * until #556 derives confirmability from a `DenyingAuthorizer` marker.
44
+ * `selectAuthorizer` encodes the liveness decision in *which* `Authorizer` it
45
+ * returns (`LocalUserAuthorizer` / `ParentAuthorizer` when authority is
46
+ * reachable, `DenyingAuthorizer` otherwise), so no separate confirmability
47
+ * predicate survives (#556 dissolved `canConfirm()`).
37
48
  */
38
49
  export class AuthorizerSelection
39
- implements GatePrompter, AuthorizerSelectionLifecycle
50
+ implements AskEscalator, AuthorizerSelectionLifecycle
40
51
  {
41
52
  private selected: Authorizer | null = null;
42
- private confirmable = false;
43
53
 
44
54
  constructor(
45
55
  private readonly deps: AuthorizerSelectionDeps & {
@@ -47,39 +57,28 @@ export class AuthorizerSelection
47
57
  },
48
58
  ) {}
49
59
 
50
- /** Select the Authorizer for `ctx` and store both it and the confirmable predicate. */
60
+ /** Select the Authorizer for `ctx` and store it. */
51
61
  activate(ctx: ExtensionContext): void {
52
62
  this.selected = selectAuthorizer(ctx, this.deps);
53
- this.confirmable = ctx.hasUI || this.deps.detection.isSubagent(ctx);
54
63
  }
55
64
 
56
65
  /** Clear the stored selection. */
57
66
  deactivate(): void {
58
67
  this.selected = null;
59
- this.confirmable = false;
60
- }
61
-
62
- /**
63
- * Whether an interactive permission prompt can be shown.
64
- *
65
- * Returns false when no authorizer has been selected. Otherwise true when
66
- * the context had UI or was a forwarding subagent at selection time — the
67
- * two Authorizer-selection predicates, evaluated once at `activate`.
68
- */
69
- canConfirm(): boolean {
70
- return this.selected !== null && this.confirmable;
71
68
  }
72
69
 
73
70
  /**
74
- * Prompt for a permission decision using the selected authorizer.
71
+ * Escalate an ask to the selected authorizer and return its decision.
75
72
  *
76
- * Rejects if no authorizer has been selected — `canConfirm()` guards this
77
- * in normal use. Implements {@link GatePrompter}.
73
+ * Rejects if no authorizer has been selected — i.e. before the session was
74
+ * activated. Implements {@link AskEscalator}.
78
75
  */
79
- prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
76
+ escalate(
77
+ details: PromptPermissionDetails,
78
+ ): Promise<PermissionPromptDecision> {
80
79
  if (this.selected === null) {
81
80
  return Promise.reject(
82
- new Error("prompt called before the session was activated"),
81
+ new Error("escalate called before the session was activated"),
83
82
  );
84
83
  }
85
84
  return this.deps.prompter.prompt(this.selected, details);
@@ -4,9 +4,17 @@ import type { Authorizer } from "./authorizer";
4
4
  /**
5
5
  * Least-privilege Authorizer: no authority is reachable for this session
6
6
  * (no UI, not a subagent), so every ask is denied.
7
+ *
8
+ * The denial carries the `confirmationUnavailable` marker so the ask path can
9
+ * distinguish "nobody could answer" from an interactive user denial when it
10
+ * derives the review-entry and decision-event resolution.
7
11
  */
8
12
  export class DenyingAuthorizer implements Authorizer {
9
13
  authorize(): Promise<PermissionPromptDecision> {
10
- return Promise.resolve({ approved: false, state: "denied" });
14
+ return Promise.resolve({
15
+ approved: false,
16
+ state: "denied",
17
+ confirmationUnavailable: true,
18
+ });
11
19
  }
12
20
  }
@@ -3,26 +3,17 @@ import {
3
3
  type ForwarderContext,
4
4
  getSessionId,
5
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";
6
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
17
7
  import {
18
8
  type ForwardedPermissionRequest,
19
9
  type ForwardedPermissionResponse,
20
10
  isForwardedPermissionRequestForSession,
21
11
  type PermissionForwardingLocation,
22
12
  } from "#src/permission-forwarding";
23
- import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
24
13
  import type { DebugReviewLogger } from "#src/session-logger";
25
-
14
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
15
+ import type { PermissionCheckResult } from "#src/types";
16
+ import type { AskEscalator } from "./authorizer-selection";
26
17
  import {
27
18
  cleanupPermissionForwardingLocationIfEmpty,
28
19
  ensureDirectoryExists,
@@ -34,6 +25,7 @@ import {
34
25
  safeDeleteFile,
35
26
  writeJsonFileAtomic,
36
27
  } from "./forwarding-io";
28
+ import type { PromptPermissionDetails } from "./permission-prompter";
37
29
 
38
30
  /**
39
31
  * Narrow seam describing what `ForwardingManager` needs from the server: a
@@ -47,20 +39,30 @@ export interface InboxProcessor {
47
39
  processInbox(ctx: ForwarderContext): Promise<void>;
48
40
  }
49
41
 
42
+ /**
43
+ * Recorded-authority view the serving node resolves a forwarded request
44
+ * against: answer one `(surface, value)` query on the serving session's
45
+ * composed base ruleset (agent-neutral — the child already applied its own
46
+ * per-agent overrides before forwarding).
47
+ *
48
+ * Narrow by design (ISP): the server needs one decision, not the whole
49
+ * resolver. The composition root satisfies it with an access-intent build plus
50
+ * `resolver.resolve`, the same primitives `LocalPermissionsService` composes.
51
+ */
52
+ export interface ServingPolicy {
53
+ check(surface: string, value: string | null): PermissionCheckResult;
54
+ }
55
+
50
56
  /** Constructor config for `ForwardedRequestServer`. */
51
57
  export interface ForwardedRequestServerDeps {
52
58
  forwardingDir: string;
53
59
  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;
60
+ /** Recorded-authority resolution for `(surface, value)` requests. */
61
+ policy: ServingPolicy;
62
+ /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
63
+ escalator: AskEscalator;
64
+ /** In-process subagent registry, read only by the one-hop canary. */
65
+ registry?: SubagentSessionRegistry;
64
66
  }
65
67
 
66
68
  // ── Module-private helpers ────────────────────────────────────────────────
@@ -78,39 +80,70 @@ function formatForwardedPermissionPrompt(
78
80
  ].join("\n");
79
81
  }
80
82
 
83
+ /**
84
+ * A request is resolvable against the ruleset only when it carries a concrete
85
+ * `(surface, value)` display projection. A legacy/version-skew request without
86
+ * them floors to `ask` (escalate), never a silent grant.
87
+ */
88
+ function hasDisplayFields(
89
+ request: ForwardedPermissionRequest,
90
+ ): request is ForwardedPermissionRequest & { surface: string; value: string } {
91
+ return (
92
+ typeof request.surface === "string" &&
93
+ request.surface.length > 0 &&
94
+ typeof request.value === "string" &&
95
+ request.value.length > 0
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Map a forwarded request onto the escalated ask's details, carrying the
101
+ * forwarded provenance (requester agent/session + the child's original display
102
+ * projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292).
103
+ */
104
+ function buildForwardedAskDetails(
105
+ request: ForwardedPermissionRequest,
106
+ ): PromptPermissionDetails {
107
+ return {
108
+ requestId: request.id,
109
+ source: request.source ?? "tool_call",
110
+ agentName: request.requesterAgentName || null,
111
+ message: formatForwardedPermissionPrompt(request),
112
+ surface: request.surface ?? null,
113
+ value: request.value ?? null,
114
+ forwarding: {
115
+ requesterAgentName: request.requesterAgentName || null,
116
+ requesterSessionId: request.requesterSessionId || null,
117
+ },
118
+ };
119
+ }
120
+
81
121
  // ── ForwardedRequestServer ────────────────────────────────────────────────
82
122
 
83
123
  /**
84
124
  * Owner of the serving-down role of the forwarded-permission behavior:
85
125
  * draining this session's forwarded-permission inbox and answering each
86
- * request (auto-approve under yolo, or prompt via the injected UI).
126
+ * request the same way the session resolves a local action — `evaluate()`
127
+ * against its recorded authority (`ServingPolicy`), then escalation to its
128
+ * selected `Authorizer` (`AskEscalator`) on `ask`.
87
129
  */
88
130
  export class ForwardedRequestServer implements InboxProcessor {
89
131
  private readonly forwardingDir: string;
90
- private readonly events: PermissionEventBus | undefined;
91
132
  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;
133
+ private readonly policy: ServingPolicy;
134
+ private readonly escalator: AskEscalator;
135
+ private readonly registry: SubagentSessionRegistry | undefined;
99
136
 
100
137
  constructor(deps: ForwardedRequestServerDeps) {
101
138
  this.forwardingDir = deps.forwardingDir;
102
- this.events = deps.events;
103
139
  this.logger = deps.logger;
104
- this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
105
- this.config = deps.config;
140
+ this.policy = deps.policy;
141
+ this.escalator = deps.escalator;
142
+ this.registry = deps.registry;
106
143
  }
107
144
 
108
145
  /** Drain and respond to this session's forwarded-permission inbox. */
109
146
  async processInbox(ctx: ForwarderContext): Promise<void> {
110
- if (!ctx.hasUI) {
111
- return;
112
- }
113
-
114
147
  const currentSessionId = getSessionId(ctx);
115
148
  const location = getExistingPermissionForwardingLocation(
116
149
  this.forwardingDir,
@@ -152,7 +185,6 @@ export class ForwardedRequestServer implements InboxProcessor {
152
185
  }
153
186
 
154
187
  await this.processSingleForwardedRequest(
155
- ctx,
156
188
  request,
157
189
  location,
158
190
  requestPath,
@@ -166,7 +198,6 @@ export class ForwardedRequestServer implements InboxProcessor {
166
198
  // ── Private methods ────────────────────────────────────────────────────
167
199
 
168
200
  private async processSingleForwardedRequest(
169
- ctx: ForwarderContext,
170
201
  request: ForwardedPermissionRequest,
171
202
  location: PermissionForwardingLocation,
172
203
  requestPath: string,
@@ -185,6 +216,8 @@ export class ForwardedRequestServer implements InboxProcessor {
185
216
  return;
186
217
  }
187
218
 
219
+ this.warnOnMultiHop(request, currentSessionId);
220
+
188
221
  const forwardedPermissionLogDetails = {
189
222
  requestId: request.id,
190
223
  source: location.label,
@@ -194,55 +227,32 @@ export class ForwardedRequestServer implements InboxProcessor {
194
227
  requestPath,
195
228
  };
196
229
 
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
- }
230
+ const decision = await this.resolveDecision(
231
+ request,
232
+ forwardedPermissionLogDetails,
233
+ );
245
234
 
235
+ this.recordForwardedDecision(
236
+ request,
237
+ location,
238
+ requestPath,
239
+ currentSessionId,
240
+ decision,
241
+ );
242
+ }
243
+
244
+ /**
245
+ * Persist the served decision: write the response file the child polls for,
246
+ * log the outcome, and delete the drained request. The symmetric "respond"
247
+ * half to {@link resolveDecision}'s "decide" half.
248
+ */
249
+ private recordForwardedDecision(
250
+ request: ForwardedPermissionRequest,
251
+ location: PermissionForwardingLocation,
252
+ requestPath: string,
253
+ currentSessionId: string,
254
+ decision: PermissionPromptDecision,
255
+ ): void {
246
256
  const responsePath = join(location.responsesDir, `${request.id}.json`);
247
257
  this.logger.review(
248
258
  decision.approved
@@ -282,4 +292,68 @@ export class ForwardedRequestServer implements InboxProcessor {
282
292
  `${location.label} forwarded permission request`,
283
293
  );
284
294
  }
295
+
296
+ /**
297
+ * Resolve the request the same way the session resolves a local action:
298
+ * recorded authority first (a request carrying `(surface, value)` resolves
299
+ * against the serving node's composed ruleset — `allow`, including
300
+ * yolo-rewritten, auto-approves; `deny` auto-denies), then escalate `ask`
301
+ * (or a request without display fields) to the selected `Authorizer`.
302
+ */
303
+ private async resolveDecision(
304
+ request: ForwardedPermissionRequest,
305
+ logDetails: Record<string, unknown>,
306
+ ): Promise<PermissionPromptDecision> {
307
+ const state = hasDisplayFields(request)
308
+ ? this.policy.check(request.surface, request.value).state
309
+ : "ask";
310
+
311
+ if (state === "allow") {
312
+ this.logger.review("forwarded_permission.auto_approved", logDetails);
313
+ return { approved: true, state: "approved" };
314
+ }
315
+ if (state === "deny") {
316
+ this.logger.review("forwarded_permission.auto_denied", logDetails);
317
+ return { approved: false, state: "denied" };
318
+ }
319
+
320
+ this.logger.review("forwarded_permission.prompted", logDetails);
321
+ try {
322
+ return await this.escalator.escalate(buildForwardedAskDetails(request));
323
+ } catch (error) {
324
+ logPermissionForwardingError(
325
+ this.logger,
326
+ `Failed to escalate forwarded permission request '${request.id}'`,
327
+ error,
328
+ );
329
+ return { approved: false, state: "denied" };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * One-hop canary: forwarding is depth-1 (child → root). If the requester is
335
+ * itself a registered subagent whose parent is not this serving session, the
336
+ * request came through more than one hop (or was misrouted) — resolution is
337
+ * still well-defined, so keep serving, but warn loudly so a future
338
+ * recursion-guard break is visible rather than silent. Unregistered
339
+ * (external file-based) requesters have no recorded parent and are silent.
340
+ */
341
+ private warnOnMultiHop(
342
+ request: ForwardedPermissionRequest,
343
+ currentSessionId: string,
344
+ ): void {
345
+ const requesterInfo = this.registry?.get(request.requesterSessionId);
346
+ if (
347
+ requesterInfo?.parentSessionId &&
348
+ requesterInfo.parentSessionId !== currentSessionId
349
+ ) {
350
+ logPermissionForwardingWarning(
351
+ this.logger,
352
+ `Forwarded permission request '${request.id}' violates the one-hop ` +
353
+ `invariant: requester '${request.requesterSessionId}' is a registered ` +
354
+ `subagent whose parent '${requesterInfo.parentSessionId}' is not this ` +
355
+ `serving session '${currentSessionId}' (multi-hop or misrouted).`,
356
+ );
357
+ }
358
+ }
285
359
  }
@@ -7,7 +7,7 @@ import {
7
7
  emitUiPromptEvent,
8
8
  type PermissionEventBus,
9
9
  } from "#src/permission-events";
10
- import { buildDirectUiPrompt } from "#src/permission-ui-prompt";
10
+ import { buildUiPrompt } from "#src/permission-ui-prompt";
11
11
  import type { Authorizer } from "./authorizer";
12
12
  import type { PromptPermissionDetails } from "./permission-prompter";
13
13
 
@@ -26,7 +26,10 @@ export interface LocalUserAuthorizerDeps {
26
26
  *
27
27
  * Emits the `permissions:ui_prompt` broadcast (moved here from
28
28
  * `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
29
- * observers know a decision is imminent.
29
+ * observers know a decision is imminent. This is the single emit site: a
30
+ * forwarded ask carries its provenance on `details.forwarding`, which this
31
+ * class renders (populated `forwarding` context + "(Subagent)" title) so the
32
+ * broadcast stays non-degraded (#292) without a second emission path.
30
33
  */
31
34
  export class LocalUserAuthorizer implements Authorizer {
32
35
  constructor(private readonly deps: LocalUserAuthorizerDeps) {}
@@ -34,11 +37,13 @@ export class LocalUserAuthorizer implements Authorizer {
34
37
  authorize(
35
38
  details: PromptPermissionDetails,
36
39
  ): Promise<PermissionPromptDecision> {
37
- const uiPrompt = buildDirectUiPrompt(details);
40
+ const uiPrompt = buildUiPrompt(details);
38
41
  emitUiPromptEvent(this.deps.events, uiPrompt);
39
42
  return this.deps.requestPermissionDecisionFromUi(
40
43
  this.deps.ui,
41
- "Permission Required",
44
+ details.forwarding
45
+ ? "Permission Required (Subagent)"
46
+ : "Permission Required",
42
47
  details.message,
43
48
  details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
44
49
  );
@@ -4,6 +4,19 @@ import type { Authorizer } from "./authorizer";
4
4
 
5
5
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
6
6
 
7
+ /**
8
+ * Provenance of a forwarded ask: who is really asking, one hop below.
9
+ *
10
+ * Present on {@link PromptPermissionDetails} only when the ask was forwarded
11
+ * from a subagent. Structurally identical to the event's `ForwardedPromptContext`
12
+ * so the details flow straight into `buildUiPrompt`, but declared here to keep
13
+ * the prompter layer free of an events-module import.
14
+ */
15
+ export interface ForwardedAskProvenance {
16
+ requesterAgentName: string | null;
17
+ requesterSessionId: string | null;
18
+ }
19
+
7
20
  /** Details passed when prompting the user for a permission decision. */
8
21
  export interface PromptPermissionDetails {
9
22
  requestId: string;
@@ -19,6 +32,12 @@ export interface PromptPermissionDetails {
19
32
  toolInputPreview?: string;
20
33
  /** Override label for the "for this session" dialog option. */
21
34
  sessionLabel?: string;
35
+ /** Explicit display-surface override (a forwarded ask carries the child's original). */
36
+ surface?: string | null;
37
+ /** Explicit display-value override (a forwarded ask carries the child's original). */
38
+ value?: string | null;
39
+ /** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
40
+ forwarding?: ForwardedAskProvenance;
22
41
  }
23
42
 
24
43
  /**
@@ -75,7 +94,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
75
94
  : "permission_request.denied",
76
95
  {
77
96
  ...details,
78
- resolution: decision.state,
97
+ resolution: decision.confirmationUnavailable
98
+ ? "confirmation_unavailable"
99
+ : decision.state,
79
100
  denialReason: decision.denialReason,
80
101
  },
81
102
  );
@@ -53,13 +53,14 @@ export function buildDecisionEvent(
53
53
  * @param action - The gate's resulting action ("allow" | "block").
54
54
  * @param hasSession - True when the gate result carries a sessionApproval
55
55
  * (indicates the user chose "for this session").
56
- * @param canConfirm - Whether an interactive prompt was available.
56
+ * @param confirmationUnavailable - True when the denial came from the
57
+ * DenyingAuthorizer (no live authority was reachable).
57
58
  */
58
59
  export function deriveResolution(
59
60
  state: "allow" | "deny" | "ask",
60
61
  action: "allow" | "block",
61
62
  hasSession: boolean,
62
- canConfirm: boolean,
63
+ confirmationUnavailable: boolean,
63
64
  autoApproved = false,
64
65
  ): PermissionDecisionResolution {
65
66
  if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
@@ -69,5 +70,5 @@ export function deriveResolution(
69
70
  if (autoApproved) return "auto_approved";
70
71
  return hasSession ? "user_approved_for_session" : "user_approved";
71
72
  }
72
- return canConfirm ? "user_denied" : "confirmation_unavailable";
73
+ return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
73
74
  }
@@ -1,10 +1,10 @@
1
+ import type { AskEscalator } from "#src/authority/authorizer-selection";
1
2
  import type { DecisionReporter } from "#src/decision-reporter";
2
3
  import {
3
4
  formatDenyReason,
4
5
  formatUnavailableReason,
5
6
  formatUserDeniedReason,
6
7
  } from "#src/denial-messages";
7
- import type { GatePrompter } from "#src/gate-prompter";
8
8
  import type { PermissionPromptDecision } from "#src/permission-dialog";
9
9
  import { applyPermissionGate } from "#src/permission-gate";
10
10
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
@@ -30,7 +30,7 @@ export class GateRunner {
30
30
  constructor(
31
31
  private readonly resolver: ScopedPermissionResolver,
32
32
  private readonly recorder: SessionApprovalRecorder,
33
- private readonly prompter: GatePrompter,
33
+ private readonly prompter: AskEscalator,
34
34
  private readonly reporter: DecisionReporter,
35
35
  ) {}
36
36
 
@@ -127,8 +127,8 @@ export class GateRunner {
127
127
  return { action: "allow" };
128
128
  }
129
129
 
130
- // 3. Apply the deny/ask/allow gate
131
- const canConfirm = this.prompter.canConfirm();
130
+ // 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
131
+ // Authorizer answers (the DenyingAuthorizer by denying with a marker).
132
132
 
133
133
  // Construct messages from the centralized formatter.
134
134
  const messages = {
@@ -139,16 +139,17 @@ export class GateRunner {
139
139
  };
140
140
 
141
141
  let autoApproved = false;
142
+ let confirmationUnavailable = false;
142
143
  const gateResult = await applyPermissionGate({
143
144
  state: check.state,
144
- canConfirm,
145
145
  sessionApproval: descriptor.sessionApproval?.toGateApproval(),
146
146
  promptForApproval: async () => {
147
- const decision = await this.prompter.prompt({
147
+ const decision = await this.prompter.escalate({
148
148
  requestId: toolCallId,
149
149
  ...descriptor.promptDetails,
150
150
  });
151
151
  autoApproved = decision.autoApproved === true;
152
+ confirmationUnavailable = decision.confirmationUnavailable === true;
152
153
  return decision;
153
154
  },
154
155
  writeLog: (event, details) =>
@@ -172,7 +173,7 @@ export class GateRunner {
172
173
  check.state,
173
174
  gateResult.action,
174
175
  hasSessionApproval,
175
- canConfirm,
176
+ confirmationUnavailable,
176
177
  autoApproved,
177
178
  ),
178
179
  ),
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
3
  import { AuthorizerSelection } from "./authority/authorizer-selection";
4
4
  import {
5
5
  ForwardedRequestServer,
6
- type ForwardedRequestServerDeps,
6
+ type ServingPolicy,
7
7
  } from "./authority/forwarded-request-server";
8
8
  import { PermissionPrompter } from "./authority/permission-prompter";
9
9
  import { SubagentDetection } from "./authority/subagent-detection";
@@ -25,6 +25,7 @@ import { GateRunner } from "./handlers/gates/runner";
25
25
  import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
26
26
  import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
27
27
  import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
28
+ import { buildAccessIntentForSurface } from "./input-normalizer";
28
29
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
29
30
  import { PermissionManager } from "./permission-manager";
30
31
  import { PermissionResolver } from "./permission-resolver";
@@ -90,15 +91,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
90
91
  logger,
91
92
  });
92
93
 
93
- const requestServerDeps: ForwardedRequestServerDeps = {
94
- forwardingDir: paths.forwardingDir,
95
- logger,
96
- events: pi.events,
97
- requestPermissionDecisionFromUi,
98
- config: configStore,
99
- };
100
- const requestServer = new ForwardedRequestServer(requestServerDeps);
101
-
102
94
  const prompter = new PermissionPrompter({ logger });
103
95
 
104
96
  const authorizerSelection = new AuthorizerSelection({
@@ -111,6 +103,38 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
111
103
  prompter,
112
104
  });
113
105
 
106
+ // Resolver composes the manager + session ruleset and owns the
107
+ // access-path → path-values unwrap. Constructed here (before `session`) so
108
+ // the forwarded-request server's ServingPolicy can resolve against it; the
109
+ // service and gates below share this one instance.
110
+ const resolver = new PermissionResolver(permissionManager, sessionRules);
111
+
112
+ // Serving a forwarded request is resolution: evaluate (surface, value)
113
+ // against the serving node's composed base ruleset (agentName undefined —
114
+ // the child already applied its own per-agent overrides before forwarding).
115
+ // The session.getPathNormalizer() read is deferred behind the closure: inbox
116
+ // polling starts at session_start, after `session` is assigned — the same
117
+ // deferred-binding precedent as the logger notify sink below.
118
+ const servingPolicy: ServingPolicy = {
119
+ check: (surface, value) =>
120
+ resolver.resolve(
121
+ buildAccessIntentForSurface(
122
+ surface,
123
+ value ?? undefined,
124
+ session.getPathNormalizer(),
125
+ undefined,
126
+ ),
127
+ ),
128
+ };
129
+
130
+ const requestServer = new ForwardedRequestServer({
131
+ forwardingDir: paths.forwardingDir,
132
+ logger,
133
+ policy: servingPolicy,
134
+ escalator: authorizerSelection,
135
+ registry: subagentRegistry,
136
+ });
137
+
114
138
  session = new PermissionSession(
115
139
  paths,
116
140
  new ForwardingManager(subagentDetection, requestServer),
@@ -136,11 +160,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
136
160
  ),
137
161
  });
138
162
 
139
- // Resolver composes the manager + session ruleset and owns the
140
- // access-path → path-values unwrap; the service routes its policy
141
- // queries through it, so it is constructed before it.
142
- const resolver = new PermissionResolver(permissionManager, sessionRules);
143
-
144
163
  const permissionsService = new LocalPermissionsService(
145
164
  resolver,
146
165
  session,
@@ -14,6 +14,14 @@ export type PermissionPromptDecision = {
14
14
  * rather than "user_approved" in the permissions:decision broadcast.
15
15
  */
16
16
  autoApproved?: true;
17
+ /**
18
+ * True when no live authority was reachable and the DenyingAuthorizer denied
19
+ * this ask (a no-UI, non-subagent session). Consumed by deriveResolution (the
20
+ * decision-event resolution), the gate (block reason), and PermissionPrompter
21
+ * (review-entry resolution) to emit "confirmation_unavailable" rather than a
22
+ * plain user denial.
23
+ */
24
+ confirmationUnavailable?: true;
17
25
  };
18
26
 
19
27
  export interface PermissionDecisionUi {
@@ -44,7 +44,8 @@ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
44
44
  * can emit a non-degraded `permissions:ui_prompt` event.
45
45
  *
46
46
  * Carried separately from the prompt message because the parent reconstructs
47
- * the original event through `buildForwardedUiPrompt`, not from the message text.
47
+ * the original event from the escalated ask's details (`buildUiPrompt`), not
48
+ * from the message text.
48
49
  */
49
50
  export interface ForwardedPromptDisplay {
50
51
  source: PermissionUiPromptSource;
@@ -10,10 +10,11 @@ export interface PermissionGateParams {
10
10
  /** The resolved permission state from checkPermission(). */
11
11
  state: "allow" | "deny" | "ask";
12
12
 
13
- /** Whether the current context supports interactive prompts. */
14
- canConfirm: boolean;
15
-
16
- /** Prompt the user for approval. Only called when state === "ask" and canConfirm is true. */
13
+ /**
14
+ * Escalate the ask to the session's Authorizer for a decision. Called for
15
+ * every `ask`; the DenyingAuthorizer answers by denying with the
16
+ * `confirmationUnavailable` marker when no live authority is reachable.
17
+ */
17
18
  promptForApproval: () => Promise<PermissionPromptDecision>;
18
19
 
19
20
  /**
@@ -45,14 +46,7 @@ export interface PermissionGateParams {
45
46
  export async function applyPermissionGate(
46
47
  params: PermissionGateParams,
47
48
  ): Promise<PermissionGateResult> {
48
- const {
49
- state,
50
- canConfirm,
51
- promptForApproval,
52
- writeLog,
53
- logContext,
54
- messages,
55
- } = params;
49
+ const { state, promptForApproval, writeLog, logContext, messages } = params;
56
50
 
57
51
  if (state === "deny") {
58
52
  writeLog("permission_request.blocked", {
@@ -63,17 +57,17 @@ export async function applyPermissionGate(
63
57
  }
64
58
 
65
59
  if (state === "ask") {
66
- if (!canConfirm) {
67
- writeLog("permission_request.blocked", {
68
- ...logContext,
69
- resolution: "confirmation_unavailable",
70
- });
71
- return { action: "block", reason: messages.unavailableReason };
72
- }
73
-
74
60
  const decision = await promptForApproval();
75
61
  if (!decision.approved) {
76
- return { action: "block", reason: messages.userDeniedReason(decision) };
62
+ // The gate writes no review entry for an ask denial — the prompter
63
+ // brackets it (waiting/denied). The block reason distinguishes an
64
+ // absent-authority denial (confirmationUnavailable) from a user denial.
65
+ return {
66
+ action: "block",
67
+ reason: decision.confirmationUnavailable
68
+ ? messages.unavailableReason
69
+ : messages.userDeniedReason(decision),
70
+ };
77
71
  }
78
72
  if (decision.state === "approved_for_session" && params.sessionApproval) {
79
73
  return { action: "allow", sessionApproval: params.sessionApproval };
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Centralized construction for `permissions:ui_prompt` payloads.
3
3
  *
4
- * Every emit site builds its event through one of these functions, so the
5
- * public contract's shape — including the normalized `surface`/`value`
6
- * projection — lives in exactly one place and cannot drift by source.
4
+ * The single builder `buildUiPrompt` handles both direct and forwarded asks, so
5
+ * the public contract's shape — including the normalized `surface`/`value`
6
+ * projection and the `forwarding` context — lives in exactly one place and
7
+ * cannot drift by source.
7
8
  *
8
9
  * This module is a leaf: it owns narrow input types that each call site's
9
10
  * domain object satisfies structurally, so it imports nothing from the
@@ -11,8 +12,8 @@
11
12
  */
12
13
 
13
14
  import type {
15
+ ForwardedPromptContext,
14
16
  PermissionUiPromptEvent,
15
- PermissionUiPromptSource,
16
17
  } from "./permission-events";
17
18
 
18
19
  /** Input for a direct (non-forwarded) tool or skill prompt. */
@@ -28,18 +29,42 @@ export interface DirectPromptInput {
28
29
  target?: string;
29
30
  }
30
31
 
31
- /** Input for a file-forwarded subagent prompt shown by the parent UI. */
32
- export interface ForwardedPromptInput {
33
- requestId: string;
34
- message: string;
35
- requesterAgentName: string | null;
36
- requesterSessionId: string | null;
37
- /** Original prompt origin, when the forwarded request carries it. */
38
- source?: PermissionUiPromptSource | null;
39
- /** Original normalized surface, when the forwarded request carries it. */
32
+ /**
33
+ * Input for any UI prompt — direct or forwarded.
34
+ *
35
+ * A direct prompt supplies only the `DirectPromptInput` fields and lets
36
+ * `surface`/`value` derive and `forwarding` default to `null`; a forwarded ask
37
+ * supplies the child's original `surface`/`value` projection explicitly and a
38
+ * populated `forwarding` context, so the parent's broadcast stays non-degraded
39
+ * (the #292 contract hardening).
40
+ */
41
+ export interface UiPromptInput extends DirectPromptInput {
42
+ /** Explicit display surface; falls back to the derived projection when omitted. */
40
43
  surface?: string | null;
41
- /** Original normalized value, when the forwarded request carries it. */
44
+ /** Explicit display value; falls back to the derived projection when omitted. */
42
45
  value?: string | null;
46
+ /** Forwarding context for a forwarded subagent ask; `null`/omitted for a direct prompt. */
47
+ forwarding?: ForwardedPromptContext | null;
48
+ }
49
+
50
+ /**
51
+ * Build a `permissions:ui_prompt` event from either a direct or a forwarded ask.
52
+ *
53
+ * `surface`/`value` use the explicit override when the caller sets them (an
54
+ * explicit `null` is honored, not treated as "derive"); otherwise they fall
55
+ * back to the direct-prompt projection. `forwarding` passes through, defaulting
56
+ * to `null`.
57
+ */
58
+ export function buildUiPrompt(input: UiPromptInput): PermissionUiPromptEvent {
59
+ return {
60
+ requestId: input.requestId,
61
+ source: input.source,
62
+ surface: input.surface !== undefined ? input.surface : directSurface(input),
63
+ value: input.value !== undefined ? input.value : directValue(input),
64
+ agentName: input.agentName,
65
+ message: input.message,
66
+ forwarding: input.forwarding ?? null,
67
+ };
43
68
  }
44
69
 
45
70
  /** Normalized display surface for a direct prompt. */
@@ -61,43 +86,3 @@ function directValue(input: DirectPromptInput): string | null {
61
86
  null
62
87
  );
63
88
  }
64
-
65
- /** Build the UI prompt event for a direct tool/skill prompt. */
66
- export function buildDirectUiPrompt(
67
- input: DirectPromptInput,
68
- ): PermissionUiPromptEvent {
69
- return {
70
- requestId: input.requestId,
71
- source: input.source,
72
- surface: directSurface(input),
73
- value: directValue(input),
74
- agentName: input.agentName,
75
- message: input.message,
76
- forwarding: null,
77
- };
78
- }
79
-
80
- /**
81
- * Build the UI prompt event for a file-forwarded subagent prompt.
82
- *
83
- * `source` defaults to `"tool_call"` (the dominant forwarded origin) when the
84
- * persisted request predates carrying it — a parent on a newer version may read
85
- * a request written by an older child during an upgrade. The consumer still
86
- * receives the notify-now signal, message, and forwarding context.
87
- */
88
- export function buildForwardedUiPrompt(
89
- input: ForwardedPromptInput,
90
- ): PermissionUiPromptEvent {
91
- return {
92
- requestId: input.requestId,
93
- source: input.source ?? "tool_call",
94
- surface: input.surface ?? null,
95
- value: input.value ?? null,
96
- agentName: input.requesterAgentName,
97
- message: input.message,
98
- forwarding: {
99
- requesterAgentName: input.requesterAgentName,
100
- requesterSessionId: input.requesterSessionId,
101
- },
102
- };
103
- }
@@ -1,12 +0,0 @@
1
- import type { PromptPermissionDetails } from "./authority/permission-prompter";
2
- import type { PermissionPromptDecision } from "./permission-dialog";
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
- }