@gotgenes/pi-permission-system 25.4.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +14 -12
  3. package/config/config.example.json +1 -2
  4. package/dist/public.d.ts +31 -10
  5. package/docs/configuration.md +23 -19
  6. package/docs/cross-extension-api.md +30 -4
  7. package/docs/migration/0745-prompt-payload-contracts.md +68 -0
  8. package/docs/migration/0746-review-log-fields.md +69 -0
  9. package/docs/troubleshooting.md +2 -1
  10. package/package.json +1 -1
  11. package/schemas/permissions.schema.json +14 -4
  12. package/src/access-intent/tool-kind.ts +1 -1
  13. package/src/authority/approval-escalator.ts +6 -4
  14. package/src/authority/authorizer.ts +3 -3
  15. package/src/authority/forwarded-request-server.ts +0 -2
  16. package/src/authority/forwarding-io.ts +7 -5
  17. package/src/authority/permission-forwarding.ts +12 -3
  18. package/src/authority/permission-prompter.ts +5 -4
  19. package/src/config-loader.ts +31 -0
  20. package/src/config-schema.ts +13 -4
  21. package/src/extension-config.ts +8 -9
  22. package/src/handlers/gates/bash-external-directory.ts +1 -12
  23. package/src/handlers/gates/bash-path.ts +1 -10
  24. package/src/handlers/gates/descriptor.ts +16 -5
  25. package/src/handlers/gates/external-directory.ts +1 -13
  26. package/src/handlers/gates/path.ts +1 -9
  27. package/src/handlers/gates/runner.ts +24 -14
  28. package/src/handlers/gates/skill-input-gate-pipeline.ts +2 -2
  29. package/src/handlers/gates/skill-input.ts +1 -10
  30. package/src/handlers/gates/skill-read.ts +1 -11
  31. package/src/handlers/gates/tool.ts +1 -11
  32. package/src/log-field-cap.ts +82 -0
  33. package/src/logging.ts +24 -3
  34. package/src/permission-events.ts +15 -2
  35. package/src/permission-prompts.ts +4 -3
  36. package/src/permission-session.ts +1 -1
  37. package/src/permission-ui-prompt.ts +4 -2
  38. package/src/presentation/agent-renderer.ts +215 -0
  39. package/src/presentation/dialog-renderer.ts +8 -64
  40. package/src/presentation/fact-vocabulary.ts +103 -0
  41. package/src/presentation/forwarded-ask-payload.ts +42 -17
  42. package/src/presentation/path-ask-payload.ts +8 -1
  43. package/src/presentation/prompt-payload.ts +165 -4
  44. package/src/presentation/review-log-renderer.ts +51 -0
  45. package/src/service.ts +11 -0
  46. package/src/tool-input-preview.ts +0 -1
  47. package/src/tool-preview-formatter.ts +18 -33
  48. package/src/denial-messages.ts +0 -269
  49. package/src/presentation/legacy-message.ts +0 -117
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The permission review log's width bound (ADR 0011 §6).
3
+ *
4
+ * The log renders the prompt payload under its own configured limits, and this
5
+ * is the limit: every string it writes is narrowed to a configured width. The
6
+ * bound is applied at `writeLine`, the single place a log line is produced, so
7
+ * a write path cannot be added that escapes it — the same discipline redaction
8
+ * already has there.
9
+ *
10
+ * A cap is not redaction, and the two must not be conflated
11
+ * (`docs/decisions/0010-permission-log-secret-exposure.md`). This narrows by
12
+ * length alone and never reads a value to decide what to shorten; redaction
13
+ * masks a value because of the key name it is bound to, and still does, so a
14
+ * sensitive-keyed value is masked whole however long it was.
15
+ */
16
+
17
+ /**
18
+ * The width when the operator configures none.
19
+ *
20
+ * Not a new number: it is the bound that already governed `toolInputPreview`,
21
+ * promoted from one field to every field so the log has one limit rather than
22
+ * one limit and an unbounded remainder.
23
+ */
24
+ export const DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH = 1000;
25
+
26
+ /** The two-field shape this module reads off the extension config. */
27
+ export interface ReviewLogWidthConfig {
28
+ readonly reviewLogFieldMaxWidth?: number;
29
+ }
30
+
31
+ /** The configured review-log field width, or the built-in default. */
32
+ export function resolveReviewLogFieldWidth(
33
+ config: ReviewLogWidthConfig,
34
+ ): number {
35
+ return config.reviewLogFieldMaxWidth ?? DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH;
36
+ }
37
+
38
+ /**
39
+ * Narrow every string in a log-detail record to `maxWidth`.
40
+ *
41
+ * Recurses through plain objects and arrays so a nested detail is bounded too,
42
+ * and touches strings only — a number, a boolean, or a null passes through as
43
+ * it was. A shortened value is marked with a bare ellipsis, the same marker the
44
+ * dialog uses: a character count is a number the reader cannot act on
45
+ * (ADR 0011 §4).
46
+ */
47
+ export function capLogFieldWidths<T>(details: T, maxWidth: number): T {
48
+ return capValue(details, maxWidth) as T;
49
+ }
50
+
51
+ function capValue(value: unknown, maxWidth: number): unknown {
52
+ if (typeof value === "string") {
53
+ return value.length <= maxWidth
54
+ ? value
55
+ : `${value.slice(0, maxWidth)}\u2026`;
56
+ }
57
+ if (Array.isArray(value)) {
58
+ return value.map((entry) => capValue(entry, maxWidth));
59
+ }
60
+ if (isPlainObject(value)) {
61
+ return Object.fromEntries(
62
+ Object.entries(value).map(([key, entry]) => [
63
+ key,
64
+ capValue(entry, maxWidth),
65
+ ]),
66
+ );
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * Whether a value is a record this cap should descend into.
73
+ *
74
+ * A class instance (a `Date`, an `Error`) is left alone: rebuilding it as a
75
+ * plain object would change what the writer serializes, and the cap's job is
76
+ * to shorten strings, not to reshape a value.
77
+ */
78
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
79
+ if (typeof value !== "object" || value === null) return false;
80
+ const prototype: unknown = Object.getPrototypeOf(value);
81
+ return prototype === Object.prototype || prototype === null;
82
+ }
package/src/logging.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  EXTENSION_ID,
5
5
  type PermissionSystemExtensionConfig,
6
6
  } from "./extension-config";
