@gotgenes/pi-permission-system 20.0.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.
@@ -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
  }
@@ -0,0 +1,51 @@
1
+ import type {
2
+ PermissionDecisionUi,
3
+ PermissionPromptDecision,
4
+ requestPermissionDecisionFromUi,
5
+ } from "#src/permission-dialog";
6
+ import {
7
+ emitUiPromptEvent,
8
+ type PermissionEventBus,
9
+ } from "#src/permission-events";
10
+ import { buildUiPrompt } from "#src/permission-ui-prompt";
11
+ import type { Authorizer } from "./authorizer";
12
+ import type { PromptPermissionDetails } from "./permission-prompter";
13
+
14
+ /** Dependencies required by {@link LocalUserAuthorizer}. */
15
+ export interface LocalUserAuthorizerDeps {
16
+ /** The active session's UI surface. */
17
+ ui: PermissionDecisionUi;
18
+ /** Event bus used for the `permissions:ui_prompt` broadcast. */
19
+ events: PermissionEventBus;
20
+ /** Injected for testability; production callers pass the real function. */
21
+ requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
22
+ }
23
+
24
+ /**
25
+ * Authorizer for a session with an active UI: prompt the human here.
26
+ *
27
+ * Emits the `permissions:ui_prompt` broadcast (moved here from
28
+ * `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
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.
33
+ */
34
+ export class LocalUserAuthorizer implements Authorizer {
35
+ constructor(private readonly deps: LocalUserAuthorizerDeps) {}
36
+
37
+ authorize(
38
+ details: PromptPermissionDetails,
39
+ ): Promise<PermissionPromptDecision> {
40
+ const uiPrompt = buildUiPrompt(details);
41
+ emitUiPromptEvent(this.deps.events, uiPrompt);
42
+ return this.deps.requestPermissionDecisionFromUi(
43
+ this.deps.ui,
44
+ details.forwarding
45
+ ? "Permission Required (Subagent)"
46
+ : "Permission Required",
47
+ details.message,
48
+ details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
49
+ );
50
+ }
51
+ }
@@ -1,15 +1,22 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { ApprovalRequester } from "./authority/approval-escalator";
3
- import type { PermissionPromptDecision } from "./permission-dialog";
4
- import {
5
- emitUiPromptEvent,
6
- type PermissionEventBus,
7
- } from "./permission-events";
8
- import { buildDirectUiPrompt } from "./permission-ui-prompt";
9
- import type { ReviewLogger } from "./session-logger";
1
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
2
+ import type { ReviewLogger } from "#src/session-logger";
3
+ import type { Authorizer } from "./authorizer";
10
4
 
11
5
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
12
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
+
13
20
  /** Details passed when prompting the user for a permission decision. */
