@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
@@ -1,16 +1,22 @@
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";
5
+ import { createPermissionRequestId } from "#src/permission-request-id";
10
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";
11
13
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
12
14
  import type { PermissionCheckResult } from "#src/types";
13
- import type { GateDescriptor, GateResult } from "./descriptor";
15
+ import type {
16
+ DecisionEventFacts,
17
+ GateDescriptor,
18
+ GateResult,
19
+ } from "./descriptor";
14
20
  import { isGateBypass } from "./descriptor";
15
21
  import {
16
22
  buildDecisionEvent,
@@ -46,33 +52,44 @@ export class GateRunner {
46
52
  /**
47
53
  * Execute a gate: null → allow; bypass → log/emit side effects then allow;
48
54
  * descriptor → full check→log→emit→approve cycle.
55
+ *
56
+ * The request id is minted here, before the branch, so a request that never
57
+ * prompts is identified exactly as one that does.
49
58
  */
50
- async run(
51
- gate: GateResult,
52
- agentName: string | null,
53
- toolCallId: string,
54
- ): Promise<GateOutcome> {
59
+ async run(gate: GateResult, agentName: string | null): Promise<GateOutcome> {
55
60
  if (!gate) {
56
61
  return { action: "allow" };
57
62
  }
63
+ const requestId = createPermissionRequestId();
58
64
  if (isGateBypass(gate)) {
59
65
  if (gate.log) {
60
- this.reporter.writeReviewLog(gate.log.event, gate.log.details);
66
+ this.reporter.writeReviewLog(gate.log.event, {
67
+ ...gate.log.details,
68
+ requestId,
69
+ });
61
70
  }
62
71
  if (gate.decision) {
63
- this.reporter.emitDecision(gate.decision);
72
+ this.emitDecision(requestId, gate.decision);
64
73
  }
65
74
  return { action: "allow" };
66
75
  }
67
- return this.runDescriptor(gate, agentName, toolCallId);
76
+ return this.runDescriptor(gate, agentName, requestId);
68
77
  }
69
78
 
70
79
  // ── Private helpers ──────────────────────────────────────────────────────
71
80
 
81
+ /**
82
+ * The one place a decision event acquires its request id, so no emit path
83
+ * can be added that forgets it.
84
+ */
85
+ private emitDecision(requestId: string, facts: DecisionEventFacts): void {
86
+ this.reporter.emitDecision({ requestId, ...facts });
87
+ }
88
+
72
89
  private async runDescriptor(
73
90
  descriptor: GateDescriptor,
74
91
  agentName: string | null,
75
- toolCallId: string,
92
+ requestId: string,
76
93
  ): Promise<GateOutcome> {
77
94
  // 1. Resolve permission state — pre-check, pre-resolved, or via resolver
78
95
  let check: PermissionCheckResult;
@@ -94,15 +111,27 @@ export class GateRunner {
94
111
  });
95
112
  }
96
113
 
114
+ // The fields every review-log write for this gate shares, whatever the
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
+ };
125
+
97
126
  // 2. Session-hit fast path
98
127
  if (check.source === "session") {
99
128
  this.reporter.writeReviewLog("permission_request.session_approved", {
100
- ...descriptor.logContext,
101
- agentName,
129
+ ...logContext,
102
130
  resolution: "session_approved",
103
131
  sessionApprovalPattern: check.matchedPattern,
104
132
  });
105
- this.reporter.emitDecision(
133
+ this.emitDecision(
134
+ requestId,
106
135
  buildDecisionEvent(
107
136
  descriptor.decision,
108
137
  check,
@@ -121,11 +150,11 @@ export class GateRunner {
121
150
  const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
122
151
  if (yoloGrant) {
123
152
  this.reporter.writeReviewLog("permission_request.auto_approved", {
124
- ...descriptor.logContext,
125
- agentName,
153
+ ...logContext,
126
154
  resolution: "auto_approved",
127
155
  });
128
- this.reporter.emitDecision(
156
+ this.emitDecision(
157
+ requestId,
129
158
  buildDecisionEvent(
130
159
  descriptor.decision,
131
160
  yoloGrant,
@@ -140,16 +169,16 @@ export class GateRunner {
140
169
  // 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
141
170
  // Authorizer answers (the DenyingAuthorizer by denying with a marker).
142
171
 
143
- // 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;
144
176
  const messages = {
145
- denyReason: formatDenyReason(descriptor.denialContext),
177
+ denyReason: renderPolicyDenial(payload, check.reason ?? null),
146
178
  unavailableReason: (decision: PermissionPromptDecision) =>
147
- formatUnavailableReason(
148
- descriptor.denialContext,
149
- decision.denialReason,
150
- ),
179
+ renderUnavailableDenial(payload, decision.denialReason ?? null),
151
180
  userDeniedReason: (decision: PermissionPromptDecision) =>
152
- formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
181
+ renderUserDenial(payload, decision.denialReason ?? null),
153
182
  };
154
183
 
155
184
  let autoApproved = false;
@@ -159,7 +188,8 @@ export class GateRunner {
159
188
  sessionApproval: descriptor.sessionApproval?.toGateApproval(),
160
189
  promptForApproval: async () => {
161
190
  const decision = await this.prompter.escalate({
162
- requestId: toolCallId,
191
+ requestId,
192
+ payload,
163
193
  ...descriptor.promptDetails,
164
194
  ...(descriptor.sessionApproval
165
195
  ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
@@ -171,7 +201,7 @@ export class GateRunner {
171
201
  },
172
202
  writeLog: (event, details) =>
173
203
  this.reporter.writeReviewLog(event, details),
174
- logContext: { ...descriptor.logContext, agentName },
204
+ logContext,
175
205
  messages,
176
206
  });
177
207
 
@@ -180,7 +210,8 @@ export class GateRunner {
180
210
  gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
181
211
 
182
212
  // 5. Emit decision event
183
- this.reporter.emitDecision(
213
+ this.emitDecision(
214
+ requestId,
184
215
  buildDecisionEvent(
185
216
  descriptor.decision,
186
217
  check,
@@ -40,7 +40,7 @@ export interface GateNotifier {
40
40
 
41
41
  /**
42
42
  * Owns the skill-input gate assembly: raw permission pre-check, deny notify,
43
- * `describeSkillInputGate` descriptor, request-id mint, and `runner.run(...)`.
43
+ * `describeSkillInputGate` descriptor, and `runner.run(...)`.
44
44
  *
45
45
  * Constructed once in the composition root and injected into
46
46
  * `PermissionGateHandler`, mirroring `ToolCallGatePipeline` for the `input`
@@ -70,29 +70,18 @@ export class SkillInputGatePipeline {
70
70
  return runner.run(
71
71
  describeSkillInputGate(skillName, agentName, check),
72
72
  agentName,
73
- createSkillInputRequestId(),
74
73
  );
75
74
  }
76
75
  }
77
76
 
78
77
  // ── Helpers ───────────────────────────────────────────────────────────────────
79
78
 
80
- /**
81
- * Mint a unique id for a skill-input permission request.
82
- *
83
- * Format is `skill-input-<timestamp>-<random>-<pid>`, matching the
84
- * `createPermissionRequestId("skill-input")` pattern it replaces (#330).
85
- */
86
- export function createSkillInputRequestId(): string {
87
- return `skill-input-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
88
- }
89
-
90
79
  /**
91
80
  * Format the deny warning shown in the UI when a skill is blocked.
92
81
  *
93
82
  * Intentionally untagged (no `[pi-permission-system]` prefix) — this is a
94
- * UI notify distinct from the gate deny reasons the runner routes through
95
- * `formatDenyReason`.
83
+ * UI notify distinct from the agent-facing deny reasons the runner routes
84
+ * through `renderPolicyDenial`.
96
85
  */
97
86
  export function formatSkillDenyNotice(
98
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,
@@ -68,10 +59,10 @@ export function describeSkillReadGate(
68
59
  },
69
60
  logContext: {
70
61
  source: "skill_read",
62
+ toolCallId: tcc.toolCallId,
71
63
  skillName: matchedSkill.name,
72
64
  agentName: tcc.agentName,
73
65
  path,
74
- message: skillReadMessage,
75
66
  },
76
67
  decision: {
77
68
  surface: "skill",
@@ -137,11 +137,7 @@ export class ToolCallGatePipeline {
137
137
  ];
138
138
 
139
139
  for (const produce of gateProducers) {
140
- const outcome = await runner.run(
141
- await produce(),
142
- tcc.agentName,
143
- tcc.toolCallId,
144
- );
140
+ const outcome = await runner.run(await produce(), tcc.agentName);
145
141
  if (outcome.action === "block") {
146
142
  return outcome;
147
143
  }
@@ -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: {
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { DecisionRecorder } from "#src/decision-audit";
3
3
  import type { DecisionReporter } from "#src/decision-reporter";
4
+ import { createPermissionRequestId } from "#src/permission-request-id";
4
5
  import { toRecord } from "#src/value-guards";
5
6
  import type { GateOutcome } from "./gates/types";
6
7
 
@@ -53,18 +54,40 @@ export function createFailClosedToolCall(
53
54
  ? { block: true, reason: outcome.reason }
54
55
  : {};
55
56
  } catch (error) {
56
- audit.recordError();
57
- reporter.writeReviewLog("permission_request.blocked", {
58
- toolName: bestEffortToolName(event),
59
- command: bestEffortCommand(event),
60
- resolution: "gate_error",
61
- error: errorMessage(error),
62
- });
57
+ recordGateError(reporter, audit, event, error);
63
58
  return { block: true, reason: formatGateErrorReason(error) };
64
59
  }
65
60
  };
66
61
  }
67
62
 
63
+ /**
64
+ * Record a gate error without ever throwing.
65
+ *
66
+ * The block below this must be reached: the SDK does not catch a throwing
67
+ * handler, so an exception escaping the recording work would leave the command
68
+ * ungated. The request id is minted here rather than borrowed — the throw may
69
+ * have come from anywhere in the pipeline, so no gate's id is available.
70
+ */
71
+ function recordGateError(
72
+ reporter: DecisionReporter,
73
+ audit: DecisionRecorder,
74
+ event: unknown,
75
+ error: unknown,
76
+ ): void {
77
+ try {
78
+ audit.recordError();
79
+ reporter.writeReviewLog("permission_request.blocked", {
80
+ requestId: createPermissionRequestId(),
81
+ toolName: bestEffortToolName(event),
82
+ command: bestEffortCommand(event),
83
+ resolution: "gate_error",
84
+ error: errorMessage(error),
85
+ });
86
+ } catch {
87
+ // The block is the guarantee; its bookkeeping is not.
88
+ }
89
+ }
90
+
68
91
  // ── Defensive event readers (never throw) ──────────────────────────────────
69
92
 
70
93
  /** Best-effort tool name from a raw event; never throws. */
@@ -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
  }
@@ -101,6 +114,12 @@ export type PermissionDecisionResolution =
101
114
 
102
115
  /** Payload emitted on `permissions:decision`. */
103
116
  export interface PermissionDecisionEvent {
117
+ /**
118
+ * Identifies the permission request this decision resolves, minted when the
119
+ * request was created. Distinct from the host's tool-call id: one tool call
120
+ * runs several gates and so raises several requests.
121
+ */
122
+ requestId: string;
104
123
  /** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
105
124
  surface: string;
106
125
  /** The value that was evaluated (command, tool name, skill name, path). */
@@ -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().";
@@ -0,0 +1,17 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ /**
4
+ * Mint the identifier for one permission request, at the moment the request is
5
+ * created rather than at the moment it prompts.
6
+ *
7
+ * Distinct from the host's `toolCallId`, which keeps flowing alongside it as
8
+ * the join back to the Pi transcript: a single tool call runs several gates and
9
+ * therefore raises several permission requests, so the SDK's id cannot identify
10
+ * one of them.
11
+ *
12
+ * The `perm-` prefix keeps the id self-identifying in a review log that also
13
+ * carries SDK tool-call ids.
14
+ */
15
+ export function createPermissionRequestId(): string {
16
+ return `perm-${randomUUID()}`;
17
+ }
@@ -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
  }