@gotgenes/pi-permission-system 25.3.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 (53) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +14 -12
  3. package/config/config.example.json +1 -2
  4. package/dist/public.d.ts +37 -10
  5. package/docs/configuration.md +23 -19
  6. package/docs/cross-extension-api.md +43 -12
  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 +32 -5
  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 +26 -6
  25. package/src/handlers/gates/external-directory.ts +1 -13
  26. package/src/handlers/gates/helpers.ts +6 -7
  27. package/src/handlers/gates/path.ts +1 -9
  28. package/src/handlers/gates/runner.ts +62 -31
  29. package/src/handlers/gates/skill-input-gate-pipeline.ts +3 -14
  30. package/src/handlers/gates/skill-input.ts +1 -10
  31. package/src/handlers/gates/skill-read.ts +2 -11
  32. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
  33. package/src/handlers/gates/tool.ts +1 -11
  34. package/src/handlers/tool-call-boundary.ts +30 -7
  35. package/src/log-field-cap.ts +82 -0
  36. package/src/logging.ts +24 -3
  37. package/src/permission-events.ts +21 -2
  38. package/src/permission-prompts.ts +4 -3
  39. package/src/permission-request-id.ts +17 -0
  40. package/src/permission-session.ts +1 -1
  41. package/src/permission-ui-prompt.ts +4 -2
  42. package/src/presentation/agent-renderer.ts +215 -0
  43. package/src/presentation/dialog-renderer.ts +8 -64
  44. package/src/presentation/fact-vocabulary.ts +103 -0
  45. package/src/presentation/forwarded-ask-payload.ts +42 -17
  46. package/src/presentation/path-ask-payload.ts +8 -1
  47. package/src/presentation/prompt-payload.ts +165 -4
  48. package/src/presentation/review-log-renderer.ts +51 -0
  49. package/src/service.ts +11 -0
  50. package/src/tool-input-preview.ts +0 -1
  51. package/src/tool-preview-formatter.ts +18 -33
  52. package/src/denial-messages.ts +0 -269
  53. package/src/presentation/legacy-message.ts +0 -117
@@ -34,7 +34,9 @@ import {
34
34
  } from "#src/authority/permission-forwarding";
35
35
  import type { ServingLookup } from "#src/authority/serving-registry";
36
36
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
37
+ import { createPermissionRequestId } from "#src/permission-request-id";
37
38
  import { buildUiPrompt } from "#src/permission-ui-prompt";
39
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
38
40
  import type { DebugReviewLogger } from "#src/session-logger";
39
41
  import { toRecord } from "#src/value-guards";
40
42
  import type { TerminalAuthorizer } from "./authorizer";
@@ -67,7 +69,7 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
67
69
 
68
70
  /**
69
71
  * The facts a forwarded request relays unchanged from the child's ask: the
70
- * prompt message, the optional display projection, and the optional
72
+ * prompt payload, the optional display projection, and the optional
71
73
  * session-approval suggestion.
72
74
  *
73
75
  * Bundled into one object so the two-hop private chain
@@ -75,7 +77,14 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
75
77
  * relayed value instead of three positional optionals.
76
78
  */