14
21
  export interface PromptPermissionDetails {
15
22
  requestId: string;
@@ -25,74 +32,61 @@ export interface PromptPermissionDetails {
25
32
  toolInputPreview?: string;
26
33
  /** Override label for the "for this session" dialog option. */
27
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;
28
41
  }
29
42
 
30
- /** Mockable contract for permission prompting. */
43
+ /**
44
+ * Narrow seam onto {@link PermissionPrompter}.
45
+ *
46
+ * Kept separate from the concrete class so consumers (e.g. `AuthorizerSelection`)
47
+ * can inject a plain `{ prompt: vi.fn() }` mock in tests — a private field on
48
+ * the concrete class would create a nominal brand that a structural mock
49
+ * cannot satisfy without a cast.
50
+ */
31
51
  export interface PermissionPrompterApi {
32
52
  prompt(
33
- ctx: ExtensionContext,
53
+ authorizer: Authorizer,
34
54
  details: PromptPermissionDetails,
35
55
  ): Promise<PermissionPromptDecision>;
36
56
  }
37
57
 
38
- /**
39
- * Dependencies required by PermissionPrompter.
40
- *
41
- * Keeps the prompter's external surface narrow: callers provide a review
42
- * logger, the UI-prompt event bus, and the forwarder that owns the
43
- * UI/subagent-forwarding branching logic.
44
- */
58
+ /** Dependencies required by {@link PermissionPrompter}. */
45
59
  export interface PermissionPrompterDeps {
46
60
  /** Write structured entries to the permission review log. */
47
61
  logger: ReviewLogger;
48
- /** Event bus used for UI prompt broadcasts. */
49
- events: PermissionEventBus;
50
- /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
51
- forwarder: ApprovalRequester;
52
62
  }
53
63
 
54
64
  /**
55
- * Encapsulates the full permission-prompt flow:
65
+ * Brackets the ask-path flow with review-log entries and delegates the
66
+ * live decision to the selected {@link Authorizer}:
56
67
  * 1. Review-log "waiting" entry.
57
- * 2. UI-present vs. subagent-forwarding branching (via confirmPermission).
68
+ * 2. `authorizer.authorize(details)`.
58
69
  * 3. Review-log "approved" / "denied" entry.
59
70
  *
71
+ * The UI/forwarding branching this class previously owned now lives on the
72
+ * individual `Authorizer` implementations (`LocalUserAuthorizer`,
73
+ * `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
74
+ * `ExtensionContext` per call.
75
+ *
60
76
  * Yolo-mode auto-approval happens upstream, at the composition stage
61
77
  * (`PermissionManager.check`'s `rewriteAsksToYolo`) — an `ask` never reaches
62
78
  * this class under yolo, so this class has no yolo-mode knowledge.
63
- *
64
- * Injecting a single PermissionPrompter instance means adding a new prompt
65
- * parameter (e.g. a future sessionLabel variant) only requires changing
66
- * PromptPermissionDetails and this class — not the full threading chain.
67
79
  */
68
80
  export class PermissionPrompter implements PermissionPrompterApi {
69
81
  constructor(private readonly deps: PermissionPrompterDeps) {}
70
82
 
71
83
  async prompt(
72
- ctx: ExtensionContext,
84
+ authorizer: Authorizer,
73
85
  details: PromptPermissionDetails,
74
86
  ): Promise<PermissionPromptDecision> {
75
87
  this.writeReviewEntry("permission_request.waiting", details);
76
88
 
77
- // Build the event once. When this session has UI it broadcasts directly;
78
- // when it does not (a forwarding subagent), the display fields ride along
79
- // to the parent so the parent emits a non-degraded event from the
80
- // forwarded path instead of here.
81
- const uiPrompt = buildDirectUiPrompt(details);
82
- if (ctx.hasUI) {
83
- emitUiPromptEvent(this.deps.events, uiPrompt);
84
- }
85
-
86
- const decision = await this.deps.forwarder.requestApproval(
87
- ctx,
88
- details.message,
89
- details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
90
- {
91
- source: uiPrompt.source,
92
- surface: uiPrompt.surface,
93
- value: uiPrompt.value,
94
- },
95
- );
89
+ const decision = await authorizer.authorize(details);
96
90
 
97
91
  this.writeReviewEntry(
98
92
  decision.approved
@@ -100,7 +94,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
100
94
  : "permission_request.denied",
101
95
  {
102
96
  ...details,
103
- resolution: decision.state,
97
+ resolution: decision.confirmationUnavailable
98
+ ? "confirmation_unavailable"
99
+ : decision.state,
104
100
  denialReason: decision.denialReason,
105
101
  },
106
102
  );
@@ -8,7 +8,7 @@ import type { SubagentSessionRegistry } from "#src/subagent-registry";
8
8
  /**
9
9
  * Narrow seam for the ask-path consumers: "is the current session a subagent?"
10
10
  *
11
- * `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
11
+ * `selectAuthorizer`/`AuthorizerSelection` and `ForwardingManager` depend on
12
12
  * this single-method view so their unit tests inject a one-field fake without
13
13
  * casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
14
14
  */
@@ -14,7 +14,7 @@ import {
14
14
  } from "./config-schema";
15
15
  import { mergeFlatPermissions } from "./permission-merge";
16
16
  import type { FlatPermissionConfig, PatternValue } from "./types";
17
- import { isDenyWithReason, isPermissionState } from "./value-guards";
17
+ import { isDenyWithReason, isPermissionState } from "./types";
18
18
 
19
19
  // The unified config shape is derived from the zod schema (config-schema.ts,
20
20
  // the single source of truth) and re-exported so existing importers keep their
@@ -1,6 +1,6 @@
1
+ import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
1
2
  import type { DenialContext } from "#src/denial-messages";
2
3
  import type { PermissionDecisionEvent } from "#src/permission-events";
3
- import type { PromptPermissionDetails } from "#src/permission-prompter";
4
4
  import type { SessionApproval } from "#src/session-approval";
5
5
  import type { PermissionCheckResult, PermissionState } from "#src/types";
6
6
 
@@ -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
  ),