7
+ import { capLogFieldWidths, resolveReviewLogFieldWidth } from "./log-field-cap";
7
8
  import {
8
9
  OWNER_ONLY_FILE_MODE,
9
10
  restrictExistingPathToOwner,
@@ -37,11 +38,20 @@ export function createPermissionSystemLogger(
37
38
  // re-invoked per session, unlike module scope, which now outlives one.
38
39
  const hardened = new Set<string>();
39
40
 
41
+ /**
42
+ * The only place a log line is produced.
43
+ *
44
+ * `maxFieldWidth` bounds every string the line carries; it is supplied for
45
+ * the review stream and withheld for the debug stream, which is opt-in and
46
+ * exists to be read in full. Capping happens before redaction, which masks
47
+ * by key name and so still masks a sensitive value whole.
48
+ */
40
49
  const writeLine = (
41
50
  stream: "debug" | "review",
42
51
  path: string,
43
52
  event: string,
44
53
  details: Record<string, unknown>,
54
+ maxFieldWidth?: number,
45
55
  ): string | undefined => {
46
56
  const directoryError = ensureLogsDirectory();
47
57
  if (directoryError) {
@@ -49,12 +59,16 @@ export function createPermissionSystemLogger(
49
59
  }
50
60
 
51
61
  try {
62
+ const bounded =
63
+ maxFieldWidth === undefined
64
+ ? details
65
+ : capLogFieldWidths(details, maxFieldWidth);
52
66
  const line = redactedJsonStringify({
53
67
  timestamp: new Date().toISOString(),
54
68
  extension: EXTENSION_ID,
55
69
  stream,
56
70
  event,
57
- ...details,
71
+ ...bounded,
58
72
  });
59
73
  if (!line) {
60
74
  return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
@@ -89,11 +103,18 @@ export function createPermissionSystemLogger(
89
103
  event: string,
90
104
  details: Record<string, unknown> = {},
91
105
  ): string | undefined => {
92
- if (!options.getConfig().permissionReviewLog) {
106
+ const config = options.getConfig();
107
+ if (!config.permissionReviewLog) {
93
108
  return undefined;
94
109
  }
95
110
 
96
- return writeLine("review", reviewLogPath, event, details);
111
+ return writeLine(
112
+ "review",
113
+ reviewLogPath,
114
+ event,
115
+ details,
116
+ resolveReviewLogFieldWidth(config),
117
+ );
97
118
  };
98
119
 
99
120
  return { debug, review };
@@ -8,6 +8,8 @@
8
8
  * removed or renamed without a semver-major version bump.
9
9
  */
10
10
 
11
+ import type { PromptRequestFacts } from "#src/presentation/prompt-payload";
12
+
11
13
  /** Minimal event bus interface required by the emit helpers. */
12
14
  export interface PermissionEventBus {
13
15
  emit(channel: string, data: unknown): void;
@@ -79,8 +81,19 @@ export interface PermissionUiPromptEvent {
79
81
  value: string | null;
80
82
  /** Agent name (when known). */
81
83
  agentName: string | null;
82
- /** Message displayed to the user. */
83
- message: string;
84
+ /**
85
+ * The ask's invariant core (ADR 0011 §3), verbatim from the prompt payload.
86
+ *
87
+ * Nested rather than flattened so the event and the payload share one shape:
88
+ * a fact added to `PromptRequestFacts` reaches the bus without a second
89
+ * hand-maintained declaration. Carries no evidence and no annotations — the
90
+ * bus is the narrowest renderer (ADR 0011 §6), observable by any loaded
91
+ * extension without the operator having named it.
92
+ *
93
+ * `request.surface` is the *gate* surface the rule fired on; the top-level
94
+ * `surface` is the display projection. Both are here on purpose.
95
+ */
96
+ request: PromptRequestFacts;
84
97
  /** Forwarding context, or null for a direct prompt. */
85
98
  forwarding: ForwardedPromptContext | null;
86
99
  }
@@ -1,8 +1,9 @@
1
1
  import { classifyToolKind } from "./access-intent/tool-kind";
2
2
 
3
- // NOTE: the ask prompts are now payload builders under src/presentation/;
4
- // denial text lives in denial-messages.ts. This module retains only the
5
- // pre-check reasons, which are agent-facing rather than user-facing.
3
+ // NOTE: the ask prompts are now payload builders under src/presentation/, and
4
+ // denial text is a render over the payload (presentation/agent-renderer.ts).
5
+ // This module retains only the pre-check reasons, refused before any payload
6
+ // exists to render.
6
7
 
7
8
  export function formatMissingToolNameReason(): string {
8
9
  return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
@@ -219,7 +219,7 @@ export class PermissionSession implements ToolCallGateInputs {
219
219
  * so the pipeline reads a clean value rather than pulling raw config.
220
220
  */
221
221
  getToolPreviewLimits(): ToolPreviewFormatterOptions {
222
- return resolveToolPreviewLimits(this.config);
222
+ return resolveToolPreviewLimits();
223
223
  }
224
224
 
225
225
  /**
@@ -11,6 +11,7 @@
11
11
  * prompter or forwarding modules (no import cycles, correct layering).
12
12
  */
13
13
 
14
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
14
15
  import type {
15
16
  ForwardedPromptContext,
16
17
  PermissionUiPromptEvent,
@@ -21,7 +22,8 @@ export interface DirectPromptInput {
21
22
  requestId: string;
22
23
  source: "tool_call" | "skill_input" | "skill_read";
23
24
  agentName: string | null;
24
- message: string;
25
+ /** The ask's complete payload; the event carries its invariant core alone. */
26
+ payload: PromptPayload;
25
27
  toolName?: string;
26
28
  skillName?: string;
27
29
  path?: string;
@@ -62,7 +64,7 @@ export function buildUiPrompt(input: UiPromptInput): PermissionUiPromptEvent {
62
64
  surface: input.surface !== undefined ? input.surface : directSurface(input),
63
65
  value: input.value !== undefined ? input.value : directValue(input),
64
66
  agentName: input.agentName,
65
- message: input.message,
67
+ request: input.payload.request,
66
68
  forwarding: input.forwarding ?? null,
67
69
  };
68
70
  }
@@ -0,0 +1,215 @@
1
+ import { EXTENSION_ID } from "#src/extension-config";
2
+ import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
3
+ import {
4
+ describeBashCommandContext,
5
+ flaggedElementLabel,
6
+ flaggedElements,
7
+ } from "#src/presentation/fact-vocabulary";
8
+ import {
9
+ allEvidence,
10
+ findEvidence,
11
+ type PromptPayload,
12
+ } from "#src/presentation/prompt-payload";
13
+
14
+ /**
15
+ * The agent-facing render of a refused permission ask (ADR 0011 §7).
16
+ *
17
+ * The rule that governs this renderer and no other:
18
+ *
19
+ * > The agent renderer identifies the call; it does not reproduce it.
20
+ *
21
+ * The agent authored the tool call, and the harness returns this text as that
22
+ * call's own tool result with its arguments still in context, so echoing the
23
+ * input back tells it nothing it did not already have. What is new is the
24
+ * verdict: which surface gated the call, which rule matched, which of the
25
+ * call's operands tripped it, and what the human said.
26
+ *
27
+ * The command is the one value never rendered — it is the payload that took
28
+ * over the viewport in #710 and the context window on every denial. The
29
+ * flagged element (a path, an MCP target, a skill) *is* rendered, because
30
+ * which operand a rule fired on is below tool-call granularity and the agent
31
+ * cannot recover it from its own arguments; being agent input, it is capped
32
+ * rather than structurally bounded.
33
+ */
34
+
35
+ /** Attribution tag on every block reason this extension produces. */
36
+ export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
37
+
38
+ /** How much room the flagged element has, as the operator configured it. */
39
+ export interface AgentRenderBudget {
40
+ /** Maximum characters of the flagged element's text. */
41
+ readonly fieldMaxWidth: number;
42
+ }
43
+
44
+ /** The agent-facing render of a policy deny. */
45
+ export function renderPolicyDenial(
46
+ payload: PromptPayload,
47
+ ruleReason: string | null,
48
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
49
+ ): string {
50
+ return tagged(
51
+ `Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
52
+ ruleReason,
53
+ );
54
+ }
55
+
56
+ /** The agent-facing render of a human's denial at an interactive prompt. */
57
+ export function renderUserDenial(
58
+ payload: PromptPayload,
59
+ denialReason: string | null,
60
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
61
+ ): string {
62
+ return tagged(
63
+ `The user denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
64
+ denialReason,
65
+ );
66
+ }
67
+
68
+ /** The agent-facing render when no live authority could answer the ask. */
69
+ export function renderUnavailableDenial(
70
+ payload: PromptPayload,
71
+ denialReason: string | null,
72
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
73
+ ): string {
74
+ return tagged(
75
+ `This ${identification(payload, budget, "call")} requires approval, but no interactive UI is available.`,
76
+ denialReason,
77
+ );
78
+ }
79
+
80
+ // ── Sentence assembly ──────────────────────────────────────────────────────
81
+
82
+ function tagged(sentence: string, reason: string | null): string {
83
+ return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
84
+ }
85
+
86
+ /**
87
+ * What was refused, in the order a reader needs it: the gate surface, the tool
88
+ * that reached it, who asked, which of the call's operands was flagged, and the
89
+ * rule that fired.
90
+ *
91
+ * `callWord` is the noun the verdict needs after the surface — a user or
92
+ * unavailable verdict refuses a *call*, while a policy deny refuses the
93
+ * surface itself.
94
+ */
95
+ function identification(
96
+ payload: PromptPayload,
97
+ budget: AgentRenderBudget,
98
+ callWord: string,
99
+ ): string {
100
+ return [
101
+ `'${payload.request.surface}'`,
102
+ callWord,
103
+ invokedAsClause(payload),
104
+ toolClause(payload),
105
+ agentClause(payload),
106
+ flaggedClause(payload, budget),
107
+ ruleClause(payload),
108
+ ]
109
+ .filter((clause) => clause !== "")
110
+ .join(" ");
111
+ }
112
+
113
+ /** The gated tool, named only when the surface has not already named it. */
114
+ function toolClause(payload: PromptPayload): string {
115
+ const { toolName, surface } = payload.request;
116
+ return toolName === null || toolName === surface
117
+ ? ""
118
+ : `for tool '${toolName}'`;
119
+ }
120
+
121
+ /** The name the agent actually called, when a shell alias re-exposed bash. */
122
+ function invokedAsClause(payload: PromptPayload): string {
123
+ const { invokedToolName } = payload.request;
124
+ return invokedToolName === null ? "" : `(invoked as '${invokedToolName}')`;
125
+ }
126
+
127
+ /** Which agent asked, when the ask carries a name. */
128
+ function agentClause(payload: PromptPayload): string {
129
+ const { agentName } = payload.request.requester;
130
+ return agentName ? `for agent '${agentName}'` : "";
131
+ }
132
+
133
+ /**
134
+ * Which of the call's operands the rule fired on.
135
+ *
136
+ * Omitted for a bash ask, whose flagged element is the command §7 forbids
137
+ * echoing; for a generic tool ask, whose value is the tool name an earlier
138
+ * clause already stated; and for a payload-less forwarded relay, whose value
139
+ * shape is unknown, so it cannot be shown to not be a command.
140
+ */
141
+ function flaggedClause(
142
+ payload: PromptPayload,
143
+ budget: AgentRenderBudget,
144
+ ): string {
145
+ if (payload.kind === "bash" || payload.kind === "forwarded") {
146
+ return "";
147
+ }
148
+ const label = flaggedElementLabel(payload);
149
+ const elements = flaggedElements(payload).filter(
150
+ (element) => element !== payload.request.toolName,
151
+ );
152
+ if (elements.length === 0) {
153
+ return "";
154
+ }
155
+ const noun = elements.length === 1 ? label : `${label}s`;
156
+ return `for ${noun} ${elements
157
+ .map(
158
+ (element) =>
159
+ `'${cap(element, budget)}'${resolvedAlias(payload, element)}`,
160
+ )
161
+ .join(", ")}`;
162
+ }
163
+
164
+ /** The canonical target of a flagged path, when it names somewhere else. */
165
+ function resolvedAlias(payload: PromptPayload, element: string): string {
166
+ const resolved =
167
+ findEvidence(payload, "resolves to")?.text ??
168
+ allEvidence(payload, "external path").find(
169
+ (entry) => entry.text === element,
170
+ )?.detail;
171
+ return resolved ? ` (resolves to '${resolved}')` : "";
172
+ }
173
+
174
+ /** The rule that fired, with the nested context that makes it intelligible. */
175
+ function ruleClause(payload: PromptPayload): string {
176
+ const { matchedPattern, commandContext } = payload.request;
177
+ const parts: string[] = [];
178
+ if (matchedPattern !== null) {
179
+ parts.push(`rule '${matchedPattern}'`);
180
+ }
181
+ const context = describeBashCommandContext(commandContext);
182
+ if (context !== undefined) {
183
+ parts.push(`inside ${context}`);
184
+ }
185
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
186
+ }
187
+
188
+ /** The working directory the flagged paths escaped. */
189
+ function boundaryClause(payload: PromptPayload): string {
190
+ const cwd = findEvidence(payload, "working directory")?.text;
191
+ return cwd ? `: outside working directory '${cwd}'` : "";
192
+ }
193
+
194
+ /** The path a skill read reached its skill through. */
195
+ function provenanceClause(payload: PromptPayload): string {
196
+ const readPath = findEvidence(payload, "read path")?.text;
197
+ return readPath ? `, reached via '${readPath}'` : "";
198
+ }
199
+
200
+ function reasonClause(reason: string | null): string {
201
+ return reason ? ` Reason: ${reason}.` : "";
202
+ }
203
+
204
+ /**
205
+ * Narrow the flagged element to the budget.
206
+ *
207
+ * The command is never rendered, so this bounds the only agent-supplied value
208
+ * that reaches the agent. A quantity bound applied uniformly, never a content
209
+ * filter, with the same bare-ellipsis marker the dialog uses (ADR 0011 §4).
210
+ */
211
+ function cap(text: string, budget: AgentRenderBudget): string {
212
+ return text.length <= budget.fieldMaxWidth
213
+ ? text
214
+ : `${text.slice(0, budget.fieldMaxWidth)}\u2026`;
215
+ }
@@ -1,9 +1,10 @@
1
- import { describeBashCommandContext } from "#src/denial-messages";
2
- import { fitLinesToWidth } from "#src/presentation/line-fitting";
3
1
  import {
4
- allEvidence,
5
- type PromptPayload,
6
- } from "#src/presentation/prompt-payload";
2
+ describeBashCommandContext,
3
+ flaggedElements,
4
+ valueLabel,
5
+ } from "#src/presentation/fact-vocabulary";
6
+ import { fitLinesToWidth } from "#src/presentation/line-fitting";
7
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
7
8
 
8
9
  /**
9
10
  * Render a {@link PromptPayload} for a human deciding an ask (ADR 0011 §5).
@@ -31,7 +32,7 @@ export function renderPromptDialog(
31
32
  );
32
33
  const blocks = layout(
33
34
  [...core, ...evidence],
34
- flaggedTexts(payload),
35
+ flaggedElements(payload),
35
36
  paint,
36
37
  ).map((block) => fitLinesToWidth(block, budget.width));
37
38
  const fitted = fitToRows(
@@ -122,21 +123,6 @@ export function completeViewBudget(width: number): DialogBudget {
122
123
 
123
124
  const plainText: HighlightPaint = (text) => text;
124
125
 
125
- /**
126
- * What the ask is flagging.
127
- *
128
- * The decision-relevant value for every shape but one: a bash ask that escaped
129
- * the working directory flags the paths it referenced, not the command that
130
- * referenced them — the command is the context, and the paths are what the
131
- * operator is ruling on.
132
- */
133
- function flaggedTexts(payload: PromptPayload): string[] {
134
- if (payload.kind === "bash_external_directory") {
135
- return allEvidence(payload, "external path").map((entry) => entry.text);
136
- }
137
- return payload.request.value === "" ? [] : [payload.request.value];
138
- }
139
-
140
126
  /** One rendered fact. */
141
127
  interface Fact {
142
128
  readonly label: string;
@@ -242,9 +228,7 @@ function coreFacts(payload: PromptPayload): Fact[] {
242
228
  if (request.executedUnit !== null) {
243
229
  facts.push({ label: "runs", text: request.executedUnit });
244
230
  }
245
- const context = describeBashCommandContext(
246
- request.commandContext ?? undefined,
247
- );
231
+ const context = describeBashCommandContext(request.commandContext);
248
232
  if (context !== undefined) {
249
233
  facts.push({ label: "context", text: context });
250
234
  }
@@ -293,46 +277,6 @@ function toolText(payload: PromptPayload): string {
293
277
  : `${String(toolName)} (invoked as ${invokedToolName})`;
294
278
  }
295
279
 
296
- /** What the decision-relevant value is called, per ask shape. */
297
- function valueLabel(payload: PromptPayload): string {
298
- switch (payload.kind) {
299
- case "bash":
300
- case "bash_external_directory":
301
- return "command";
302
- case "mcp":
303
- return "target";
304
- case "tool":
305
- return "tool";
306
- case "path":
307
- case "external_directory":
308
- return "path";
309
- case "skill":
310
- case "skill_read":
311
- return "skill";
312
- case "forwarded":
313
- return forwardedValueLabel(payload.request.surface);
314
- }
315
- }
316
-
317
- /**
318
- * A forwarded request carries the child's *display* projection — its tool name
319
- * as the surface — rather than the child's own payload, so the label is
320
- * inferred from it and falls back to a neutral one.
321
- *
322
- * Dissolves when the payload replaces `message` on the wire (#745): the
323
- * serving node will then hold the child's real `kind`.
324
- */
325
- function forwardedValueLabel(surface: string): string {
326
- switch (surface) {
327
- case "bash":
328
- return "command";
329
- case "skill":
330
- return "skill";
331
- default:
332
- return "value";
333
- }
334
- }
335
-
336
280
  /**
337
281
  * Align the labels into a `label : value` column.
338
282
  *
@@ -0,0 +1,103 @@
1
+ import {
2
+ allEvidence,
3
+ type PromptPayload,
4
+ } from "#src/presentation/prompt-payload";
5
+ import type { BashCommandContext } from "#src/types";
6
+
7
+ /**
8
+ * The render vocabulary shared by every renderer over a {@link PromptPayload}.
9
+ *
10
+ * Which element an ask flags, what that element is called, and how a nested
11
+ * execution context reads are all answers a render needs and none of them is
12
+ * a payload fact — the payload carries `value`, `kind`, and `commandContext`,
13
+ * and this module is where they acquire a name. It lives apart from any one
14
+ * renderer so the dialog, the agent-facing text, and the review log cannot
15
+ * disagree about what a given ask is flagging.
16
+ */
17
+
18
+ /**
19
+ * What the ask is flagging.
20
+ *
21
+ * The decision-relevant value for every shape but one: a bash ask that escaped
22
+ * the working directory flags the paths it referenced, not the command that
23
+ * referenced them — the command is the context, and the paths are what the
24
+ * operator is ruling on.
25
+ */
26
+ export function flaggedElements(payload: PromptPayload): readonly string[] {
27
+ if (payload.kind === "bash_external_directory") {
28
+ return allEvidence(payload, "external path").map((entry) => entry.text);
29
+ }
30
+ return payload.request.value === "" ? [] : [payload.request.value];
31
+ }
32
+
33
+ /**
34
+ * What {@link flaggedElements} returns is called.
35
+ *
36
+ * Differs from {@link valueLabel} for exactly one shape: a bash ask that
37
+ * escaped the working directory flags paths while its value is the command,
38
+ * so the two nouns are for two different things.
39
+ */
40
+ export function flaggedElementLabel(payload: PromptPayload): string {
41
+ return payload.kind === "bash_external_directory"
42
+ ? "path"
43
+ : valueLabel(payload);
44
+ }
45
+
46
+ /** What the decision-relevant value is called, per ask shape. */
47
+ export function valueLabel(payload: PromptPayload): string {
48
+ switch (payload.kind) {
49
+ case "bash":
50
+ case "bash_external_directory":
51
+ return "command";
52
+ case "mcp":
53
+ return "target";
54
+ case "tool":
55
+ return "tool";
56
+ case "path":
57
+ case "external_directory":
58
+ return "path";
59
+ case "skill":
60
+ case "skill_read":
61
+ return "skill";
62
+ case "forwarded":
63
+ return forwardedValueLabel(payload.request.surface);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Labels the version-skew render only: a payload-bearing forwarded ask carries
69
+ * the child's real `kind` and never reaches this arm (#745).
70
+ *
71
+ * Without a payload all that survives is the child's *display* projection — its
72
+ * tool name as the surface — so the label is inferred from it and falls back to
73
+ * a neutral one.
74
+ */
75
+ function forwardedValueLabel(surface: string): string {
76
+ switch (surface) {
77
+ case "bash":
78
+ return "command";
79
+ case "skill":
80
+ return "skill";
81
+ default:
82
+ return "value";
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Human-readable label for a nested bash execution context, or `undefined` for
88
+ * a current-shell (top-level) command.
89
+ */
90
+ export function describeBashCommandContext(
91
+ context: BashCommandContext | null,
92
+ ): string | undefined {
93
+ switch (context) {
94
+ case "command_substitution":
95
+ return "command substitution";
96
+ case "process_substitution":
97
+ return "process substitution";
98
+ case "subshell":
99
+ return "subshell";
100
+ case null:
101
+ return undefined;
102
+ }
103
+ }