@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
@@ -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). */
@@ -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,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,
@@ -1,14 +1,15 @@
1
1
  import type { AskEscalator } from "#src/authority/authorizer-selection";
2
2
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
3
  import type { DecisionReporter } from "#src/decision-reporter";
4
- import {
5
- formatDenyReason,
6
- formatUnavailableReason,
7
- formatUserDeniedReason,
8
- } from "#src/denial-messages";
9
4
  import { applyPermissionGate } from "#src/permission-gate";
10
5
  import { createPermissionRequestId } from "#src/permission-request-id";
11
6
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
7
+ import {
8
+ renderPolicyDenial,
9
+ renderUnavailableDenial,
10
+ renderUserDenial,
11
+ } from "#src/presentation/agent-renderer";
12
+ import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
12
13
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
13
14
  import type { PermissionCheckResult } from "#src/types";
14
15
  import type {
@@ -111,8 +112,16 @@ export class GateRunner {
111
112
  }
112
113
 
113
114
  // The fields every review-log write for this gate shares, whatever the
114
- // resolution — built once so a field added here reaches all of them.
115
- const logContext = { ...descriptor.logContext, agentName, requestId };
115
+ // resolution — built once so a field added here reaches all of them. The
116
+ // payload's request facts are stamped here rather than by each gate, for
117
+ // the same reason `requestId` is: a gate cannot forget what it never
118
+ // supplies (ADR 0011 §6).
119
+ const logContext = {
120
+ ...descriptor.logContext,
121
+ ...renderReviewLogFacts(descriptor.payload),
122
+ agentName,
123
+ requestId,
124
+ };
116
125
 
117
126
  // 2. Session-hit fast path
118
127
  if (check.source === "session") {
@@ -160,16 +169,16 @@ export class GateRunner {
160
169
  // 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
161
170
  // Authorizer answers (the DenyingAuthorizer by denying with a marker).
162
171
 
163
- // Construct messages from the centralized formatter.
172
+ // The agent-facing renders of this ask. The rule reason is the operator's
173
+ // deny-with-reason text, which lives on the resolved check rather than the
174
+ // payload: no human render wants it, because a deny never prompts.
175
+ const { payload } = descriptor;
164
176
  const messages = {
165
- denyReason: formatDenyReason(descriptor.denialContext),
177
+ denyReason: renderPolicyDenial(payload, check.reason ?? null),
166
178
  unavailableReason: (decision: PermissionPromptDecision) =>
167
- formatUnavailableReason(
168
- descriptor.denialContext,
169
- decision.denialReason,
170
- ),
179
+ renderUnavailableDenial(payload, decision.denialReason ?? null),
171
180
  userDeniedReason: (decision: PermissionPromptDecision) =>
172
- formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
181
+ renderUserDenial(payload, decision.denialReason ?? null),
173
182
  };
174
183
 
175
184
  let autoApproved = false;
@@ -180,6 +189,7 @@ export class GateRunner {
180
189
  promptForApproval: async () => {
181
190
  const decision = await this.prompter.escalate({
182
191
  requestId,
192
+ payload,
183
193
  ...descriptor.promptDetails,
184
194
  ...(descriptor.sessionApproval
185
195
  ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
@@ -80,8 +80,8 @@ export class SkillInputGatePipeline {
80
80
  * Format the deny warning shown in the UI when a skill is blocked.
81
81
  *
82
82
  * Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
83
- * UI notify distinct from the gate deny reasons the runner routes through
84
- * `formatDenyReason`.
83
+ * UI notify distinct from the agent-facing deny reasons the runner routes
84
+ * through `renderPolicyDenial`.
85
85
  */
86
86
  export function formatSkillDenyNotice(
87
87
  skillName: string,
@@ -1,4 +1,3 @@
1
- import { renderLegacyMessage } from "#src/presentation/legacy-message";
2
1
  import { buildSkillAskPayload } from "#src/presentation/skill-ask-payload";
3
2
  import type { PermissionCheckResult } from "#src/types";
4
3
  import type { GateDescriptor } from "./descriptor";
@@ -17,21 +16,14 @@ export function describeSkillInputGate(
17
16
  preCheck: PermissionCheckResult,
18
17
  ): GateDescriptor {
19
18
  const payload = buildSkillAskPayload(skillName, agentName);
20
- const message = renderLegacyMessage(payload);
21
19
  return {
22
20
  surface: "skill",
23
21
  input: { name: skillName },
24
22
  preCheck,
25
- denialContext: {
26
- kind: "skill_input",
27
- skillName,
28
- agentName: agentName ?? undefined,
29
- },
23
+ payload,
30
24
  promptDetails: {
31
25
  source: "skill_input",
32
26
  agentName,
33
- message,
34
- payload,
35
27
  skillName,
36
28
  accessIntent: accessFactsFromValue("skill", skillName),
37
29
  },
@@ -39,7 +31,6 @@ export function describeSkillInputGate(
39
31
  source: "skill_input",
40
32
  skillName,
41
33
  agentName,
42
- message,
43
34
  },
44
35
  decision: {
45
36
  surface: "skill",
@@ -1,5 +1,4 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { renderLegacyMessage } from "#src/presentation/legacy-message";
3
2
  import { buildSkillPathAskPayload } from "#src/presentation/skill-ask-payload";
4
3
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
5
4
  import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
@@ -44,22 +43,14 @@ export function describeSkillReadGate(
44
43
  }
45
44
 
46
45
  const payload = buildSkillPathAskPayload(matchedSkill, path, tcc.agentName);
47
- const skillReadMessage = renderLegacyMessage(payload);
48
46
 
49
47
  return {
50
48
  surface: "skill",
51
49
  input: { name: matchedSkill.name },
52
- denialContext: {
53
- kind: "skill_read",
54
- skillName: matchedSkill.name,
55
- readPath: path,
56
- agentName: tcc.agentName ?? undefined,
57
- },
50
+ payload,
58
51
  promptDetails: {
59
52
  source: "skill_read",
60
53
  agentName: tcc.agentName,
61
- message: skillReadMessage,
62
- payload,
63
54
  toolCallId: tcc.toolCallId,
64
55
  toolName: tcc.toolName,
65
56
  skillName: matchedSkill.name,
@@ -72,7 +63,6 @@ export function describeSkillReadGate(
72
63
  skillName: matchedSkill.name,
73
64
  agentName: tcc.agentName,
74
65
  path,
75
- message: skillReadMessage,
76
66
  },
77
67
  decision: {
78
68
  surface: "skill",
@@ -6,7 +6,6 @@ import {
6
6
  type ShellInvocation,
7
7
  } from "#src/access-intent/tool-kind";
8
8
  import { suggestSessionPattern } from "#src/pattern-suggest";
9
- import { renderLegacyMessage } from "#src/presentation/legacy-message";
10
9
  import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
11
10
  import { SessionApproval } from "#src/session-approval";
12
11
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
@@ -81,7 +80,6 @@ export function describeToolGate(
81
80
  input: tcc.input,
82
81
  formatter,
83
82
  });
84
- const askMessage = renderLegacyMessage(payload);
85
83
 
86
84
  const decisionValue = deriveDecisionValue(
87
85
  gateSurface,
@@ -98,12 +96,7 @@ export function describeToolGate(
98
96
  return {
99
97
  surface: gateSurface,
100
98
  input: tcc.input,
101
- denialContext: {
102
- kind: "tool",
103
- check,
104
- agentName: tcc.agentName ?? undefined,
105
- input: tcc.input,
106
- },
99
+ payload,
107
100
  sessionApproval: SessionApproval.single(
108
101
  suggestion.surface,
109
102
  suggestion.pattern,
@@ -111,8 +104,6 @@ export function describeToolGate(
111
104
  promptDetails: {
112
105
  source: "tool_call",
113
106
  agentName: tcc.agentName,
114
- message: askMessage,
115
- payload,
116
107
  toolCallId: tcc.toolCallId,
117
108
  toolName: tcc.toolName,
118
109
  sessionLabel: suggestion.label,
@@ -123,7 +114,6 @@ export function describeToolGate(
123
114
  source: "tool_call",
124
115
  toolCallId: tcc.toolCallId,
125
116
  toolName: tcc.toolName,
126
- message: askMessage,
127
117
  ...permissionLogContext,
128
118
  },
129
119
  decision: {