@gotgenes/pi-permission-system 25.4.0 → 26.1.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 (59) hide show
  1. package/CHANGELOG.md +49 -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/subagent-integration.md +14 -0
  10. package/docs/troubleshooting.md +2 -1
  11. package/package.json +1 -1
  12. package/schemas/permissions.schema.json +14 -4
  13. package/src/access-intent/tool-kind.ts +1 -1
  14. package/src/authority/approval-escalator.ts +38 -5
  15. package/src/authority/authorizer-chain.ts +39 -11
  16. package/src/authority/authorizer-selection.ts +5 -5
  17. package/src/authority/authorizer.ts +16 -3
  18. package/src/authority/decision-source.ts +235 -0
  19. package/src/authority/denying-authorizer.ts +4 -0
  20. package/src/authority/forwarded-request-server.ts +44 -14
  21. package/src/authority/forwarding-io.ts +12 -5
  22. package/src/authority/permission-dialog.ts +23 -2
  23. package/src/authority/permission-forwarding.ts +25 -3
  24. package/src/authority/permission-prompt-component.ts +34 -10
  25. package/src/authority/permission-prompt-decision.ts +7 -3
  26. package/src/authority/permission-prompter.ts +13 -4
  27. package/src/config-loader.ts +31 -0
  28. package/src/config-schema.ts +13 -4
  29. package/src/extension-config.ts +8 -9
  30. package/src/handlers/gates/bash-external-directory.ts +10 -12
  31. package/src/handlers/gates/bash-path.ts +9 -10
  32. package/src/handlers/gates/descriptor.ts +25 -5
  33. package/src/handlers/gates/external-directory.ts +3 -13
  34. package/src/handlers/gates/path.ts +1 -9
  35. package/src/handlers/gates/runner.ts +44 -14
  36. package/src/handlers/gates/skill-input-gate-pipeline.ts +2 -2
  37. package/src/handlers/gates/skill-input.ts +1 -10
  38. package/src/handlers/gates/skill-read.ts +1 -11
  39. package/src/handlers/gates/tool.ts +1 -11
  40. package/src/handlers/tool-call-boundary.ts +4 -1
  41. package/src/log-field-cap.ts +82 -0
  42. package/src/logging.ts +24 -3
  43. package/src/permission-events.ts +15 -2
  44. package/src/permission-gate.ts +11 -0
  45. package/src/permission-prompts.ts +4 -3
  46. package/src/permission-session.ts +1 -1
  47. package/src/permission-ui-prompt.ts +4 -2
  48. package/src/presentation/agent-renderer.ts +215 -0
  49. package/src/presentation/dialog-renderer.ts +8 -64
  50. package/src/presentation/fact-vocabulary.ts +103 -0
  51. package/src/presentation/forwarded-ask-payload.ts +42 -17
  52. package/src/presentation/path-ask-payload.ts +8 -1
  53. package/src/presentation/prompt-payload.ts +165 -4
  54. package/src/presentation/review-log-renderer.ts +51 -0
  55. package/src/service.ts +11 -0
  56. package/src/tool-input-preview.ts +0 -1
  57. package/src/tool-preview-formatter.ts +18 -33
  58. package/src/denial-messages.ts +0 -269
  59. package/src/presentation/legacy-message.ts +0 -117
