@gotgenes/pi-permission-system 25.2.2 → 25.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +156 -41
  5. package/docs/configuration.md +17 -2
  6. package/package.json +1 -1
  7. package/schemas/permissions.schema.json +16 -0
  8. package/src/access-intent/bash/command-enumeration.ts +45 -117
  9. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  10. package/src/authority/forwarded-request-server.ts +5 -14
  11. package/src/authority/local-user-authorizer.ts +2 -3
  12. package/src/authority/permission-prompt-component.ts +87 -47
  13. package/src/authority/permission-prompter.ts +9 -0
  14. package/src/config-loader.ts +2 -0
  15. package/src/config-schema.ts +14 -0
  16. package/src/extension-config.ts +10 -0
  17. package/src/handlers/gates/bash-command.ts +7 -2
  18. package/src/handlers/gates/bash-external-directory.ts +11 -6
  19. package/src/handlers/gates/bash-path.ts +10 -6
  20. package/src/handlers/gates/external-directory.ts +13 -8
  21. package/src/handlers/gates/path.ts +11 -14
  22. package/src/handlers/gates/skill-input.ts +5 -2
  23. package/src/handlers/gates/skill-read.ts +5 -6
  24. package/src/handlers/gates/tool.ts +10 -5
  25. package/src/index.ts +2 -0
  26. package/src/permission-prompts.ts +4 -72
  27. package/src/presentation/dialog-renderer.ts +404 -0
  28. package/src/presentation/forwarded-ask-payload.ts +45 -0
  29. package/src/presentation/legacy-message.ts +117 -0
  30. package/src/presentation/line-fitting.ts +27 -0
  31. package/src/presentation/path-ask-payload.ts +128 -0
  32. package/src/presentation/prompt-payload.ts +137 -0
  33. package/src/presentation/skill-ask-payload.ts +50 -0
  34. package/src/presentation/tool-ask-payload.ts +104 -0
  35. package/src/tool-preview-formatter.ts +1 -1
  36. package/src/types.ts +6 -0
  37. package/src/handlers/gates/external-directory-messages.ts +0 -28
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Wrapper interpretation for a bash command unit: what kind of wrapper it is,
3
+ * and — where it can be established — what it actually runs.
4
+ *
5
+ * Pure and word-based; the AST walk that produces the words lives in
6
+ * `command-enumeration.ts`. Both questions live here together deliberately: the
7
+ * shape that floors a unit to `ask` and the shape that names its inner command
8
+ * must agree, and two classifiers over the same vocabulary would drift.
9
+ */
10
+
11
+ /** One word of a command unit: its text, and its offset into the unit's text. */
12
+ export interface CommandWord {
13
+ readonly text: string;
14
+ readonly offset: number;
15
+ }
16
+
17
+ /**
18
+ * Why a command unit's decision is floored to at least `ask`.
19
+ * `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
20
+ * program is not re-parsed (#481).
21
+ * `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
22
+ * whose inner command is a visible argument but is not gated on its own (#490).
23
+ * The kind selects the audit sentinel; both floor identically.
24
+ */
25
+ export type WrapperKind = "opaque-payload" | "indirection";
26
+
27
+ /**
28
+ * Classify a command unit's words as a floored wrapper, or `undefined` for an
29
+ * ordinary command. `words[0]` is the command name; a leading
30
+ * `variable_assignment` prefix is already stripped by the caller. The command
31
+ * name is matched on its basename, so `/bin/bash -c …` counts.
32
+ *
33
+ * `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
34
+ * `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
35
+ * argument the enumerator does not re-parse (#481).
36
+ *
37
+ * `"indirection"`: an always-invoking prefix/exec wrapper
38
+ * ({@link INDIRECTION_WRAPPER_NAMES}), or a search tool
39
+ * ({@link EXEC_CONDITIONAL_WRAPPERS}, `find`/`fd`) carrying a per-result exec
40
+ * flag — the inner command is a visible argument that a `<cmd> *` rule would
41
+ * otherwise never match (#490). A bare `find`/`fd` search runs no subcommand and
42
+ * is not flagged.
43
+ */
44
+ export function classifyWrapperWords(
45
+ words: readonly CommandWord[],
46
+ ): WrapperKind | undefined {
47
+ const commandName = wrapperName(words);
48
+ if (commandName === undefined) return undefined;
49
+ const args = words.slice(1).map((word) => word.text);
50
+ if (commandName === "eval") return "opaque-payload";
51
+ if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
52
+ return "opaque-payload";
53
+ }
54
+ if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
55
+ if (execFlagIndex(commandName, args) !== -1) return "indirection";
56
+ return undefined;
57
+ }
58
+
59
+ // ── Wrapper vocabulary ───────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * The command a wrapper unit actually runs, or `null` when it cannot be
63
+ * established or adds nothing over the unit itself.
64
+ *
65
+ * Display-only (ADR 0011 §3.5, #713): the result is never gated and never
66
+ * becomes a `BashCommand`, so the wrapper floor is untouched. Because it is
67
+ * shown on a decision surface, the rule is to fail to `null` rather than to a
68
+ * guess — an unrecognized option shape yields nothing rather than a remainder
69
+ * that might name the wrong command.
70
+ *
71
+ * Nested wrappers unwrap to the innermost command (`sudo timeout 5 xargs grep
72
+ * foo` → `grep foo`), bounded by {@link MAX_UNWRAP_DEPTH}.
73
+ */
74
+ export function executedUnitOf(
75
+ unitText: string,
76
+ words: readonly CommandWord[],
77
+ ): string | null {
78
+ let text = unitText;
79
+ let current = words;
80
+
81
+ for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
82
+ const kind = classifyWrapperWords(current);
83
+ if (kind === undefined) break;
84
+
85
+ if (kind === "opaque-payload") {
86
+ // The payload is an inner *program*, not a slice of this command line, so
87
+ // it is unquoted and terminal — unwrapping it further would need a parse.
88
+ return nothingNew(opaquePayload(current), unitText);
89
+ }
90
+
91
+ const start = innerCommandIndex(current);
92
+ if (start === -1 || start >= current.length) break;
93
+ const end = execTerminatorIndex(current, start);
94
+ text = sliceWords(text, current, start, end).trimEnd();
95
+ current = rebase(current, start, end);
96
+ }
97
+
98
+ return nothingNew(text, unitText);
99
+ }
100
+
101
+ /** How many wrapper layers to unwrap before giving up. */
102
+ const MAX_UNWRAP_DEPTH = 4;
103
+
104
+ /**
105
+ * The extracted text, or `null` when it establishes nothing new — it is absent
106
+ * or empty, it still begins with an option (so the inner command was never
107
+ * reached), or it simply repeats the unit.
108
+ */
109
+ function nothingNew(text: string | null, unitText: string): string | null {
110
+ if (text === null || text === "" || text === unitText) return null;
111
+ return text.startsWith("-") ? null : text;
112
+ }
113
+
114
+ /** The inline-shell payload argument, unquoted; `null` when absent. */
115
+ function opaquePayload(words: readonly CommandWord[]): string | null {
116
+ const args = words.slice(1);
117
+ // `eval` takes its program as the first argument (no `-c`, so the index is
118
+ // -1); a shell takes it after the `-c` cluster.
119
+ const flagIndex = shortFlagCIndex(args.map((word) => word.text));
120
+ const payload = args[flagIndex + 1] as CommandWord | undefined;
121
+ return payload === undefined ? null : unquote(payload.text);
122
+ }
123
+
124
+ /** Strip one matching pair of surrounding quotes. */
125
+ function unquote(text: string): string {
126
+ const first = text.at(0);
127
+ const quoted =
128
+ (first === "'" || first === '"') &&
129
+ text.length >= 2 &&
130
+ text.endsWith(first);
131
+ return quoted ? text.slice(1, -1) : text;
132
+ }
133
+
134
+ /**
135
+ * Index of the word beginning the inner command, or `-1` when the wrapper's own
136
+ * options run out first.
137
+ *
138
+ * Skips the wrapper name, environment assignments, options (consuming a
139
+ * following value for the options in {@link VALUE_TAKING_FLAGS}), and a leading
140
+ * operand for the wrappers that take one. An exec-conditional wrapper instead
141
+ * starts immediately after its exec flag.
142
+ */
143
+ function innerCommandIndex(words: readonly CommandWord[]): number {
144
+ const name = wrapperName(words);
145
+ if (name === undefined) return -1;
146
+
147
+ const argTexts = words.slice(1).map((word) => word.text);
148
+ const execFlag = execFlagIndex(name, argTexts);
149
+ if (execFlag !== -1) return execFlag + 2;
150
+
151
+ const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
152
+ let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
153
+ let index = 1;
154
+
155
+ while (index < words.length) {
156
+ const word = words[index].text;
157
+ if (word === "--") return index + 1;
158
+ if (isEnvironmentAssignment(word)) {
159
+ index++;
160
+ continue;
161
+ }
162
+ if (word.startsWith("-")) {
163
+ index += valueTaking.has(word) ? 2 : 1;
164
+ continue;
165
+ }
166
+ if (operandPending) {
167
+ operandPending = false;
168
+ index++;
169
+ continue;
170
+ }
171
+ return index;
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ /**
177
+ * Index of an exec wrapper's `;`/`+` terminator, or `words.length` — the
178
+ * terminator belongs to `find`, not to the command it runs.
179
+ */
180
+ function execTerminatorIndex(
181
+ words: readonly CommandWord[],
182
+ start: number,
183
+ ): number {
184
+ const terminator = words.findIndex(
185
+ (word, index) =>
186
+ index >= start && EXEC_TERMINATORS.has(word.text.replace(/^\\/, "")),
187
+ );
188
+ return terminator === -1 ? words.length : terminator;
189
+ }
190
+
191
+ /** The unit text spanned by `words[start..end)`. */
192
+ function sliceWords(
193
+ unitText: string,
194
+ words: readonly CommandWord[],
195
+ start: number,
196
+ end: number,
197
+ ): string {
198
+ const from = words[start].offset;
199
+ return end < words.length
200
+ ? unitText.slice(from, words[end].offset)
201
+ : unitText.slice(from);
202
+ }
203
+
204
+ /** `words[start..end)` with offsets rebased onto the sliced text. */
205
+ function rebase(
206
+ words: readonly CommandWord[],
207
+ start: number,
208
+ end: number,
209
+ ): CommandWord[] {
210
+ const origin = words[start].offset;
211
+ return words
212
+ .slice(start, end)
213
+ .map((word) => ({ text: word.text, offset: word.offset - origin }));
214
+ }
215
+
216
+ /** True for a `NAME=value` environment prefix. */
217
+ function isEnvironmentAssignment(word: string): boolean {
218
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
219
+ }
220
+
221
+ /**
222
+ * Shell command names whose `-c` flag introduces an opaque inline program.
223
+ */
224
+ const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
225
+
226
+ /**
227
+ * Indirection wrappers that always invoke a following command, so the wrapper
228
+ * (not the inner command) is what a bash rule matches. Floored by command-name
229
+ * basename alone. Extend this set to cover another always-invoking wrapper.
230
+ */
231
+ const INDIRECTION_WRAPPER_NAMES = new Set([
232
+ "sudo",
233
+ "env",
234
+ "xargs",
235
+ "time",
236
+ "nohup",
237
+ "timeout",
238
+ "nice",
239
+ // Exec-capable rewrites and prefix wrappers surveyed in #575: parallelizers
240
+ // (parallel/rust-parallel/rush), a sudo rewrite (doas), and prefix wrappers
241
+ // (setsid/stdbuf/watch/flock) that all always invoke a following command.
242
+ "parallel",
243
+ "rust-parallel",
244
+ "rush",
245
+ "doas",
246
+ "setsid",
247
+ "stdbuf",
248
+ "watch",
249
+ "flock",
250
+ ]);
251
+
252
+ /**
253
+ * Search tools that invoke a command per result only when an exec flag is
254
+ * present; a bare search runs no subcommand. Floored only when an argument
255
+ * exactly matches one of the tool's exec flags. Extend by adding a tool with
256
+ * its exec-flag set.
257
+ */
258
+ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
259
+ ["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
260
+ ["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
261
+ ]);
262
+
263
+ /**
264
+ * Curated per-wrapper options that consume the following word, so skipping a
265
+ * wrapper's own arguments does not mistake an option's value for the inner
266
+ * command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
267
+ * word. Only the display-side extraction reads this, and a missing or wrong
268
+ * entry yields `null` (see {@link executedUnitOf}), never a weaker gate.
269
+ */
270
+ const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
271
+ ["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
272
+ ["doas", new Set(["-u", "-C"])],
273
+ ["env", new Set(["-u", "-C", "--unset", "--chdir"])],
274
+ [
275
+ "xargs",
276
+ new Set(["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-l", "-s", "-a"]),
277
+ ],
278
+ ["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
279
+ ["nice", new Set(["-n", "--adjustment"])],
280
+ ["time", new Set(["-o", "-f", "--output", "--format"])],
281
+ ["stdbuf", new Set(["-i", "-o", "-e", "--input", "--output", "--error"])],
282
+ ["watch", new Set(["-n", "--interval"])],
283
+ ["flock", new Set(["-w", "-E", "--timeout", "--conflict-exit-code"])],
284
+ ]);
285
+
286
+ const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
287
+
288
+ /**
289
+ * Wrappers whose first bare word is an operand (a duration, a lock file) rather
290
+ * than the start of the inner command.
291
+ */
292
+ const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
293
+
294
+ /** Words ending a `find -exec` clause; they belong to `find`, not its command. */
295
+ const EXEC_TERMINATORS = new Set([";", "+"]);
296
+
297
+ // ── Shared helpers ───────────────────────────────────────────────────────────
298
+
299
+ /** The wrapper's command-name basename, or `undefined` for an empty unit. */
300
+ function wrapperName(words: readonly CommandWord[]): string | undefined {
301
+ return words.length === 0 ? undefined : basename(words[0].text);
302
+ }
303
+
304
+ /**
305
+ * True when an argument list has a short-flag cluster containing `c` before any
306
+ * `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
307
+ * flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
308
+ */
309
+ function hasShortFlagC(args: readonly string[]): boolean {
310
+ return shortFlagCIndex(args) !== -1;
311
+ }
312
+
313
+ /** Index within `args` of the `-c` short-flag cluster, or `-1`. */
314
+ function shortFlagCIndex(args: readonly string[]): number {
315
+ for (const [index, arg] of args.entries()) {
316
+ if (arg === "--") return -1;
317
+ if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
318
+ return index;
319
+ }
320
+ }
321
+ return -1;
322
+ }
323
+
324
+ /** Index within `args` of a matched per-result exec flag, or `-1`. */
325
+ function execFlagIndex(commandName: string, args: readonly string[]): number {
326
+ const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
327
+ if (!execFlags) return -1;
328
+ return args.findIndex((arg) => execFlags.has(arg));
329
+ }
330
+
331
+ /** The final path segment of a command name (`/bin/bash` → `bash`). */
332
+ function basename(name: string): string {
333
+ const slash = name.lastIndexOf("/");
334
+ return slash === -1 ? name : name.slice(slash + 1);
335
+ }
@@ -13,6 +13,8 @@ import {
13
13
  type PermissionForwardingLocation,
14
14
  } from "#src/authority/permission-forwarding";
15
15
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
16
+ import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
17
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
16
18
  import { SessionApproval } from "#src/session-approval";
17
19
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
18
20
  import type { DebugReviewLogger } from "#src/session-logger";
@@ -78,19 +80,6 @@ export interface ForwardedRequestServerDeps {
78
80
 
79
81
  // ── Module-private helpers ────────────────────────────────────────────────
80
82
 
81
- function formatForwardedPermissionPrompt(
82
- request: ForwardedPermissionRequest,
83
- ): string {
84
- const agentName = request.requesterAgentName || "unknown";
85
- const sessionId = request.requesterSessionId || "unknown";
86
- return [
87
- `Subagent '${agentName}' requested permission.`,
88
- `Session ID: ${sessionId}`,
89
- "",
90
- request.message,
91
- ].join("\n");
92
- }
93
-
94
83
  /**
95
84
  * Map a forwarded request onto the escalated ask's details, carrying the
96
85
  * forwarded provenance (requester agent/session + the child's original display
@@ -106,11 +95,13 @@ function formatForwardedPermissionPrompt(
106
95
  function buildForwardedAskDetails(
107
96
  request: ForwardedPermissionRequest,
108
97
  ): PromptPermissionDetails {
98
+ const payload = buildForwardedAskPayload(request);
109
99
  return {
110
100
  requestId: request.id,
111
101
  source: request.source ?? "tool_call",
112
102
  agentName: request.requesterAgentName || null,
113
- message: formatForwardedPermissionPrompt(request),
103
+ message: renderLegacyMessage(payload),
104
+ payload,
114
105
  surface: request.surface ?? null,
115
106
  value: request.value ?? null,
116
107
  forwarding: {
@@ -53,13 +53,12 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
53
53
  {
54
54
  mode: this.deps.mode,
55
55
  ui: this.deps.ui,
56
- doublePressToConfirm:
57
- this.deps.getPromptPreferences().doublePressToConfirm,
56
+ ...this.deps.getPromptPreferences(),
58
57
  },
59
58
  details.forwarding
60
59
  ? "Permission Required (Subagent)"
61
60
  : "Permission Required",
62
- details.message,
61
+ details.payload,
63
62
  buildRequestOptions(details),
64
63
  );
65
64
  }
@@ -3,12 +3,7 @@ import type {
3
3
  ExtensionUIContext,
4
4
  KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import {
7
- type Component,
8
- matchesKey,
9
- truncateToWidth,
10
- wrapTextWithAnsi,
11
- } from "@earendil-works/pi-tui";
6
+ import { type Component, matchesKey } from "@earendil-works/pi-tui";
12
7
  import {
13
8
  type PermissionPromptDecision,
14
9
  type RequestPermissionOptions,
@@ -22,6 +17,14 @@ import {
22
17
  type PromptViewState,
23
18
  reducePrompt,
24
19
  } from "#src/authority/permission-prompt-decision";
20
+ import {
21
+ completeViewBudget,
22
+ type DialogView,
23
+ type RenderBudget,
24
+ renderPromptDialog,
25
+ } from "#src/presentation/dialog-renderer";
26
+ import { fitLinesToWidth } from "#src/presentation/line-fitting";
27
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
25
28
 
26
29
  /**
27
30
  * Inline `ctx.ui.custom` permission dialog for TUI sessions.
@@ -43,15 +46,16 @@ export type PermissionPromptUi = Pick<
43
46
  type PromptKeybindings = Pick<KeybindingsManager, "matches">;
44
47
 
45
48
  /** The resolved presentation context selected once per activation. */
46
- export interface PermissionPromptView {
49
+ export interface PermissionPromptView extends PromptPreferences {
47
50
  mode: ExtensionContext["mode"];
48
51
  ui: PermissionPromptUi;
49
- doublePressToConfirm: boolean;
50
52
  }
51
53
 
52
54
  /** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
53
55
  export interface PromptPreferences {
54
56
  doublePressToConfirm: boolean;
57
+ /** How much room a render has; the terminal width is added per frame. */
58
+ budget: RenderBudget;
55
59
  }
56
60
 
57
61
  /**
@@ -64,15 +68,30 @@ export interface PromptPreferences {
64
68
  export function requestPermissionDecision(
65
69
  view: PermissionPromptView,
66
70
  title: string,
67
- message: string,
71
+ payload: PromptPayload,
68
72
  options?: RequestPermissionOptions,
69
73
  ): Promise<PermissionPromptDecision> {
70
74
  if (view.mode === "tui") {
71
- return presentInlinePermissionPrompt(view, title, message, options);
75
+ return presentInlinePermissionPrompt(view, title, payload, options);
72
76
  }
73
- return requestPermissionDecisionFromUi(view.ui, title, message, options);
77
+ // The fallback renders once and cannot re-render, so it neither paints nor
78
+ // offers an expansion; it substitutes a nominal width for the terminal size
79
+ // it is never told, and the host's own select wraps from there.
80
+ const rendered = renderPromptDialog(payload, {
81
+ ...view.budget,
82
+ width: FALLBACK_RENDER_WIDTH,
83
+ });
84
+ return requestPermissionDecisionFromUi(
85
+ view.ui,
86
+ title,
87
+ rendered.lines.join("\n"),
88
+ options,
89
+ );
74
90
  }
75
91
 
92
+ /** The width the `select`/`input` fallback renders against. */
93
+ const FALLBACK_RENDER_WIDTH = 80;
94
+
76
95
  /** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
77
96
  interface PromptTheme {
78
97
  fg(color: string, text: string): string;
@@ -92,7 +111,7 @@ const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
92
111
  export function presentInlinePermissionPrompt(
93
112
  view: PermissionPromptView,
94
113
  title: string,
95
- message: string,
114
+ payload: PromptPayload,
96
115
  options?: RequestPermissionOptions,
97
116
  ): Promise<PermissionPromptDecision> {
98
117
  const config: PromptModelConfig = {
@@ -106,7 +125,8 @@ export function presentInlinePermissionPrompt(
106
125
  theme,
107
126
  config,
108
127
  title,
109
- message,
128
+ payload,
129
+ view.budget,
110
130
  (data) => handleToolsExpandAction(data, keybindings, view.ui),
111
131
  () => {
112
132
  tui.requestRender();
@@ -145,12 +165,15 @@ function handleToolsExpandAction(
145
165
  class PermissionPromptComponent implements Component {
146
166
  private state: PromptViewState;
147
167
  private reasonBuffer = "";
168
+ /** Whether the operator asked to see the complete request (ADR 0011 §4). */
169
+ private expanded = false;
148
170
 
149
171
  constructor(
150
172
  private readonly theme: PromptTheme,
151
173
  private readonly config: PromptModelConfig,
152
174
  private readonly title: string,
153
- private readonly message: string,
175
+ private readonly payload: PromptPayload,
176
+ private readonly budget: RenderBudget,
154
177
  private readonly handleAppAction: (data: string) => boolean,
155
178
  private readonly requestRender: () => void,
156
179
  private readonly done: (decision: PermissionPromptDecision) => void,
@@ -163,26 +186,66 @@ class PermissionPromptComponent implements Component {
163
186
  }
164
187
 
165
188
  render(width: number): string[] {
166
- return fitToWidth(this.renderStep(), width);
189
+ return fitLinesToWidth(this.renderStep(width), width);
167
190
  }
168
191
 
169
- private renderStep(): string[] {
192
+ private renderStep(width: number): string[] {
170
193
  switch (this.state.step) {
171
194
  case "decision":
172
- return this.renderDecision();
195
+ return this.renderDecision(width);
173
196
  case "reason":
174
- return this.renderReason();
197
+ return this.renderReason(width);
175
198
  case "scope":
176
199
  return this.renderScope();
177
200
  }
178
201
  }
179
202
 
203
+ /**
204
+ * The ask itself, bounded to the budget at this frame's width.
205
+ *
206
+ * Rendered per frame rather than once, because the row budget is a function
207
+ * of the width the host gives us, which a resize changes.
208
+ */
209
+ private renderAsk(width: number): DialogView {
210
+ return renderPromptDialog(
211
+ this.payload,
212
+ this.expanded ? completeViewBudget(width) : { ...this.budget, width },
213
+ (text) => this.theme.fg("warning", text),
214
+ );
215
+ }
216
+
217
+ /**
218
+ * The key hints, naming the expansion only when it would do something.
219
+ *
220
+ * An affordance advertised when there is nothing to expand is noise; one
221
+ * left unadvertised when the render dropped something is a decision made
222
+ * without the evidence.
223
+ */
224
+ private hint(view: DialogView): string {
225
+ const keys = [
226
+ "↑/↓ move",
227
+ "enter confirm",
228
+ "esc deny",
229
+ "press a letter, then again to confirm",
230
+ ];
231
+ if (this.expanded) {
232
+ keys.push("ctrl+o collapse");
233
+ } else if (view.elided) {
234
+ keys.push("ctrl+o full request");
235
+ }
236
+ return this.theme.fg("muted", keys.join(" · "));
237
+ }
238
+
180
239
  handleInput(data: string): void {
181
240
  if (this.state.step === "reason") {
182
241
  this.handleReasonInput(data);
183
242
  return;
184
243
  }
185
244
  if (this.handleAppAction(data)) {
245
+ // One "expand" for the operator: the host expands its pending tool call
246
+ // and the dialog expands its own render, on the same keystroke.
247
+ this.expanded = !this.expanded;
248
+ this.requestRender();
186
249
  return;
187
250
  }
188
251
  const event = this.toEvent(data);
@@ -247,8 +310,9 @@ class PermissionPromptComponent implements Component {
247
310
  this.requestRender();
248
311
  }
249
312
 
250
- private renderDecision(): string[] {
251
- const lines = [this.theme.fg("accent", this.title), this.message, ""];
313
+ private renderDecision(width: number): string[] {
314
+ const ask = this.renderAsk(width);
315
+ const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
252
316
  for (const key of OPTION_ORDER) {
253
317
  const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
254
318
  const selected = this.state.highlightedKey === key;
@@ -257,20 +321,14 @@ class PermissionPromptComponent implements Component {
257
321
  lines.push(selected ? this.theme.fg("accent", row) : row);
258
322
  }
259
323
  lines.push("");
260
- lines.push(
261
- this.state.hint ||
262
- this.theme.fg(
263
- "muted",
264
- "↑/↓ move · enter confirm · esc deny · press a letter, then again to confirm",
265
- ),
266
- );
324
+ lines.push(this.state.hint || this.hint(ask));
267
325
  return lines;
268
326
  }
269
327
 
270
- private renderReason(): string[] {
328
+ private renderReason(width: number): string[] {
271
329
  const lines = [
272
330
  this.theme.fg("accent", this.title),
273
- this.message,
331
+ ...this.renderAsk(width).lines,
274
332
  "",
275
333
  `Reason (required): ${this.reasonBuffer}\u2588`,
276
334
  ];
@@ -307,24 +365,6 @@ class PermissionPromptComponent implements Component {
307
365
  }
308
366
  }
309
367
 
310
- /**
311
- * Fit rendered lines to the terminal width, satisfying the `ctx.ui.custom`
312
- * contract that every returned line be a single visual row no wider than
313
- * `width`. Long lines (e.g. a wide tool-preview message) are wrapped rather
314
- * than clipped so no content is lost; the final `truncateToWidth` guards the
315
- * edge cases `wrapTextWithAnsi` cannot split (a lone wide grapheme).
316
- */
317
- function fitToWidth(lines: string[], width: number): string[] {
318
- if (width <= 0) {
319
- return [];
320
- }
321
- return lines.flatMap((line) =>
322
- wrapTextWithAnsi(line, width).map((wrapped) =>
323
- truncateToWidth(wrapped, width),
324
- ),
325
- );
326
- }
327
-
328
368
  function isPrintable(data: string): boolean {
329
369
  if (data.length !== 1) {
330
370
  return false;
@@ -3,6 +3,7 @@ import type {
3
3
  ForwardedAccessFacts,
4
4
  ForwardedSessionApproval,
5
5
  } from "#src/authority/permission-forwarding";
6
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
6
7
  import type { ReviewLogger } from "#src/session-logger";
7
8
  import type { TerminalAuthorizer } from "./authorizer";
8
9
 
@@ -27,6 +28,14 @@ export interface PromptPermissionDetails {
27
28
  source: PermissionReviewSource;
28
29
  agentName: string | null;
29
30
  message: string;
31
+ /**
32
+ * The complete structured description of this ask (ADR 0011 §2).
33
+ *
34
+ * Required: every ask carries one, and the type is what guarantees it rather
35
+ * than a convention each gate has to remember. `message` is a render over it
36
+ * for the duration of the transition, so the two cannot disagree.
37
+ */
38
+ payload: PromptPayload;
30
39
  toolCallId?: string;
31
40
  toolName?: string;
32
41
  skillName?: string;
@@ -222,6 +222,8 @@ export function mergeUnifiedConfigs(
222
222
  // Number scalars: override replaces base when defined
223
223
  for (const key of [
224
224
  "forwardingTimeoutMs",
225
+ "promptMaxRows",
226
+ "promptFieldMaxWidth",
225
227
  "toolInputPreviewMaxLength",
226
228
  "toolTextSummaryMaxLength",
227
229
  ] as const) {