@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.
package/src/index.ts CHANGED
@@ -1,13 +1,11 @@
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";
3
+ import { AuthorizerSelection } from "./authority/authorizer-selection";
7
4
  import {
8
5
  ForwardedRequestServer,
9
- type ForwardedRequestServerDeps,
6
+ type ServingPolicy,
10
7
  } from "./authority/forwarded-request-server";
8
+ import { PermissionPrompter } from "./authority/permission-prompter";
11
9
  import { SubagentDetection } from "./authority/subagent-detection";
12
10
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
13
11
  import { registerPermissionSystemCommand } from "./config-modal";
@@ -27,13 +25,12 @@ import { GateRunner } from "./handlers/gates/runner";
27
25
  import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
28
26
  import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
29
27
  import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
28
+ import { buildAccessIntentForSurface } from "./input-normalizer";
30
29
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
31
30
  import { PermissionManager } from "./permission-manager";
32
- import { PermissionPrompter } from "./permission-prompter";
33
31
  import { PermissionResolver } from "./permission-resolver";
34
32
  import { PermissionSession } from "./permission-session";
35
33
  import { LocalPermissionsService } from "./permissions-service";
36
- import { PromptingGateway } from "./prompting-gateway";
37
34
  import { PermissionServiceLifecycle } from "./service-lifecycle";
38
35
  import { PermissionSessionLogger } from "./session-logger";
39
36
  import { SessionRules } from "./session-rules";
@@ -94,33 +91,48 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
94
91
  logger,
95
92
  });
96
93
 
97
- const escalatorDeps: ApprovalEscalatorDeps = {
98
- forwardingDir: paths.forwardingDir,
94
+ const prompter = new PermissionPrompter({ logger });
95
+
96
+ const authorizerSelection = new AuthorizerSelection({
99
97
  detection: subagentDetection,
100
- registry: subagentRegistry,
101
- logger,
98
+ events: pi.events,
102
99
  requestPermissionDecisionFromUi,
103
- };
104
- const escalator = new ApprovalEscalator(escalatorDeps);
105
-
106
- const requestServerDeps: ForwardedRequestServerDeps = {
107
100
  forwardingDir: paths.forwardingDir,
101
+ registry: subagentRegistry,
108
102
  logger,
109
- events: pi.events,
110
- requestPermissionDecisionFromUi,
111
- config: configStore,
103
+ prompter,
104
+ });
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
+ ),
112
128
  };
113
- const requestServer = new ForwardedRequestServer(requestServerDeps);
114
129
 
115
- const prompter = new PermissionPrompter({
130
+ const requestServer = new ForwardedRequestServer({
131
+ forwardingDir: paths.forwardingDir,
116
132
  logger,
117
- events: pi.events,
118
- forwarder: escalator,
119
- });
120
-
121
- const gateway = new PromptingGateway({
122
- detection: subagentDetection,
123
- prompter,
133
+ policy: servingPolicy,
134
+ escalator: authorizerSelection,
135
+ registry: subagentRegistry,
124
136
  });
125
137
 
126
138
  session = new PermissionSession(
@@ -129,7 +141,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
141
  permissionManager,
130
142
  sessionRules,
131
143
  configStore,
132
- gateway,
144
+ authorizerSelection,
133
145
  hostPlatform,
134
146
  );
135
147
 
@@ -148,11 +160,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
148
160
  ),
149
161
  });
150
162
 
151
- // Resolver composes the manager + session ruleset and owns the
152
- // access-path → path-values unwrap; the service routes its policy
153
- // queries through it, so it is constructed before it.
154
- const resolver = new PermissionResolver(permissionManager, sessionRules);
155
-
156
163
  const permissionsService = new LocalPermissionsService(
157
164
  resolver,
158
165
  session,
@@ -196,7 +203,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
196
203
  const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
197
204
 
198
205
  const reporter = new GateDecisionReporter(logger, pi.events);
199
- const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter);
206
+ const gateRunner = new GateRunner(
207
+ resolver,
208
+ sessionRules,
209
+ authorizerSelection,
210
+ reporter,
211
+ );
200
212
  const toolCallGatePipeline = new ToolCallGatePipeline(
201
213
  resolver,
202
214
  session,
package/src/normalize.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Rule, Ruleset } from "./rule";
2
2
  import type { FlatPermissionConfig } from "./types";
3
- import { isDenyWithReason, isPermissionState } from "./value-guards";
3
+ import { isDenyWithReason, isPermissionState } from "./types";
4
4
 