@@ -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";
@@ -46,6 +45,8 @@ export function describeExternalDirectoryGate(
46
45
  if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
47
46
  return {
48
47
  action: "allow",
48
+ // Containment allowed this, not a rule the operator wrote.
49
+ decidedBy: { kind: "infrastructure_read" },
49
50
  log: {
50
51
  event: "permission_request.infrastructure_auto_allowed",
51
52
  details: {
@@ -87,26 +88,16 @@ export function describeExternalDirectoryGate(
87
88
  agentName: tcc.agentName,
88
89
  matchedPattern: preCheck.matchedPattern,
89
90
  });
90
- const extDirMessage = renderLegacyMessage(payload);
91
91
 
92
92
  return {
93
93
  surface: "external_directory",
94
94
  input: {},
95
95
  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
- },
96
+ payload,
104
97
  sessionApproval: SessionApproval.single("external_directory", pattern),
105
98
  promptDetails: {
106
99
  source: "tool_call",
107
100
  agentName: tcc.agentName,
108
- message: extDirMessage,
109
- payload,
110
101
  toolCallId: tcc.toolCallId,
111
102
  toolName: tcc.toolName,
112
103
  path: externalDirectoryPath,
@@ -118,7 +109,6 @@ export function describeExternalDirectoryGate(
118
109
  toolName: tcc.toolName,
119
110
  agentName: tcc.agentName,
120
111
  path: externalDirectoryPath,
121
- message: extDirMessage,
122
112
  },
123
113
  decision: {
124
114
  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 {
@@ -65,6 +66,7 @@ export class GateRunner {
65
66
  this.reporter.writeReviewLog(gate.log.event, {
66
67
  ...gate.log.details,
67
68
  requestId,
69
+ decidedBy: gate.decidedBy,
68
70
  });
69
71
  }
70
72
  if (gate.decision) {
@@ -111,8 +113,21 @@ export class GateRunner {
111
113
  }
112
114
 
113
115
  // 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 };
116
+ // resolution — built once so a field added here reaches all of them. The
117
+ // payload's request facts are stamped here rather than by each gate, for
118
+ // the same reason `requestId` is: a gate cannot forget what it never
119
+ // supplies (ADR 0011 §6).
120
+ const logContext = {
121
+ ...descriptor.logContext,
122
+ ...renderReviewLogFacts(descriptor.payload),
123
+ agentName,
124
+ requestId,
125
+ };
126
+
127
+ // Each resolution below states its own decider. The provenance is built
128
+ // at the branch that decides rather than merged into `logContext`: that
129
+ // context holds what every resolution of this gate shares, and who decided
130
+ // is by definition not shared (#726).
116
131
 
117
132
  // 2. Session-hit fast path
118
133
  if (check.source === "session") {
@@ -120,6 +135,11 @@ export class GateRunner {
120
135
  ...logContext,
121
136
  resolution: "session_approved",
122
137
  sessionApprovalPattern: check.matchedPattern,
138
+ decidedBy: {
139
+ kind: "session_approval",
140
+ surface: descriptor.surface,
141
+ pattern: check.matchedPattern ?? null,
142
+ },
123
143
  });
124
144
  this.emitDecision(
125
145
  requestId,
@@ -143,6 +163,9 @@ export class GateRunner {
143
163
  this.reporter.writeReviewLog("permission_request.auto_approved", {
144
164
  ...logContext,
145
165
  resolution: "auto_approved",
166
+ // The pattern that raised the ask, sentinel included: "yolo allowed
167
+ // it" alone does not say why it was asked in the first place.
168
+ decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
146
169
  });
147
170
  this.emitDecision(
148
171
  requestId,
@@ -160,16 +183,16 @@ export class GateRunner {
160
183
  // 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
161
184
  // Authorizer answers (the DenyingAuthorizer by denying with a marker).
162
185
 
163
- // Construct messages from the centralized formatter.
186
+ // The agent-facing renders of this ask. The rule reason is the operator's
187
+ // deny-with-reason text, which lives on the resolved check rather than the
188
+ // payload: no human render wants it, because a deny never prompts.
189
+ const { payload } = descriptor;
164
190
  const messages = {
165
- denyReason: formatDenyReason(descriptor.denialContext),
191
+ denyReason: renderPolicyDenial(payload, check.reason ?? null),
166
192
  unavailableReason: (decision: PermissionPromptDecision) =>
167
- formatUnavailableReason(
168
- descriptor.denialContext,
169
- decision.denialReason,
170
- ),
193
+ renderUnavailableDenial(payload, decision.denialReason ?? null),
171
194
  userDeniedReason: (decision: PermissionPromptDecision) =>
172
- formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
195
+ renderUserDenial(payload, decision.denialReason ?? null),
173
196
  };
174
197
 
175
198
  let autoApproved = false;
@@ -180,6 +203,7 @@ export class GateRunner {
180
203
  promptForApproval: async () => {
181
204
  const decision = await this.prompter.escalate({
182
205
  requestId,
206
+ payload,
183
207
  ...descriptor.promptDetails,
184
208
  ...(descriptor.sessionApproval
185
209
  ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
@@ -192,6 +216,12 @@ export class GateRunner {
192
216
  writeLog: (event, details) =>
193
217
  this.reporter.writeReviewLog(event, details),
194
218
  logContext,
219
+ decidedByRule: {
220
+ kind: "rule",
221
+ surface: descriptor.surface,
222
+ pattern: check.matchedPattern ?? null,
223
+ origin: check.origin,
224
+ },
195
225
  messages,
196
226
  });
197
227
 
@@ -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: {
@@ -76,12 +76,15 @@ function recordGateError(
76
76
  ): void {
77
77
  try {
78
78
  audit.recordError();
79
+ const reason = errorMessage(error);
79
80
  reporter.writeReviewLog("permission_request.blocked", {
80
81
  requestId: createPermissionRequestId(),
81
82
  toolName: bestEffortToolName(event),
82
83
  command: bestEffortCommand(event),
83
84
  resolution: "gate_error",
84
- error: errorMessage(error),
85
+ error: reason,
86
+ // The boundary decided, by failing closed -- no rule and no human did.
87
+ decidedBy: { kind: "gate_error", reason },
85
88
  });
86
89
  } catch {
87
90
  // The block is the guarantee; its bookkeeping is not.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The permission review log's width bound (ADR 0011 §6).
3
+ *
4
+ * The log renders the prompt payload under its own configured limits, and this
5
+ * is the limit: every string it writes is narrowed to a configured width. The
6
+ * bound is applied at `writeLine`, the single place a log line is produced, so
7
+ * a write path cannot be added that escapes it — the same discipline redaction
8
+ * already has there.
9
+ *
10
+ * A cap is not redaction, and the two must not be conflated
11
+ * (`docs/decisions/0010-permission-log-secret-exposure.md`). This narrows by
12
+ * length alone and never reads a value to decide what to shorten; redaction
13
+ * masks a value because of the key name it is bound to, and still does, so a
14
+ * sensitive-keyed value is masked whole however long it was.
15
+ */
16
+
17
+ /**
18
+ * The width when the operator configures none.
19
+ *
20
+ * Not a new number: it is the bound that already governed `toolInputPreview`,
21
+ * promoted from one field to every field so the log has one limit rather than
22
+ * one limit and an unbounded remainder.
23
+ */
24
+ export const DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH = 1000;
25
+
26
+ /** The two-field shape this module reads off the extension config. */
27
+ export interface ReviewLogWidthConfig {
28
+ readonly reviewLogFieldMaxWidth?: number;
29
+ }
30
+
31
+ /** The configured review-log field width, or the built-in default. */
32
+ export function resolveReviewLogFieldWidth(
33
+ config: ReviewLogWidthConfig,
34
+ ): number {
35
+ return config.reviewLogFieldMaxWidth ?? DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH;
36
+ }
37
+
38
+ /**
39
+ * Narrow every string in a log-detail record to `maxWidth`.
40
+ *
41
+ * Recurses through plain objects and arrays so a nested detail is bounded too,
42
+ * and touches strings only — a number, a boolean, or a null passes through as
43
+ * it was. A shortened value is marked with a bare ellipsis, the same marker the
44
+ * dialog uses: a character count is a number the reader cannot act on
45
+ * (ADR 0011 §4).
46
+ */
47
+ export function capLogFieldWidths<T>(details: T, maxWidth: number): T {
48
+ return capValue(details, maxWidth) as T;
49
+ }
50
+
51
+ function capValue(value: unknown, maxWidth: number): unknown {
52
+ if (typeof value === "string") {
53
+ return value.length <= maxWidth
54
+ ? value
55
+ : `${value.slice(0, maxWidth)}\u2026`;
56
+ }
57
+ if (Array.isArray(value)) {
58
+ return value.map((entry) => capValue(entry, maxWidth));
59
+ }
60
+ if (isPlainObject(value)) {
61
+ return Object.fromEntries(
62
+ Object.entries(value).map(([key, entry]) => [
63
+ key,
64
+ capValue(entry, maxWidth),
65
+ ]),
66
+ );
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * Whether a value is a record this cap should descend into.
73
+ *
74
+ * A class instance (a `Date`, an `Error`) is left alone: rebuilding it as a
75
+ * plain object would change what the writer serializes, and the cap's job is
76
+ * to shorten strings, not to reshape a value.
77
+ */
78
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
79
+ if (typeof value !== "object" || value === null) return false;
80
+ const prototype: unknown = Object.getPrototypeOf(value);
81
+ return prototype === Object.prototype || prototype === null;
82
+ }
package/src/logging.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  EXTENSION_ID,
5
5
  type PermissionSystemExtensionConfig,
6
6
  } from "./extension-config";
7
+ import { capLogFieldWidths, resolveReviewLogFieldWidth } from "./log-field-cap";
7
8
  import {
8
9
  OWNER_ONLY_FILE_MODE,
9
10
  restrictExistingPathToOwner,
@@ -37,11 +38,20 @@ export function createPermissionSystemLogger(
37
38
  // re-invoked per session, unlike module scope, which now outlives one.
38
39
  const hardened = new Set<string>();
39
40
 
41
+ /**
42
+ * The only place a log line is produced.
43
+ *
44
+ * `maxFieldWidth` bounds every string the line carries; it is supplied for
45
+ * the review stream and withheld for the debug stream, which is opt-in and
46
+ * exists to be read in full. Capping happens before redaction, which masks
47
+ * by key name and so still masks a sensitive value whole.
48
+ */
40
49
  const writeLine = (
41
50
  stream: "debug" | "review",
42
51
  path: string,
43
52
  event: string,
44
53
  details: Record<string, unknown>,
54
+ maxFieldWidth?: number,
45
55
  ): string | undefined => {
46
56
  const directoryError = ensureLogsDirectory();
47
57
  if (directoryError) {
@@ -49,12 +59,16 @@ export function createPermissionSystemLogger(
49
59
  }
50
60
 
51
61
  try {
62
+ const bounded =
63
+ maxFieldWidth === undefined
64
+ ? details
65
+ : capLogFieldWidths(details, maxFieldWidth);
52
66
  const line = redactedJsonStringify({
53
67
  timestamp: new Date().toISOString(),
54
68
  extension: EXTENSION_ID,
55
69
  stream,
56
70
  event,
57
- ...details,
71
+ ...bounded,
58
72
  });
59
73
  if (!line) {
60
74
  return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
@@ -89,11 +103,18 @@ export function createPermissionSystemLogger(
89
103
  event: string,
90
104
  details: Record<string, unknown> = {},
91
105
  ): string | undefined => {
92
- if (!options.getConfig().permissionReviewLog) {
106
+ const config = options.getConfig();
107
+ if (!config.permissionReviewLog) {
93
108
  return undefined;
94
109
  }
95
110
 
96
- return writeLine("review", reviewLogPath, event, details);
111
+ return writeLine(
112
+ "review",
113
+ reviewLogPath,
114
+ event,
115
+ details,
116
+ resolveReviewLogFieldWidth(config),
117
+ );
97
118
  };
98
119
 
99
120
  return { debug, review };
@@ -8,6 +8,8 @@
8
8
  * removed or renamed without a semver-major version bump.
9
9
  */
10
10
 
11
+ import type { PromptRequestFacts } from "#src/presentation/prompt-payload";
12
+
11
13
  /** Minimal event bus interface required by the emit helpers. */
12
14
  export interface PermissionEventBus {
13
15
  emit(channel: string, data: unknown): void;
@@ -79,8 +81,19 @@ export interface PermissionUiPromptEvent {
79
81
  value: string | null;
80
82
  /** Agent name (when known). */
81
83
  agentName: string | null;
82
- /** Message displayed to the user. */
83
- message: string;
84
+ /**
85
+ * The ask's invariant core (ADR 0011 §3), verbatim from the prompt payload.
86
+ *
87
+ * Nested rather than flattened so the event and the payload share one shape:
88
+ * a fact added to `PromptRequestFacts` reaches the bus without a second
89
+ * hand-maintained declaration. Carries no evidence and no annotations — the
90
+ * bus is the narrowest renderer (ADR 0011 §6), observable by any loaded
91
+ * extension without the operator having named it.
92
+ *
93
+ * `request.surface` is the *gate* surface the rule fired on; the top-level
94
+ * `surface` is the display projection. Both are here on purpose.
95
+ */
96
+ request: PromptRequestFacts;
84
97
  /** Forwarding context, or null for a direct prompt. */
85
98
  forwarding: ForwardedPromptContext | null;
86
99
  }
@@ -1,3 +1,4 @@
1
+ import type { DecisionSource } from "#src/authority/decision-source";
1
2
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
3
 
3
4
  /** Result of applying the permission gate. */
@@ -30,6 +31,15 @@ export interface PermissionGateParams {
30
31
  /** Log context fields shared across all log calls for this gate. */
31
32
  logContext: Record<string, unknown>;
32
33
 
34
+ /**
35
+ * The rule that resolved this gate, for the deny arm's review entry.
36
+ *
37
+ * A sibling of `logContext` rather than a member of it: the context holds
38
+ * what every resolution of this gate shares, and the decider is by
39
+ * definition not shared (#726).
40
+ */
41
+ decidedByRule: DecisionSource;
42
+
33
43
  /** Message strings/factories for each outcome. */
34
44
  messages: {
35
45
  denyReason: string;
@@ -52,6 +62,7 @@ export async function applyPermissionGate(
52
62
  writeLog("permission_request.blocked", {
53
63
  ...logContext,
54
64
  resolution: "policy_denied",
65
+ decidedBy: params.decidedByRule,
55
66
  });
56
67
  return { action: "block", reason: messages.denyReason };
57
68
  }
@@ -1,8 +1,9 @@
1
1
  import { classifyToolKind } from "./access-intent/tool-kind";
2
2
 
3
- // NOTE: the ask prompts are now payload builders under src/presentation/;
4
- // denial text lives in denial-messages.ts. This module retains only the
5
- // pre-check reasons, which are agent-facing rather than user-facing.
3
+ // NOTE: the ask prompts are now payload builders under src/presentation/, and
4
+ // denial text is a render over the payload (presentation/agent-renderer.ts).
5
+ // This module retains only the pre-check reasons, refused before any payload
6
+ // exists to render.
6
7
 
7
8
  export function formatMissingToolNameReason(): string {
8
9
  return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
@@ -219,7 +219,7 @@ export class PermissionSession implements ToolCallGateInputs {
219
219
  * so the pipeline reads a clean value rather than pulling raw config.
220
220
  */
221
221
  getToolPreviewLimits(): ToolPreviewFormatterOptions {
222
- return resolveToolPreviewLimits(this.config);
222
+ return resolveToolPreviewLimits();
223
223
  }
224
224
 
225
225
  /**
@@ -11,6 +11,7 @@
11
11
  * prompter or forwarding modules (no import cycles, correct layering).
12
12
  */
13
13
 
14
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
14
15
  import type {
15
16
  ForwardedPromptContext,
16
17
  PermissionUiPromptEvent,
@@ -21,7 +22,8 @@ export interface DirectPromptInput {
21
22
  requestId: string;
22
23
  source: "tool_call" | "skill_input" | "skill_read";
23
24
  agentName: string | null;
24
- message: string;
25
+ /** The ask's complete payload; the event carries its invariant core alone. */
26
+ payload: PromptPayload;
25
27
  toolName?: string;
26
28
  skillName?: string;
27
29
  path?: string;
@@ -62,7 +64,7 @@ export function buildUiPrompt(input: UiPromptInput): PermissionUiPromptEvent {
62
64
  surface: input.surface !== undefined ? input.surface : directSurface(input),
63
65
  value: input.value !== undefined ? input.value : directValue(input),
64
66
  agentName: input.agentName,
65
- message: input.message,
67
+ request: input.payload.request,
66
68
  forwarding: input.forwarding ?? null,
67
69
  };
68
70
  }