@gotgenes/pi-permission-system 20.1.0 → 20.3.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.
@@ -15,6 +15,7 @@ import {
15
15
  createPermissionForwardingLocation,
16
16
  type ForwardedPermissionRequest,
17
17
  type ForwardedPermissionResponse,
18
+ type ForwardedSessionApproval,
18
19
  type PermissionForwardingLocation,
19
20
  } from "#src/permission-forwarding";
20
21
  import type { DebugReviewLogger } from "#src/session-logger";
@@ -41,6 +42,31 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
41
42
  return undefined;
42
43
  }
43
44
 
45
+ /**
46
+ * Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
47
+ *
48
+ * Tolerant read: the child's session-approval suggestion is optional (absent
49
+ * on an older child) and only accepted when well-formed — a non-empty surface
50
+ * and an all-string patterns array.
51
+ */
52
+ function asForwardedSessionApproval(
53
+ value: unknown,
54
+ ): ForwardedSessionApproval | undefined {
55
+ if (typeof value !== "object" || value === null) {
56
+ return undefined;
57
+ }
58
+ const candidate = value as Partial<ForwardedSessionApproval>;
59
+ if (
60
+ typeof candidate.surface !== "string" ||
61
+ candidate.surface.length === 0 ||
62
+ !Array.isArray(candidate.patterns) ||
63
+ !candidate.patterns.every((pattern) => typeof pattern === "string")
64
+ ) {
65
+ return undefined;
66
+ }
67
+ return { surface: candidate.surface, patterns: [...candidate.patterns] };
68
+ }
69
+
44
70
  export function formatUnknownErrorMessage(error: unknown): string {
45
71
  if (error instanceof Error && error.message) {
46
72
  return error.message;
@@ -323,6 +349,7 @@ export function readForwardedPermissionRequest(
323
349
  source: asUiPromptSource(parsed.source),
324
350
  surface: asNullableDisplayString(parsed.surface),
325
351
  value: asNullableDisplayString(parsed.value),
352
+ sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
326
353
  };
327
354
  } catch (error) {
328
355
  logPermissionForwardingWarning(
@@ -1,13 +1,15 @@
1
+ import { buildForwardedScopeLabels } from "#src/pattern-suggest";
1
2
  import type {
2
3
  PermissionDecisionUi,
3
4
  PermissionPromptDecision,
5
+ RequestPermissionOptions,
4
6
  requestPermissionDecisionFromUi,
5
7
  } from "#src/permission-dialog";
6
8
  import {
7
9
  emitUiPromptEvent,
8
10
  type PermissionEventBus,
9
11
  } from "#src/permission-events";
10
- import { buildDirectUiPrompt } from "#src/permission-ui-prompt";
12
+ import { buildUiPrompt } from "#src/permission-ui-prompt";
11
13
  import type { Authorizer } from "./authorizer";
12
14
  import type { PromptPermissionDetails } from "./permission-prompter";
13
15
 
@@ -26,7 +28,10 @@ export interface LocalUserAuthorizerDeps {
26
28
  *
27
29
  * Emits the `permissions:ui_prompt` broadcast (moved here from
28
30
  * `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
29
- * observers know a decision is imminent.
31
+ * observers know a decision is imminent. This is the single emit site: a
32
+ * forwarded ask carries its provenance on `details.forwarding`, which this
33
+ * class renders (populated `forwarding` context + "(Subagent)" title) so the
34
+ * broadcast stays non-degraded (#292) without a second emission path.
30
35
  */
31
36
  export class LocalUserAuthorizer implements Authorizer {
32
37
  constructor(private readonly deps: LocalUserAuthorizerDeps) {}
@@ -34,13 +39,38 @@ export class LocalUserAuthorizer implements Authorizer {
34
39
  authorize(
35
40
  details: PromptPermissionDetails,
36
41
  ): Promise<PermissionPromptDecision> {
37
- const uiPrompt = buildDirectUiPrompt(details);
42
+ const uiPrompt = buildUiPrompt(details);
38
43
  emitUiPromptEvent(this.deps.events, uiPrompt);
39
44
  return this.deps.requestPermissionDecisionFromUi(
40
45
  this.deps.ui,
41
- "Permission Required",
46
+ details.forwarding
47
+ ? "Permission Required (Subagent)"
48
+ : "Permission Required",
42
49
  details.message,
43
- details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
50
+ buildRequestOptions(details),
44
51
  );
45
52
  }
46
53
  }
54
+
55
+ /**
56
+ * A forwarded ask carrying a session-approval suggestion offers the scope
57
+ * choice (subagent vs whole session); any other ask keeps its single
58
+ * "for this session" option (custom label when the gate supplied one).
59
+ */
60
+ function buildRequestOptions(
61
+ details: PromptPermissionDetails,
62
+ ): RequestPermissionOptions | undefined {
63
+ const pattern = details.sessionApproval?.patterns[0];
64
+ if (details.forwarding && details.sessionApproval && pattern) {
65
+ return {
66
+ sessionScope: buildForwardedScopeLabels(
67
+ details.forwarding.requesterAgentName,
68
+ details.sessionApproval.surface,
69
+ pattern,
70
+ ),
71
+ };
72
+ }
73
+ return details.sessionLabel
74
+ ? { sessionLabel: details.sessionLabel }
75
+ : undefined;
76
+ }
@@ -1,9 +1,23 @@
1
1
  import type { PermissionPromptDecision } from "#src/permission-dialog";
2
+ import type { ForwardedSessionApproval } from "#src/permission-forwarding";
2
3
  import type { ReviewLogger } from "#src/session-logger";
3
4
  import type { Authorizer } from "./authorizer";
4
5
 
5
6
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
6
7
 
8
+ /**
9
+ * Provenance of a forwarded ask: who is really asking, one hop below.
10
+ *
11
+ * Present on {@link PromptPermissionDetails} only when the ask was forwarded
12
+ * from a subagent. Structurally identical to the event's `ForwardedPromptContext`
13
+ * so the details flow straight into `buildUiPrompt`, but declared here to keep
14
+ * the prompter layer free of an events-module import.
15
+ */
16
+ export interface ForwardedAskProvenance {
17
+ requesterAgentName: string | null;
18
+ requesterSessionId: string | null;
19
+ }
20
+
7
21
  /** Details passed when prompting the user for a permission decision. */
8
22
  export interface PromptPermissionDetails {
9
23
  requestId: string;
@@ -19,6 +33,19 @@ export interface PromptPermissionDetails {
19
33
  toolInputPreview?: string;
20
34
  /** Override label for the "for this session" dialog option. */
21
35
  sessionLabel?: string;
36
+ /** Explicit display-surface override (a forwarded ask carries the child's original). */
37
+ surface?: string | null;
38
+ /** Explicit display-value override (a forwarded ask carries the child's original). */
39
+ value?: string | null;
40
+ /** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
41
+ forwarding?: ForwardedAskProvenance;
42
+ /**
43
+ * The session-approval suggestion for this ask. On the child's escalation it
44
+ * rides into the forwarded request; on the serving node it lets the dialog
45
+ * offer a whole-session grant scope. Absent when the gate computed no
46
+ * suggestion.
47
+ */
48
+ sessionApproval?: ForwardedSessionApproval;
22
49
  }
23
50
 
24
51
  /**
@@ -75,7 +102,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
75
102
  : "permission_request.denied",
76
103
  {
77
104
  ...details,
78
- resolution: decision.state,
105
+ resolution: decision.confirmationUnavailable
106
+ ? "confirmation_unavailable"
107
+ : decision.state,
79
108
  denialReason: decision.denialReason,
80
109
  },
81
110
  );
@@ -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,20 @@ 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
+ ...(descriptor.sessionApproval
151
+ ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
152
+ : {}),
150
153
  });
151
154
  autoApproved = decision.autoApproved === true;
155
+ confirmationUnavailable = decision.confirmationUnavailable === true;
152
156
  return decision;
153
157
  },
154
158
  writeLog: (event, details) =>
@@ -172,7 +176,7 @@ export class GateRunner {
172
176
  check.state,
173
177
  gateResult.action,
174
178
  hasSessionApproval,
175
- canConfirm,
179
+ confirmationUnavailable,
176
180
  autoApproved,
177
181
  ),
178
182
  ),
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,42 @@ 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
+ // Records a whole-session grant into the same SessionRules the resolver and
136
+ // gate runner read, so a serving-scope grant governs the parent and future
137
+ // forwarded resolutions.
138
+ recorder: sessionRules,
139
+ registry: subagentRegistry,
140
+ });
141
+
114
142
  session = new PermissionSession(
115
143
  paths,
116
144
  new ForwardingManager(subagentDetection, requestServer),
@@ -136,11 +164,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
136
164
  ),
137
165
  });
138
166
 
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
167
  const permissionsService = new LocalPermissionsService(
145
168
  resolver,
146
169
  session,
@@ -62,6 +62,35 @@ export function suggestMcpPattern(target: string): string {
62
62
  return "*";
63
63
  }
64
64
 
65
+ /** Scope labels for the forwarded-approval two-step scope select. */
66
+ export interface ForwardedScopeLabels {
67
+ /** Least-privilege default: record on the requesting subagent only. */
68
+ subagentLabel: string;
69
+ /** Record on the serving node — covers the parent and all subagents. */
70
+ servingSessionLabel: string;
71
+ }
72
+
73
+ /**
74
+ * Build the two scope labels shown when a human grants a forwarded request
75
+ * "for this session."
76
+ *
77
+ * The subagent option names the requester (least privilege); the whole-session
78
+ * option restates the surface + pattern being granted session-wide.
79
+ */
80
+ export function buildForwardedScopeLabels(
81
+ agentName: string | null,
82
+ surface: string,
83
+ pattern: string,
84
+ ): ForwardedScopeLabels {
85
+ const subagentLabel = agentName
86
+ ? `This subagent ('${agentName}') only`
87
+ : "This subagent only";
88
+ return {
89
+ subagentLabel,
90
+ servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
91
+ };
92
+ }
93
+
65
94
  /** Surface-aware human-readable labels for the session-approval option. */
66
95
  function buildLabel(pattern: string, surface: string): string {
67
96
  switch (surface) {
@@ -1,6 +1,7 @@
1
1
  export type PermissionDecisionState =
2
2
  | "approved"
3
3
  | "approved_for_session"
4
+ | "approved_for_serving_session"
4
5
  | "denied"
5
6
  | "denied_with_reason";
6
7
 
@@ -14,6 +15,14 @@ export type PermissionPromptDecision = {
14
15
  * rather than "user_approved" in the permissions:decision broadcast.
15
16
  */
16
17
  autoApproved?: true;
18
+ /**
19
+ * True when no live authority was reachable and the DenyingAuthorizer denied
20
+ * this ask (a no-UI, non-subagent session). Consumed by deriveResolution (the
21
+ * decision-event resolution), the gate (block reason), and PermissionPrompter
22
+ * (review-entry resolution) to emit "confirmation_unavailable" rather than a
23
+ * plain user denial.
24
+ */
25
+ confirmationUnavailable?: true;
17
26
  };
18
27
 
19
28
  export interface PermissionDecisionUi {
@@ -59,6 +68,7 @@ export function isPermissionDecisionState(
59
68
  return (
60
69
  value === "approved" ||
61
70
  value === "approved_for_session" ||
71
+ value === "approved_for_serving_session" ||
62
72
  value === "denied" ||
63
73
  value === "denied_with_reason"
64
74
  );
@@ -67,6 +77,15 @@ export function isPermissionDecisionState(
67
77
  export interface RequestPermissionOptions {
68
78
  /** Override the "for this session" option label (e.g. to show the suggested pattern). */
69
79
  sessionLabel?: string;
80
+ /**
81
+ * Forwarded asks only: when set, choosing the "for this session" option opens
82
+ * a second select asking whether the grant applies to the requesting subagent
83
+ * only (the least-privilege default) or the whole serving session.
84
+ */
85
+ sessionScope?: {
86
+ subagentLabel: string;
87
+ servingSessionLabel: string;
88
+ };
70
89
  }
71
90
 
72
91
  export async function requestPermissionDecisionFromUi(
@@ -95,6 +114,21 @@ export async function requestPermissionDecisionFromUi(
95
114
  }
96
115
 
97
116
  if (selected === sessionOption) {
117
+ if (options?.sessionScope) {
118
+ const scope = await ui.select(`${title}\nApply this session grant to:`, [
119
+ options.sessionScope.subagentLabel,
120
+ options.sessionScope.servingSessionLabel,
121
+ ]);
122
+ return {
123
+ approved: true,
124
+ // A cancelled scope select (undefined) falls back to the
125
+ // least-privilege subagent scope.
126
+ state:
127
+ scope === options.sessionScope.servingSessionLabel
128
+ ? "approved_for_serving_session"
129
+ : "approved_for_session",
130
+ };
131
+ }
98
132
  return {
99
133
  approved: true,
100
134
  state: "approved_for_session",
@@ -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;
@@ -52,6 +53,20 @@ export interface ForwardedPromptDisplay {
52
53
  value: string | null;
53
54
  }
54
55
 
56
+ /**
57
+ * The child's session-approval suggestion, relayed to the serving node so a
58
+ * human who grants "the whole session" records the same pattern the child
59
+ * would have recorded locally.
60
+ *
61
+ * A plain data shape (not the `SessionApproval` value object) so it serializes
62
+ * onto the forwarded request; the serving node rebuilds a `SessionApproval`
63
+ * from it via `SessionApproval.multiple`.
64
+ */
65
+ export interface ForwardedSessionApproval {
66
+ surface: string;
67
+ patterns: readonly string[];
68
+ }
69
+
55
70
  export type ForwardedPermissionRequest = {
56
71
  id: string;
57
72
  createdAt: number;
@@ -68,6 +83,13 @@ export type ForwardedPermissionRequest = {
68
83
  source?: PermissionUiPromptSource;
69
84
  surface?: string | null;
70
85
  value?: string | null;
86
+ /**
87
+ * The child's session-approval suggestion. Present when the child computed a
88
+ * "for this session" pattern for the ask; lets the serving node record a
89
+ * whole-session grant. Optional for version-skew tolerance (an older child
90
+ * omits it, and the serving dialog then offers no scope choice).
91
+ */
92
+ sessionApproval?: ForwardedSessionApproval;
71
93
  };
72
94
 
73
95
  export type ForwardedPermissionResponse = {
@@ -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
- }