77
79
  interface ForwardedRequestFacts {
78
- message: string;
80
+ /**
81
+ * The requester's own permission request id, adopted as the forwarded
82
+ * request's id so one id runs from the child's gate to the serving node's
83
+ * decision instead of a third being minted here.
84
+ */
85
+ requestId: string;
86
+ /** The child's complete prompt payload, relayed for the serving node to render. */
87
+ payload: PromptPayload;
79
88
  display?: ForwardedPromptDisplay;
80
89
  sessionApproval?: ForwardedSessionApproval;
81
90
  /** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
@@ -111,6 +120,23 @@ function abandon(denialReason: string): PermissionPromptDecision {
111
120
  };
112
121
  }
113
122
 
123
+ /** Ids this node is willing to use as a request/response filename. */
124
+ const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
125
+
126
+ /**
127
+ * The id to write on the forwarded request: the requester's own, or a fresh
128
+ * mint when that id could not safely name a file.
129
+ *
130
+ * At a relay hop the adopted id came from a request file on disk, which the
131
+ * tolerant reader validates only as a string — so this is the boundary that
132
+ * keeps an inbound id from choosing an outbound path.
133
+ */
134
+ function forwardableRequestId(requesterRequestId: string): string {
135
+ return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
136
+ ? requesterRequestId
137
+ : createPermissionRequestId();
138
+ }
139
+
114
140
  /**
115
141
  * Authorizer for a subagent session: escalate the ask up the tree to the
116
142
  * parent's authority.
@@ -146,7 +172,8 @@ export class ParentAuthorizer implements TerminalAuthorizer {
146
172
  ): Promise<PermissionPromptDecision> {
147
173
  const uiPrompt = buildUiPrompt(details);
148
174
  return this.waitForForwardedApproval(this.ctx, {
149
- message: details.message,
175
+ requestId: details.requestId,
176
+ payload: details.payload,
150
177
  display: {
151
178
  source: uiPrompt.source,
152
179
  surface: uiPrompt.surface,
@@ -250,7 +277,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
250
277
  requesterSessionId: string,
251
278
  targetSessionId: string,
252
279
  ): ForwardedPermissionRequest {
253
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
280
+ const requestId = forwardableRequestId(facts.requestId);
254
281
  const requesterAgentName =
255
282
  getActiveAgentName(ctx) ??
256
283
  getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
@@ -275,7 +302,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
275
302
  requesterSessionId,
276
303
  targetSessionId,
277
304
  requesterAgentName,
278
- message: facts.message,
305
+ payload: facts.payload,
279
306
  ...(facts.display
280
307
  ? {
281
308
  source: facts.display.source,
@@ -49,9 +49,9 @@ export interface Authorizer {
49
49
  * ADR 0007's terminal-cannot-defer invariant.
50
50
  *
51
51
  * One method, one responsibility. `DenyingAuthorizer` ignores `details`;
52
- * `LocalUserAuthorizer` reads `message`/`sessionLabel` and derives the UI
53
- * event from it; `ParentAuthorizer` reads `message` and derives the
54
- * forwarded display from it.
52
+ * `LocalUserAuthorizer` renders `payload` for the human and derives the UI
53
+ * event from the request facts; `ParentAuthorizer` ships `payload` over the
54
+ * wire so the serving node renders it under its own budget.
55
55
  */
56
56
  export interface TerminalAuthorizer {
57
57
  authorize(
@@ -14,7 +14,6 @@ import {
14
14
  } from "#src/authority/permission-forwarding";
15
15
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
16
16
  import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
17
- import { renderLegacyMessage } from "#src/presentation/legacy-message";
18
17
  import { SessionApproval } from "#src/session-approval";
19
18
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
20
19
  import type { DebugReviewLogger } from "#src/session-logger";
@@ -100,7 +99,6 @@ function buildForwardedAskDetails(
100
99
  requestId: request.id,
101
100
  source: request.source ?? "tool_call",
102
101
  agentName: request.requesterAgentName || null,
103
- message: renderLegacyMessage(payload),
104
102
  payload,
105
103
  surface: request.surface ?? null,
106
104
  value: request.value ?? null,
@@ -23,6 +23,7 @@ import {
23
23
  OWNER_ONLY_FILE_MODE,
24
24
  } from "#src/log-file-permissions";
25
25
  import type { PermissionUiPromptSource } from "#src/permission-events";
26
+ import { asPromptPayload } from "#src/presentation/prompt-payload";
26
27
  import type { DebugReviewLogger } from "#src/session-logger";
27
28
 
28
29
  /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
@@ -396,8 +397,7 @@ export function readForwardedPermissionRequest(
396
397
  typeof parsed.createdAt !== "number" ||
397
398
  typeof parsed.requesterSessionId !== "string" ||
398
399
  typeof parsed.targetSessionId !== "string" ||
399
- typeof parsed.requesterAgentName !== "string" ||
400
- typeof parsed.message !== "string"
400
+ typeof parsed.requesterAgentName !== "string"
401
401
  ) {
402
402
  logPermissionForwardingWarning(
403
403
  logger,
@@ -412,9 +412,11 @@ export function readForwardedPermissionRequest(
412
412
  requesterSessionId: parsed.requesterSessionId,
413
413
  targetSessionId: parsed.targetSessionId,
414
414
  requesterAgentName: parsed.requesterAgentName,
415
- message: parsed.message,
416
- // Tolerant read: display fields are optional and may be absent (older
417
- // child) or malformed; reconstruct only the well-formed ones.
415
+ // Tolerant read: the payload and display fields are optional and may be
416
+ // absent (older child) or malformed; reconstruct only the well-formed
417
+ // ones. An older child's `message` is deliberately not salvaged — a
418
+ // skewed ask renders from the fields it does carry (ADR 0011 §9).
419
+ payload: asPromptPayload(parsed.payload),
418
420
  source: asUiPromptSource(parsed.source),
419
421
  surface: asNullableDisplayString(parsed.surface),
420
422
  value: asNullableDisplayString(parsed.value),
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { PermissionUiPromptSource } from "#src/permission-events";
3
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
3
4
  import type { PermissionDecisionState } from "./permission-dialog";
4
5
  import type { SubagentSessionRegistry } from "./subagent-registry";
5
6
 
@@ -53,9 +54,9 @@ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
53
54
  * Display fields relayed from a forwarding child to the parent UI so the parent
54
55
  * can emit a non-degraded `permissions:ui_prompt` event.
55
56
  *
56
- * Carried separately from the prompt message because the parent reconstructs
57
+ * Carried separately from the prompt payload because the parent reconstructs
57
58
  * the original event from the escalated ask's details (`buildUiPrompt`), not
58
- * from the message text.
59
+ * from the payload's own facts.
59
60
  */
60
61
  export interface ForwardedPromptDisplay {
61
62
  source: PermissionUiPromptSource;
@@ -123,7 +124,15 @@ export type ForwardedPermissionRequest = {
123
124
  requesterSessionId: string;
124
125
  targetSessionId: string;
125
126
  requesterAgentName: string;
126
- message: string;
127
+ /**
128
+ * The child's complete prompt payload (ADR 0011 §2), so the serving node
129
+ * renders the child's own facts under the *parent's* budget rather than
130
+ * relaying a sentence the child assembled under its own configuration.
131
+ *
132
+ * Optional for version-skew tolerance: an older child omits it, and the
133
+ * serving node renders from the display fields it does carry (ADR 0011 §9).
134
+ */
135
+ payload?: PromptPayload;
127
136
  /**
128
137
  * Original prompt display fields, persisted so the parent emits a
129
138
  * non-degraded event. Optional for version-skew tolerance: a parent on a
@@ -4,6 +4,7 @@ import type {
4
4
  ForwardedSessionApproval,
5
5
  } from "#src/authority/permission-forwarding";
6
6
  import type { PromptPayload } from "#src/presentation/prompt-payload";
7
+ import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
7
8
  import type { ReviewLogger } from "#src/session-logger";
8
9
  import type { TerminalAuthorizer } from "./authorizer";
9
10
 
@@ -27,13 +28,13 @@ export interface PromptPermissionDetails {
27
28
  requestId: string;
28
29
  source: PermissionReviewSource;
29
30
  agentName: string | null;
30
- message: string;
31
31
  /**
32
32
  * The complete structured description of this ask (ADR 0011 §2).
33
33
  *
34
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.
35
+ * than a convention each gate has to remember. Every consumer — the dialog,
36
+ * the wire, the broadcast, the review log, the agent-facing denial text — is
37
+ * a render over it, so no two of them can disagree.
37
38
  */
38
39
  payload: PromptPayload;
39
40
  toolCallId?: string;
@@ -149,7 +150,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
149
150
  requestId: details.requestId,
150
151
  source: details.source,
151
152
  agentName: details.agentName,
152
- message: details.message,
153
+ ...renderReviewLogFacts(details.payload),
153
154
  toolCallId: details.toolCallId ?? null,
154
155
  toolName: details.toolName ?? null,
155
156
  skillName: details.skillName ?? null,
@@ -224,6 +224,7 @@ export function mergeUnifiedConfigs(
224
224
  "forwardingTimeoutMs",
225
225
  "promptMaxRows",
226
226
  "promptFieldMaxWidth",
227
+ "reviewLogFieldMaxWidth",
227
228
  "toolInputPreviewMaxLength",
228
229
  "toolTextSummaryMaxLength",
229
230
  ] as const) {
@@ -374,6 +375,9 @@ export function loadAndMergeConfigs(
374
375
  const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
375
376
  if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
376
377
 
378
+ const deprecatedCapsIssue = detectDeprecatedPreviewCaps(merged);
379
+ if (deprecatedCapsIssue) allIssues.push(deprecatedCapsIssue);
380
+
377
381
  return {
378
382
  global: globalConfig,
379
383
  project: projectConfig,
@@ -414,6 +418,33 @@ export function detectPermissiveBashFallback(
414
418
  );
415
419
  }
416
420
 
421
+ /**
422
+ * Detect a config still setting one of the two superseded tool-preview caps.
423
+ *
424
+ * `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength` bounded one
425
+ * preview inside a prompt, never the prompt itself, which is why they never
426
+ * bounded it; `promptMaxRows` and `promptFieldMaxWidth` supersede them
427
+ * (ADR 0011 §5). Both stay valid in the schema so an existing config is not
428
+ * rejected fail-closed — they are simply no longer read.
429
+ *
430
+ * Pure, following `detectPermissiveBashFallback`: it takes the merged config
431
+ * and returns a message; the caller owns pushing it onto the issue list.
432
+ */
433
+ export function detectDeprecatedPreviewCaps(
434
+ config: UnifiedPermissionConfig,
435
+ ): string | undefined {
436
+ const set = (
437
+ ["toolInputPreviewMaxLength", "toolTextSummaryMaxLength"] as const
438
+ ).filter((key) => config[key] !== undefined);
439
+ if (set.length === 0) return undefined;
440
+
441
+ return (
442
+ `Permission config sets ${set.map((key) => `'${key}'`).join(" and ")}, ` +
443
+ "which is deprecated and ignored. The prompt is bounded by " +
444
+ "'promptMaxRows' and 'promptFieldMaxWidth' instead; remove the setting."
445
+ );
446
+ }
447
+
417
448
  /**
418
449
  * Load and normalize a unified config file.
419
450
  * Returns an empty config with no issues if the file does not exist.
@@ -208,17 +208,26 @@ export const unifiedConfigSchema = z
208
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
209
  default: 400,
210
210
  }),
211
+ reviewLogFieldMaxWidth: z.number().int().min(1).optional().meta({
212
+ description:
213
+ "Maximum characters of any one value written to the permission review log. Omit to use the default (1000).",
214
+ markdownDescription:
215
+ "Maximum characters of any one value written to the permission review log.\n\nOmit to use the default (1000). Every string the review log writes is narrowed to this width and marked with an ellipsis, so the log's growth is a decision you make rather than a side effect of how long a command happened to be. Raise it to keep longer values \u2014 a bash command exceeding the width is stored shortened.\n\nThis is a length bound, not redaction: it never inspects a value to decide what to hide. Key-name masking is unchanged and applies independently.",
216
+ default: 1000,
217
+ }),
211
218
  toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
219
+ deprecated: true,
212
220
  description:
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.",
221
+ "Deprecated and ignored. Superseded by promptMaxRows and promptFieldMaxWidth, which bound the whole prompt rather than one preview. Still accepted so an existing config is not rejected; remove it.",
214
222
  markdownDescription:
215
- "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
223
+ "**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one preview inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
216
224
  }),
217
225
  toolTextSummaryMaxLength: z.number().int().min(1).optional().meta({
226
+ deprecated: true,
218
227
  description:
219
- "Maximum character length of inline pattern/path summaries (e.g. grep patterns, find globs, ls paths) in permission prompts. Omit to use the default (80).",
228
+ "Deprecated and ignored. Superseded by promptMaxRows and promptFieldMaxWidth, which bound the whole prompt rather than one summary. Still accepted so an existing config is not rejected; remove it.",
220
229
  markdownDescription:
221
- "Maximum character length of inline pattern/path summaries (e.g. grep patterns, find globs, ls paths) shown in permission prompts.\n\nOmit to use the default (80). Increase this when working with long regexes or deep paths that are being cut off.",
230
+ "**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one summary inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
222
231
  }),
223
232
  piInfrastructureReadPaths: z.array(z.string().min(1)).optional().meta({
224
233
  description:
@@ -26,10 +26,8 @@ export interface PermissionSystemExtensionConfig {
26
26
  promptMaxRows?: number;
27
27
  /** Max characters of any one field shown in a permission prompt. Defaults to 400. */
28
28
  promptFieldMaxWidth?: number;
29
- /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
30
- toolInputPreviewMaxLength?: number;
31
- /** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
32
- toolTextSummaryMaxLength?: number;
29
+ /** Max characters of any one value written to the permission review log. Defaults to 1000. */
30
+ reviewLogFieldMaxWidth?: number;
33
31
  /** Non-bash tools that carry shell semantics, keyed by tool name. */
34
32
  shellTools?: ShellToolsConfig;
35
33
  /** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
@@ -86,12 +84,13 @@ export function normalizePermissionSystemConfig(
86
84
  if (raw.promptFieldMaxWidth !== undefined) {
87
85
  result.promptFieldMaxWidth = raw.promptFieldMaxWidth;
88
86
  }
89
- if (raw.toolInputPreviewMaxLength !== undefined) {
90
- result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
91
- }
92
- if (raw.toolTextSummaryMaxLength !== undefined) {
93
- result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
87
+ if (raw.reviewLogFieldMaxWidth !== undefined) {
88
+ result.reviewLogFieldMaxWidth = raw.reviewLogFieldMaxWidth;
94
89
  }
90
+ // `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` are deliberately
91
+ // absent: the schema and the merge still accept them so the deprecation
92
+ // detector can see an operator's setting, but no runtime consumer may read
93
+ // one (ADR 0011 §5, #745).
95
94
  if (raw.shellTools !== undefined) {
96
95
  result.shellTools = raw.shellTools;
97
96
  }
@@ -1,6 +1,5 @@
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
3
  import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
5
4
  import { SessionApproval } from "#src/session-approval";
6
5
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -85,26 +84,17 @@ export function describeBashExternalDirectoryGate(
85
84
  toolName: tcc.toolName,
86
85
  matchedPattern: preCheck.matchedPattern,
87
86
  });
88
- const bashExtMessage = renderLegacyMessage(payload);
89
87
 
90
88
  const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
91
89
 
92
90
  return {
93
91
  surface: "external_directory",
94
92
  input: {},
95
- denialContext: {
96
- kind: "bash_external_directory",
97
- command,
98
- externalPaths: disclosures,
99
- cwd: tcc.cwd,
100
- agentName: tcc.agentName ?? undefined,
101
- },
93
+ payload,
102
94
  sessionApproval: SessionApproval.multiple("external_directory", patterns),
103
95
  promptDetails: {
104
96
  source: "tool_call",
105
97
  agentName: tcc.agentName,
106
- message: bashExtMessage,
107
- payload,
108
98
  toolCallId: tcc.toolCallId,
109
99
  toolName: tcc.toolName,
110
100
  command,
@@ -117,7 +107,6 @@ export function describeBashExternalDirectoryGate(
117
107
  agentName: tcc.agentName,
118
108
  command,
119
109
  externalPaths: uncoveredPaths,
120
- message: bashExtMessage,
121
110
  },
122
111
  decision: {
123
112
  surface: "external_directory",
@@ -1,7 +1,6 @@
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
4
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
6
5
  import { SessionApproval } from "#src/session-approval";
7
6
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -120,23 +119,15 @@ export function describeBashPathGate(
120
119
  agentName: tcc.agentName,
121
120
  matchedPattern: worstCheck.matchedPattern,
122
121
  });
123
- const askMessage = renderLegacyMessage(payload);
124
122
 
125
123
  return {
126
124
  surface: "path",
127
125
  input: { path: worstToken },
128
- denialContext: {
129
- kind: "bash_path",
130
- command,
131
- pathValue: worstToken,
132
- agentName: tcc.agentName ?? undefined,
133
- },
126
+ payload,
134
127
  sessionApproval: SessionApproval.single("path", pattern),
135
128
  promptDetails: {
136
129
  source: "tool_call",
137
130
  agentName: tcc.agentName,
138
- message: askMessage,
139
- payload,
140
131
  toolCallId: tcc.toolCallId,
141
132
  toolName: tcc.toolName,
142
133
  command,
@@ -1,6 +1,6 @@
1
1
  import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
2
- import type { DenialContext } from "#src/denial-messages";
3
2
  import type { PermissionDecisionEvent } from "#src/permission-events";
3
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
4
4
  import type { SessionApproval } from "#src/session-approval";
5
5
  import type { PermissionCheckResult, PermissionState } from "#src/types";
6
6
 
@@ -18,16 +18,27 @@ export interface GateDescriptor {
18
18
  surface: string;
19
19
  /** Input passed to checkPermission. */
20
20
  input: unknown;
21
- /** Structured denial context — the runner formats messages from this. */
22
- denialContext: DenialContext;
21
+ /**
22
+ * The complete structured description of this ask (ADR 0011 §2).
23
+ *
24
+ * The descriptor's one presentation fact: every render over it — the dialog,
25
+ * the agent-facing denial text, the review log — reads this and nothing
26
+ * else, so a gate states its facts once.
27
+ */
28
+ payload: PromptPayload;
23
29
  /**
24
30
  * Session-approval suggestion for the "for this session" option.
25
31
  * Wraps either a single pattern or multiple patterns behind a unified
26
32
  * interface — the runner never needs to know which case applies.
27
33
  */
28
34
  sessionApproval?: SessionApproval;
29
- /** Details passed to the interactive permission prompt (requestId is added by the runner). */
30
- promptDetails: Omit<PromptPermissionDetails, "requestId">;
35
+ /**
36
+ * Details passed to the interactive permission prompt.
37
+ *
38
+ * The runner stamps both `requestId` (which it mints) and `payload` (which
39
+ * the descriptor owns), so neither is a gate's to supply twice.
40
+ */
41
+ promptDetails: Omit<PromptPermissionDetails, "requestId" | "payload">;
31
42
  /** Extra context fields written to the review log alongside gate outcomes. */
32
43
  logContext: Record<string, unknown>;
33
44
  /** Surface and value for the decision event (may differ from the check surface). */
@@ -51,6 +62,15 @@ export interface GateDescriptor {
51
62
  preCheck?: PermissionCheckResult;
52
63
  }
53
64
 
65
+ /**
66
+ * A decision event's facts, before the runner stamps the request id it minted.
67
+ *
68
+ * A gate knows what was decided but not which request it was deciding — the id
69
+ * is minted in `GateRunner.run`. Producing this type rather than the full event
70
+ * is what routes every emit through the runner's single stamping site.
71
+ */
72
+ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
73
+
54
74
  /**
55
75
  * Early allow result — gate has determined the action without needing the runner.
56
76
  *
@@ -62,7 +82,7 @@ export interface GateBypass {
62
82
  /** Optional review log entry to emit. */
63
83
  log?: { event: string; details: Record<string, unknown> };
64
84
  /** Optional decision event to emit. */
65
- decision?: PermissionDecisionEvent;
85
+ decision?: DecisionEventFacts;
66
86
  }
67
87
 
68
88
  /** Union of possible gate function return values. */
@@ -1,7 +1,6 @@
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
4
  import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
6
5
  import { SessionApproval } from "#src/session-approval";
7
6
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -87,26 +86,16 @@ export function describeExternalDirectoryGate(
87
86
  agentName: tcc.agentName,
88
87
  matchedPattern: preCheck.matchedPattern,
89
88
  });
90
- const extDirMessage = renderLegacyMessage(payload);
91
89
 
92
90
  return {
93
91
  surface: "external_directory",
94
92
  input: {},
95
93
  preCheck,
96
- denialContext: {
97
- kind: "external_directory",
98
- toolName: tcc.toolName,
99
- pathValue: externalDirectoryPath,
100
- resolvedPath: resolvedAlias,
101
- cwd: tcc.cwd,
102
- agentName: tcc.agentName ?? undefined,
103
- },
94
+ payload,
104
95
  sessionApproval: SessionApproval.single("external_directory", pattern),
105
96
  promptDetails: {
106
97
  source: "tool_call",
107
98
  agentName: tcc.agentName,
108
- message: extDirMessage,
109
- payload,
110
99
  toolCallId: tcc.toolCallId,
111
100
  toolName: tcc.toolName,
112
101
  path: externalDirectoryPath,
@@ -118,7 +107,6 @@ export function describeExternalDirectoryGate(
118
107
  toolName: tcc.toolName,
119
108
  agentName: tcc.agentName,
120
109
  path: externalDirectoryPath,
121
- message: extDirMessage,
122
110
  },
123
111
  decision: {
124
112
  surface: "external_directory",
@@ -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,7 +1,6 @@
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
4
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
6
5
  import { SessionApproval } from "#src/session-approval";
7
6
  import { deriveApprovalPattern } from "#src/session-rules";
@@ -59,18 +58,11 @@ export function describePathGate(
59
58
  const descriptor: GateDescriptor = {
60
59
  surface: "path",
61
60
  input: { path: filePath },
62
- denialContext: {
63
- kind: "path",
64
- toolName: tcc.toolName,
65
- pathValue: filePath,
66
- agentName: tcc.agentName ?? undefined,
67
- },
61
+ payload,
68
62
  sessionApproval: SessionApproval.single("path", pattern),
69
63
  promptDetails: {
70
64
  source: "tool_call",
71
65
  agentName: tcc.agentName,
72
- message: renderLegacyMessage(payload),
73
- payload,
74
66
  toolCallId: tcc.toolCallId,
75
67
  toolName: tcc.toolName,
76
68
  path: filePath,