@gotgenes/pi-permission-system 25.2.2 → 25.4.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 (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +162 -41
  5. package/docs/configuration.md +17 -2
  6. package/docs/cross-extension-api.md +14 -9
  7. package/package.json +1 -1
  8. package/schemas/permissions.schema.json +16 -0
  9. package/src/access-intent/bash/command-enumeration.ts +45 -117
  10. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  11. package/src/authority/approval-escalator.ts +26 -1
  12. package/src/authority/forwarded-request-server.ts +5 -14
  13. package/src/authority/local-user-authorizer.ts +2 -3
  14. package/src/authority/permission-prompt-component.ts +87 -47
  15. package/src/authority/permission-prompter.ts +9 -0
  16. package/src/config-loader.ts +2 -0
  17. package/src/config-schema.ts +14 -0
  18. package/src/extension-config.ts +10 -0
  19. package/src/handlers/gates/bash-command.ts +7 -2
  20. package/src/handlers/gates/bash-external-directory.ts +11 -6
  21. package/src/handlers/gates/bash-path.ts +10 -6
  22. package/src/handlers/gates/descriptor.ts +10 -1
  23. package/src/handlers/gates/external-directory.ts +13 -8
  24. package/src/handlers/gates/helpers.ts +6 -7
  25. package/src/handlers/gates/path.ts +11 -14
  26. package/src/handlers/gates/runner.ts +40 -19
  27. package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
  28. package/src/handlers/gates/skill-input.ts +5 -2
  29. package/src/handlers/gates/skill-read.ts +6 -6
  30. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
  31. package/src/handlers/gates/tool.ts +10 -5
  32. package/src/handlers/tool-call-boundary.ts +30 -7
  33. package/src/index.ts +2 -0
  34. package/src/permission-events.ts +6 -0
  35. package/src/permission-prompts.ts +4 -72
  36. package/src/permission-request-id.ts +17 -0
  37. package/src/presentation/dialog-renderer.ts +404 -0
  38. package/src/presentation/forwarded-ask-payload.ts +45 -0
  39. package/src/presentation/legacy-message.ts +117 -0
  40. package/src/presentation/line-fitting.ts +27 -0
  41. package/src/presentation/path-ask-payload.ts +128 -0
  42. package/src/presentation/prompt-payload.ts +137 -0
  43. package/src/presentation/skill-ask-payload.ts +50 -0
  44. package/src/presentation/tool-ask-payload.ts +104 -0
  45. package/src/tool-preview-formatter.ts +1 -1
  46. package/src/types.ts +6 -0
  47. package/src/handlers/gates/external-directory-messages.ts +0 -28
@@ -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) {
@@ -194,6 +194,20 @@ export const unifiedConfigSchema = z
194
194
  "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
195
195
  default: 600000,
196
196
  }),
197
+ promptMaxRows: z.number().int().min(1).optional().meta({
198
+ description:
199
+ "Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
200
+ markdownDescription:
201
+ "Maximum rows a permission prompt renders before eliding its evidence.\n\nOmit to use the default (24). The request's own facts — the requesting agent, the tool, the matched rule, the decision-relevant value — are never elided by this budget; what gives way is the supporting evidence, and `Ctrl+O` expands the prompt to the complete request.",
202
+ default: 24,
203
+ }),
204
+ promptFieldMaxWidth: z.number().int().min(1).optional().meta({
205
+ description:
206
+ "Maximum characters of any one field shown in a permission prompt. Omit to use the default (400).",
207
+ markdownDescription:
208
+ "Maximum characters of any one field shown in a permission prompt.\n\nOmit to use the default (400). This is what bounds a single pathological field — a long here-string command, say — that would otherwise fill the prompt through wrapping. A shortened field is marked with an ellipsis, and `Ctrl+O` shows it in full.",
209
+ default: 400,
210
+ }),
197
211
  toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
198
212
  description:
199
213
  "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