5
5
  /**
6
6
  * Convert a flat permission config into a Ruleset.
@@ -112,6 +112,18 @@ export class PathNormalizer {
112
112
  return this.impl.isAbsolute(pathValue);
113
113
  }
114
114
 
115
+ /**
116
+ * True when the host platform treats a backslash as a path separator (win32).
117
+ *
118
+ * The bash rule-candidate classifier reads this to decide whether a
119
+ * backslash-relative token (`dir\file`) is path-shaped: on win32 `\` is a
120
+ * separator, but on POSIX it is a legal filename character (#520). Keeping the
121
+ * decision here means no bash-layer module re-reads `process.platform`.
122
+ */
123
+ usesWindowsSeparators(): boolean {
124
+ return this.platform === "win32";
125
+ }
126
+
115
127
  /**
116
128
  * Interpret a literal `cd` target's effect on the effective base.
117
129
  *
@@ -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 };
@@ -34,7 +34,7 @@ import type {
34
34
  PermissionCheckResult,
35
35
  PermissionState,
36
36
  } from "./types";
37
- import { isPermissionState } from "./value-guards";
37
+ import { isPermissionState } from "./types";
38
38
  import { wildcardMatch } from "./wildcard-matcher";
39
39
 
40
40
  const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
@@ -3,7 +3,7 @@ import {
3
3
  getActiveAgentName,
4
4
  getActiveAgentNameFromSystemPrompt,
5
5
  } from "./active-agent";
6
-
6
+ import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-selection";
7
7
  import type { SessionConfigStore } from "./config-store";
8
8
  import type { PermissionSystemExtensionConfig } from "./extension-config";
9
9
  import type { ExtensionPaths } from "./extension-paths";
@@ -11,7 +11,6 @@ import type { ForwardingController } from "./forwarding-manager";
11
11
  import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
12
12
  import { PathNormalizer } from "./path-normalizer";
13
13
  import type { ScopedPermissionManager } from "./permission-manager";
14
- import type { PromptingGatewayLifecycle } from "./prompting-gateway";
15
14
 
16
15
  import type { SessionRules } from "./session-rules";
17
16
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
@@ -33,7 +32,7 @@ import type { PathRuleTokenMatcher } from "./types";
33
32
  * - `ExtensionPaths` — immutable path constants
34
33
  * - `ForwardingController` — polling lifecycle
35
34
  * - `SessionConfigStore` — owns extension config; provides refresh, log, read
36
- * - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
35
+ * - `AuthorizerSelectionLifecycle` — authorizer-selection lifecycle forwarded via activate/deactivate
37
36
  */