@@ -22,6 +22,10 @@ export interface PermissionSystemExtensionConfig {
22
22
  piInfrastructureReadPaths?: string[];
23
23
  /** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
24
24
  forwardingTimeoutMs?: number;
25
+ /** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */
26
+ promptMaxRows?: number;
27
+ /** Max characters of any one field shown in a permission prompt. Defaults to 400. */
28
+ promptFieldMaxWidth?: number;
25
29
  /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
26
30
  toolInputPreviewMaxLength?: number;
27
31
  /** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
@@ -76,6 +80,12 @@ export function normalizePermissionSystemConfig(
76
80
  if (raw.forwardingTimeoutMs !== undefined) {
77
81
  result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
78
82
  }
83
+ if (raw.promptMaxRows !== undefined) {
84
+ result.promptMaxRows = raw.promptMaxRows;
85
+ }
86
+ if (raw.promptFieldMaxWidth !== undefined) {
87
+ result.promptFieldMaxWidth = raw.promptFieldMaxWidth;
88
+ }
79
89
  if (raw.toolInputPreviewMaxLength !== undefined) {
80
90
  result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
81
91
  }
@@ -83,7 +83,7 @@ export function resolveBashCommandCheck(
83
83
  input: { command: cmd.text },
84
84
  agentName,
85
85
  });
86
- const result =
86
+ const floored =
87
87
  cmd.wrapperKind && base.state === "allow"
88
88
  ? {
89
89
  ...base,
@@ -91,7 +91,12 @@ export function resolveBashCommandCheck(
91
91
  matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind],
92
92
  }
93
93
  : base;
94
- return cmd.context ? { ...result, commandContext: cmd.context } : result;
94
+ const result = cmd.context
95
+ ? { ...floored, commandContext: cmd.context }
96
+ : floored;
97
+ return cmd.executedUnit === undefined
98
+ ? result
99
+ : { ...result, executedUnit: cmd.executedUnit };
95
100
  });
96
101
  return (
97
102
  pickMostRestrictive(results) ??
@@ -1,9 +1,10 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
4
+ import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
3
5
  import { SessionApproval } from "#src/session-approval";
4
6
  import { deriveApprovalPattern } from "#src/session-rules";
5
7
  import type { GateResult } from "./descriptor";
6
- import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
7
8
  import { selectUncoveredExternalPaths } from "./external-directory-policy";
8
9
  import { accessFactsFromPath } from "./helpers";
9
10
  import type { ToolCallContext } from "./types";
@@ -76,12 +77,15 @@ export function describeBashExternalDirectoryGate(
76
77
  resolvedPath: path.resolvedAlias(),
77
78
  }));
78
79
 
79
- const bashExtMessage = formatBashExternalDirectoryAskPrompt(
80
+ const payload = buildBashExternalDirectoryAskPayload({
80
81
  command,
81
- disclosures,
82
- tcc.cwd,
83
- tcc.agentName ?? undefined,
84
- );
82
+ externalPaths: disclosures,
83
+ cwd: tcc.cwd,
84
+ agentName: tcc.agentName,
85
+ toolName: tcc.toolName,
86
+ matchedPattern: preCheck.matchedPattern,
87
+ });
88
+ const bashExtMessage = renderLegacyMessage(payload);
85
89
 
86
90
  const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
87
91
 
@@ -100,6 +104,7 @@ export function describeBashExternalDirectoryGate(
100
104
  source: "tool_call",
101
105
  agentName: tcc.agentName,
102
106
  message: bashExtMessage,
107
+ payload,
103
108
  toolCallId: tcc.toolCallId,
104
109
  toolName: tcc.toolName,
105
110
  command,
@@ -1,13 +1,14 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import type { BashProgram } from "#src/access-intent/bash/program";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
5
+ import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
4
6
  import { SessionApproval } from "#src/session-approval";
5
7
  import { deriveApprovalPattern } from "#src/session-rules";
6
8
  import type { PermissionCheckResult } from "#src/types";
7
9
  import { pickMostRestrictive } from "./candidate-check";
8
10
  import type { GateResult } from "./descriptor";
9
11
  import { accessFactsFromPath } from "./helpers";
10
- import { formatPathAskPrompt } from "./path";
11
12
  import type { ToolCallContext } from "./types";
12
13
 
13
14
  /**
@@ -113,11 +114,13 @@ export function describeBashPathGate(
113
114
  // path), so it matches the values a later call produces. For an unknown base
114
115
  // (`forLiteral`) `value()` is the raw token.
115
116
  const pattern = deriveApprovalPattern(worstEntry.path.value());
116
- const askMessage = formatPathAskPrompt(
117
- tcc.toolName,
118
- worstToken,
119
- tcc.agentName ?? undefined,
120
- );
117
+ const payload = buildPathAskPayload({
118
+ toolName: tcc.toolName,
119
+ pathValue: worstToken,
120
+ agentName: tcc.agentName,
121
+ matchedPattern: worstCheck.matchedPattern,
122
+ });
123
+ const askMessage = renderLegacyMessage(payload);
121
124
 
122
125
  return {
123
126
  surface: "path",
@@ -133,6 +136,7 @@ export function describeBashPathGate(
133
136
  source: "tool_call",
134
137
  agentName: tcc.agentName,
135
138
  message: askMessage,
139
+ payload,
136
140
  toolCallId: tcc.toolCallId,
137
141
  toolName: tcc.toolName,
138
142
  command,
@@ -51,6 +51,15 @@ export interface GateDescriptor {
51
51
  preCheck?: PermissionCheckResult;
52
52
  }
53
53
 
54
+ /**
55
+ * A decision event's facts, before the runner stamps the request id it minted.
56
+ *
57
+ * A gate knows what was decided but not which request it was deciding — the id
58
+ * is minted in `GateRunner.run`. Producing this type rather than the full event
59
+ * is what routes every emit through the runner's single stamping site.
60
+ */
61
+ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
62
+
54
63
  /**
55
64
  * Early allow result — gate has determined the action without needing the runner.
56
65
  *
@@ -62,7 +71,7 @@ export interface GateBypass {
62
71
  /** Optional review log entry to emit. */
63
72
  log?: { event: string; details: Record<string, unknown> };
64
73
  /** Optional decision event to emit. */
65
- decision?: PermissionDecisionEvent;
74
+ decision?: DecisionEventFacts;
66
75
  }
67
76
 
68
77
  /** Union of possible gate function return values. */
@@ -1,11 +1,12 @@
1
1
  import { getToolInputPath } from "#src/access-intent/tool-input-path";
2
2
  import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
5
+ import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
4
6
  import { SessionApproval } from "#src/session-approval";
5
7
  import { deriveApprovalPattern } from "#src/session-rules";
6
8
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
7
9
  import type { GateResult } from "./descriptor";
8
- import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
9
10
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
10
11
  import { accessFactsFromPath } from "./helpers";
11
12
  import type { ToolCallContext } from "./types";
@@ -69,13 +70,6 @@ export function describeExternalDirectoryGate(
69
70
 
70
71
  // ── Build descriptor for permission check ───────────────────────────────
71
72
  const resolvedAlias = accessPath.resolvedAlias();
72
- const extDirMessage = formatExternalDirectoryAskPrompt(
73
- tcc.toolName,
74
- externalDirectoryPath,
75
- resolvedAlias,
76
- tcc.cwd,
77
- tcc.agentName ?? undefined,
78
- );
79
73
 
80
74
  // The runner consumes this preCheck and skips its own resolve.
81
75
  const preCheck = resolveExternalDirectoryPolicy(
@@ -85,6 +79,16 @@ export function describeExternalDirectoryGate(
85
79
  );
86
80
  const pattern = deriveApprovalPattern(accessPath.value());
87
81
 
82
+ const payload = buildExternalDirectoryAskPayload({
83
+ toolName: tcc.toolName,
84
+ pathValue: externalDirectoryPath,
85
+ resolvedPath: resolvedAlias,
86
+ cwd: tcc.cwd,
87
+ agentName: tcc.agentName,
88
+ matchedPattern: preCheck.matchedPattern,
89
+ });
90
+ const extDirMessage = renderLegacyMessage(payload);
91
+
88
92
  return {
89
93
  surface: "external_directory",
90
94
  input: {},
@@ -102,6 +106,7 @@ export function describeExternalDirectoryGate(
102
106
  source: "tool_call",
103
107
  agentName: tcc.agentName,
104
108
  message: extDirMessage,
109
+ payload,
105
110
  toolCallId: tcc.toolCallId,
106
111
  toolName: tcc.toolName,
107
112
  path: externalDirectoryPath,
@@ -1,11 +1,9 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import { classifyToolKind } from "#src/access-intent/tool-kind";
3
3
  import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
4
- import type {
5
- PermissionDecisionEvent,
6
- PermissionDecisionResolution,
7
- } from "#src/permission-events";
4
+ import type { PermissionDecisionResolution } from "#src/permission-events";
8
5
  import type { PermissionCheckResult } from "#src/types";
6
+ import type { DecisionEventFacts } from "./descriptor";
9
7
 
10
8
  /**
11
9
  * Build the child-fixed access facts for a path-shaped gate from its
@@ -62,11 +60,12 @@ export function deriveDecisionValue(
62
60
  }
63
61
 
64
62
  /**
65
- * Build a `PermissionDecisionEvent` from the gate's inputs.
63
+ * Build a decision event's facts from the gate's inputs.
66
64
  *
67
65
  * Centralises the `origin / agentName / matchedPattern ?? null` normalization
68
66
  * that is otherwise duplicated across the session-hit path and the gate-result
69
- * path in `runGateCheck`.
67
+ * path in `runGateCheck`. The request id is stamped by the runner, which is
68
+ * where it was minted.
70
69
  */
71
70
  export function buildDecisionEvent(
72
71
  decision: { surface: string; value: string },
@@ -74,7 +73,7 @@ export function buildDecisionEvent(
74
73
  agentName: string | null,
75
74
  result: "allow" | "deny",
76
75
  resolution: PermissionDecisionResolution,
77
- ): PermissionDecisionEvent {
76
+ ): DecisionEventFacts {
78
77
  return {
79
78
  surface: decision.surface,
80
79
  value: decision.value,
@@ -1,6 +1,8 @@
1
1
  import { getToolInputPath } from "#src/access-intent/tool-input-path";
2
2
  import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
5
+ import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
4
6
  import { SessionApproval } from "#src/session-approval";
5
7
  import { deriveApprovalPattern } from "#src/session-rules";
6
8
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
@@ -47,6 +49,13 @@ export function describePathGate(
47
49
  // the policy values a later call produces.
48
50
  const pattern = deriveApprovalPattern(accessPath.value());
49
51
 
52
+ const payload = buildPathAskPayload({
53
+ toolName: tcc.toolName,
54
+ pathValue: filePath,
55
+ agentName: tcc.agentName,
56
+ matchedPattern: check.matchedPattern,
57
+ });
58
+
50
59
  const descriptor: GateDescriptor = {
51
60
  surface: "path",
52
61
  input: { path: filePath },
@@ -60,11 +69,8 @@ export function describePathGate(
60
69
  promptDetails: {
61
70
  source: "tool_call",
62
71
  agentName: tcc.agentName,
63
- message: formatPathAskPrompt(
64
- tcc.toolName,
65
- filePath,
66
- tcc.agentName ?? undefined,
67
- ),
72
+ message: renderLegacyMessage(payload),
73
+ payload,
68
74
  toolCallId: tcc.toolCallId,
69
75
  toolName: tcc.toolName,
70
76
  path: filePath,
@@ -86,12 +92,3 @@ export function describePathGate(
86
92
 
87
93
  return descriptor;
88
94
  }
89
-
90
- export function formatPathAskPrompt(
91
- toolName: string,
92
- pathValue: string,
93
- agentName?: string,
94
- ): string {
95
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
96
- return `${subject} requested tool '${toolName}' for path '${pathValue}'. Allow this path access?`;
97
- }