38
37
  export class PermissionSession implements ToolCallGateInputs {
39
38
  private context: ExtensionContext | null = null;
@@ -47,7 +46,7 @@ export class PermissionSession implements ToolCallGateInputs {
47
46
  private readonly permissionManager: ScopedPermissionManager,
48
47
  private readonly sessionRules: SessionRules,
49
48
  private readonly configStore: SessionConfigStore,
50
- private readonly gateway: PromptingGatewayLifecycle,
49
+ private readonly authorizerSelection: AuthorizerSelectionLifecycle,
51
50
  private readonly platform: NodeJS.Platform,
52
51
  ) {
53
52
  // Placeholder until the first activate(ctx) binds the real cwd; every gate
@@ -71,14 +70,14 @@ export class PermissionSession implements ToolCallGateInputs {
71
70
  this.context = ctx;
72
71
  this.pathNormalizer = new PathNormalizer(this.platform, ctx.cwd);
73
72
  this.forwarding.start(ctx);
74
- this.gateway.activate(ctx);
73
+ this.authorizerSelection.activate(ctx);
75
74
  }
76
75
 
77
- /** Clear the context, stop forwarding, and deactivate the gateway. */
76
+ /** Clear the context, stop forwarding, and deactivate the authorizer selection. */
78
77
  deactivate(): void {
79
78
  this.context = null;
80
79
  this.forwarding.stop();
81
- this.gateway.deactivate();
80
+ this.authorizerSelection.deactivate();
82
81
  }
83
82
 
84
83
  /** Return the current runtime context, or null if not activated. */
@@ -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
- }
@@ -19,7 +19,7 @@ export interface ReviewLogger {
19
19
 
20
20
  /**
21
21
  * Logging seam for consumers that write both debug and review entries.
22
- * Injected into `ConfigStore`, `ApprovalEscalator`, and `ForwardedRequestServer`.
22
+ * Injected into `ConfigStore`, `ParentAuthorizer`, and `ForwardedRequestServer`.
23
23
  */
24
24
  export interface DebugReviewLogger extends ReviewLogger {
25
25
  debug(event: string, details?: Record<string, unknown>): void;
package/src/types.ts CHANGED
@@ -63,3 +63,23 @@ export interface PermissionCheckResult {
63
63
  */
64
64
  commandContext?: BashCommandContext;
65
65
  }
66
+
67
+ export function isPermissionState(value: unknown): value is PermissionState {
68
+ return value === "allow" || value === "deny" || value === "ask";
69
+ }
70
+
71
+ /**
72
+ * Narrow type guard: a raw value representing a DenyWithReason object.
73
+ * Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
74
+ * Rejects a non-string `reason` to keep malformed config out of the rule set.
75
+ */
76
+ export function isDenyWithReason(value: unknown): value is DenyWithReason {
77
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
78
+ return false;
79
+ }
80
+ const record = value as Record<string, unknown>;
81
+ return (
82
+ record.action === "deny" &&
83
+ (record.reason === undefined || typeof record.reason === "string")
84
+ );
85
+ }
@@ -1,5 +1,3 @@
1
- import type { DenyWithReason, PermissionState } from "./types";
2
-
3
1
  export function toRecord(value: unknown): Record<string, unknown> {
4
2
  if (!value || typeof value !== "object" || Array.isArray(value)) {
5
3
  return {};
@@ -16,23 +14,3 @@ export function getNonEmptyString(value: unknown): string | null {
16
14
  const trimmed = value.trim();
17
15
  return trimmed.length > 0 ? trimmed : null;
18
16
  }
19
-
20
- export function isPermissionState(value: unknown): value is PermissionState {
21
- return value === "allow" || value === "deny" || value === "ask";
22
- }
23
-
24
- /**
25
- * Narrow type guard: a raw value representing a DenyWithReason object.
26
- * Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
27
- * Rejects a non-string `reason` to keep malformed config out of the rule set.
28
- */
29
- export function isDenyWithReason(value: unknown): value is DenyWithReason {
30
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
31
- return false;
32
- }
33
- const record = value as Record<string, unknown>;
34
- return (
35
- record.action === "deny" &&
36
- (record.reason === undefined || typeof record.reason === "string")
37
- );
38
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,89 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { SubagentDetector } from "./authority/subagent-detection";
3
- import type { GatePrompter } from "./gate-prompter";
4
- import type { PermissionPromptDecision } from "./permission-dialog";
5
- import type {
6
- PermissionPrompterApi,
7
- PromptPermissionDetails,
8
- } from "./permission-prompter";
9
-
10
- /**
11
- * Dependencies required by PromptingGateway.
12
- *
13
- * All fields are actively consumed:
14
- * - `detection` drives `canConfirm()`.
15
- * - `prompter` is called by `prompt()`.
16
- */
17
- export interface PromptingGatewayDeps {
18
- /** Single owner of subagent detection; drives `canConfirm()`. */
19
- detection: SubagentDetector;
20
- /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
21
- prompter: PermissionPrompterApi;
22
- }
23
-
24
- /**
25
- * The lifecycle slice of the gateway that PermissionSession drives.
26
- *
27
- * PermissionSession calls activate/deactivate to keep the gateway's stored
28
- * context in sync with its own — the same pattern used for ForwardingController.
29
- */
30
- export interface PromptingGatewayLifecycle {
31
- activate(ctx: ExtensionContext): void;
32
- deactivate(): void;
33
- }
34
-
35
- /**
36
- * Context-owning implementation of the GatePrompter role.
37
- *
38
- * Owns the stored ExtensionContext and the "can we prompt?" policy
39
- * (UI / subagent), replacing the four twin methods that previously
40
- * lived on PermissionSession.
41
- *
42
- * Lifecycle: PermissionSession drives activate/deactivate so the stored
43
- * context mirrors the session context without independent call-site changes.
44
- */
45
- export class PromptingGateway
46
- implements GatePrompter, PromptingGatewayLifecycle
47
- {
48
- private context: ExtensionContext | null = null;
49
-
50
- constructor(private readonly deps: PromptingGatewayDeps) {}
51
-
52
- /** Store the current extension context. */
53
- activate(ctx: ExtensionContext): void {
54
- this.context = ctx;
55
- }
56
-
57
- /** Clear the stored context. */
58
- deactivate(): void {
59
- this.context = null;
60
- }
61
-
62
- /**
63
- * Whether an interactive permission prompt can be shown.
64
- *
65
- * Returns false when no context is active. Otherwise true when the
66
- * context has UI or is a forwarding subagent — the two Authorizer-
67
- * selection predicates the Phase 9 spine will consume. Yolo-mode never
68
- * reaches this policy: it is resolved upstream at the composition stage.
69
- */
70
- canConfirm(): boolean {
71
- if (this.context === null) return false;
72
- return this.context.hasUI || this.deps.detection.isSubagent(this.context);
73
- }
74
-
75
- /**
76
- * Prompt the user for a permission decision using the stored context.
77
- *
78
- * Rejects if no context is active — canConfirm() guards this in normal use.
79
- * Implements {@link GatePrompter}.
80
- */
81
- prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
82
- if (this.context === null) {
83
- return Promise.reject(
84
- new Error("prompt called before the session was activated"),
85
- );
86
- }
87
- return this.deps.prompter.prompt(this.context, details);
88
- }
89
- }