@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
@@ -9,6 +9,7 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
 
12
+ import { asDecisionSource } from "#src/authority/decision-source";
12
13
  import { isPermissionDecisionState } from "#src/authority/permission-dialog";
13
14
  import {
14
15
  createPermissionForwardingLocation,
@@ -23,6 +24,7 @@ import {
23
24
  OWNER_ONLY_FILE_MODE,
24
25
  } from "#src/log-file-permissions";
25
26
  import type { PermissionUiPromptSource } from "#src/permission-events";
27
+ import { asPromptPayload } from "#src/presentation/prompt-payload";
26
28
  import type { DebugReviewLogger } from "#src/session-logger";
27
29
 
28
30
  /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
@@ -396,8 +398,7 @@ export function readForwardedPermissionRequest(
396
398
  typeof parsed.createdAt !== "number" ||
397
399
  typeof parsed.requesterSessionId !== "string" ||
398
400
  typeof parsed.targetSessionId !== "string" ||
399
- typeof parsed.requesterAgentName !== "string" ||
400
- typeof parsed.message !== "string"
401
+ typeof parsed.requesterAgentName !== "string"
401
402
  ) {
402
403
  logPermissionForwardingWarning(
403
404
  logger,
@@ -412,9 +413,11 @@ export function readForwardedPermissionRequest(
412
413
  requesterSessionId: parsed.requesterSessionId,
413
414
  targetSessionId: parsed.targetSessionId,
414
415
  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.
416
+ // Tolerant read: the payload and display fields are optional and may be
417
+ // absent (older child) or malformed; reconstruct only the well-formed
418
+ // ones. An older child's `message` is deliberately not salvaged — a
419
+ // skewed ask renders from the fields it does carry (ADR 0011 §9).
420
+ payload: asPromptPayload(parsed.payload),
418
421
  source: asUiPromptSource(parsed.source),
419
422
  surface: asNullableDisplayString(parsed.surface),
420
423
  value: asNullableDisplayString(parsed.value),
@@ -464,6 +467,10 @@ export function readForwardedPermissionResponse(
464
467
  typeof parsed.respondedAt === "number"
465
468
  ? parsed.respondedAt
466
469
  : Date.now(),
470
+ // Tolerant like the request's `accessIntent`: an unusable provenance
471
+ // record is dropped, but the decision itself still has to reach the
472
+ // requester, so it never rejects the response.
473
+ decidedBy: asDecisionSource(parsed.decidedBy),
467
474
  };
468
475
  } catch (error) {
469
476
  logPermissionForwardingWarning(
@@ -1,3 +1,5 @@
1
+ import type { DecisionSource } from "#src/authority/decision-source";
2
+
1
3
  export type PermissionDecisionState =
2
4
  | "approved"
3
5
  | "approved_for_session"
@@ -26,8 +28,27 @@ export type PermissionPromptDecision = {
26
28
  * denial — a user who was never asked denied nothing (#719).
27
29
  */
28
30
  confirmationUnavailable?: true;
31
+ /**
32
+ * What decided this request, stamped by the site that decided it.
33
+ *
34
+ * Required: every decision names its decider, and the type is what
35
+ * guarantees it rather than a convention each producer has to remember — the
36
+ * same discipline `PromptPermissionDetails.payload` carries (#726).
37
+ */
38
+ decidedBy: DecisionSource;
29
39
  };
30
40
 
41
+ /**
42
+ * A decision before its decider is known.
43
+ *
44
+ * The inner producers — the dialog's decision model, the `select`/`input`
45
+ * fallback, the verdict mapper — state the outcome; which decider to attribute
46
+ * it to is settled one layer up, at the site that chose the producer. The same
47
+ * shape `GateBypass.decision` uses for the request id: a producer emits only
48
+ * what it knows.
49
+ */
50
+ export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
51
+
31
52
  export interface PermissionDecisionUi {
32
53
  select(title: string, options: string[]): Promise<string | undefined>;
33
54
  input(title: string, placeholder?: string): Promise<string | undefined>;
@@ -51,7 +72,7 @@ export function normalizePermissionDenialReason(
51
72
 
52
73
  export function createDeniedPermissionDecision(
53
74
  denialReason?: string,
54
- ): PermissionPromptDecision {
75
+ ): UnattributedDecision {
55
76
  const normalizedReason = normalizePermissionDenialReason(denialReason);
56
77
  return normalizedReason
57
78
  ? {
@@ -96,7 +117,7 @@ export async function requestPermissionDecisionFromUi(
96
117
  title: string,
97
118
  message: string,
98
119
  options?: RequestPermissionOptions,
99
- ): Promise<PermissionPromptDecision> {
120
+ ): Promise<UnattributedDecision> {
100
121
  const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
101
122
  const decisionOptions = [
102
123
  APPROVE_OPTION,
@@ -1,5 +1,7 @@
1
1
  import { join } from "node:path";
2
+ import type { DecisionSource } from "#src/authority/decision-source";
2
3
  import type { PermissionUiPromptSource } from "#src/permission-events";
4
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
3
5
  import type { PermissionDecisionState } from "./permission-dialog";
4
6
  import type { SubagentSessionRegistry } from "./subagent-registry";
5
7
 
@@ -53,9 +55,9 @@ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
53
55
  * Display fields relayed from a forwarding child to the parent UI so the parent
54
56
  * can emit a non-degraded `permissions:ui_prompt` event.
55
57
  *
56
- * Carried separately from the prompt message because the parent reconstructs
58
+ * Carried separately from the prompt payload because the parent reconstructs
57
59
  * the original event from the escalated ask's details (`buildUiPrompt`), not
58
- * from the message text.
60
+ * from the payload's own facts.
59
61
  */
60
62
  export interface ForwardedPromptDisplay {
61
63
  source: PermissionUiPromptSource;
@@ -123,7 +125,15 @@ export type ForwardedPermissionRequest = {
123
125
  requesterSessionId: string;
124
126
  targetSessionId: string;
125
127
  requesterAgentName: string;
126
- message: string;
128
+ /**
129
+ * The child's complete prompt payload (ADR 0011 §2), so the serving node
130
+ * renders the child's own facts under the *parent's* budget rather than
131
+ * relaying a sentence the child assembled under its own configuration.
132
+ *
133
+ * Optional for version-skew tolerance: an older child omits it, and the
134
+ * serving node renders from the display fields it does carry (ADR 0011 §9).
135
+ */
136
+ payload?: PromptPayload;
127
137
  /**
128
138
  * Original prompt display fields, persisted so the parent emits a
129
139
  * non-degraded event. Optional for version-skew tolerance: a parent on a
@@ -154,6 +164,18 @@ export type ForwardedPermissionResponse = {
154
164
  denialReason?: string;
155
165
  responderSessionId: string;
156
166
  respondedAt: number;
167
+ /**
168
+ * What decided, inside the responding session (#726).
169
+ *
170
+ * `responderSessionId` names *where* the decision was made; this names
171
+ * *what* made it, which is the difference between a human at the parent's
172
+ * dialog and the parent's policy answering on their behalf.
173
+ *
174
+ * Optional for version-skew tolerance: an older responder omits it, and the
175
+ * requester records the hop with a `null` inner decision rather than
176
+ * rejecting the answer.
177
+ */
178
+ decidedBy?: DecisionSource;
157
179
  };
158
180
 
159
181
  export type PermissionForwardingLocation = {
@@ -4,10 +4,15 @@ import type {
4
4
  KeybindingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { type Component, matchesKey } from "@earendil-works/pi-tui";
7
+ import type {
8
+ DecisionSource,
9
+ UserDecisionSurface,
10
+ } from "#src/authority/decision-source";
7
11
  import {
8
12
  type PermissionPromptDecision,
9
13
  type RequestPermissionOptions,
10
14
  requestPermissionDecisionFromUi,
15
+ type UnattributedDecision,
11
16
  } from "#src/authority/permission-dialog";
12
17
  import {
13
18
  initialPromptState,
@@ -64,15 +69,23 @@ export interface PromptPreferences {
64
69
  *
65
70
  * The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
66
71
  * one place so the fallback and the inline component never both render.
72
+ *
73
+ * It is therefore also the one place that knows which surface the human
74
+ * answered on, so it is where the decision is attributed to that surface
75
+ * (#726). Having the dialog model and the fallback each name themselves would
76
+ * be two sites that must agree with this branch.
67
77
  */
68
- export function requestPermissionDecision(
78
+ export async function requestPermissionDecision(
69
79
  view: PermissionPromptView,
70
80
  title: string,
71
81
  payload: PromptPayload,
72
82
  options?: RequestPermissionOptions,
73
83
  ): Promise<PermissionPromptDecision> {
74
84
  if (view.mode === "tui") {
75
- return presentInlinePermissionPrompt(view, title, payload, options);
85
+ return attributeToHuman(
86
+ await presentInlinePermissionPrompt(view, title, payload, options),
87
+ "dialog",
88
+ );
76
89
  }
77
90
  // The fallback renders once and cannot re-render, so it neither paints nor
78
91
  // offers an expansion; it substitutes a nominal width for the terminal size
@@ -81,14 +94,25 @@ export function requestPermissionDecision(
81
94
  ...view.budget,
82
95
  width: FALLBACK_RENDER_WIDTH,
83
96
  });
84
- return requestPermissionDecisionFromUi(
85
- view.ui,
86
- title,
87
- rendered.lines.join("\n"),
88
- options,
97
+ return attributeToHuman(
98
+ await requestPermissionDecisionFromUi(
99
+ view.ui,
100
+ title,
101
+ rendered.lines.join("\n"),
102
+ options,
103
+ ),
104
+ "select",
89
105
  );
90
106
  }
91
107
 
108
+ function attributeToHuman(
109
+ decision: UnattributedDecision,
110
+ via: UserDecisionSurface,
111
+ ): PermissionPromptDecision {
112
+ const decidedBy: DecisionSource = { kind: "user", via };
113
+ return { ...decision, decidedBy };
114
+ }
115
+
92
116
  /** The width the `select`/`input` fallback renders against. */
93
117
  const FALLBACK_RENDER_WIDTH = 80;
94
118
 
@@ -113,13 +137,13 @@ export function presentInlinePermissionPrompt(
113
137
  title: string,
114
138
  payload: PromptPayload,
115
139
  options?: RequestPermissionOptions,
116
- ): Promise<PermissionPromptDecision> {
140
+ ): Promise<UnattributedDecision> {
117
141
  const config: PromptModelConfig = {
118
142
  doublePressToConfirm: view.doublePressToConfirm,
119
143
  sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
120
144
  sessionScope: options?.sessionScope,
121
145
  };
122
- return view.ui.custom<PermissionPromptDecision>(
146
+ return view.ui.custom<UnattributedDecision>(
123
147
  (tui, theme, keybindings, done) =>
124
148
  new PermissionPromptComponent(
125
149
  theme,
@@ -176,7 +200,7 @@ class PermissionPromptComponent implements Component {
176
200
  private readonly budget: RenderBudget,
177
201
  private readonly handleAppAction: (data: string) => boolean,
178
202
  private readonly requestRender: () => void,
179
- private readonly done: (decision: PermissionPromptDecision) => void,
203
+ private readonly done: (decision: UnattributedDecision) => void,
180
204
  ) {
181
205
  this.state = initialPromptState(config);
182
206
  }
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createDeniedPermissionDecision,
3
3
  normalizePermissionDenialReason,
4
- type PermissionPromptDecision,
5
4
  type RequestPermissionOptions,
5
+ type UnattributedDecision,
6
6
  } from "#src/authority/permission-dialog";
7
7
 
8
8
  /**
@@ -70,7 +70,7 @@ export type PromptEvent =
70
70
  /** Either a re-render or a terminal decision. */
71
71
  export type PromptOutcome =
72
72
  | { kind: "render"; state: PromptViewState }
73
- | { kind: "decision"; decision: PermissionPromptDecision };
73
+ | { kind: "decision"; decision: UnattributedDecision };
74
74
 
75
75
  export function initialPromptState(
76
76
  _config: PromptModelConfig,
@@ -88,7 +88,11 @@ export function initialPromptState(
88
88
 
89
89
  /**
90
90
  * Advance the dialog by one input event, returning either the next view state
91
- * to render or the committed {@link PermissionPromptDecision}.
91
+ * to render or the committed {@link UnattributedDecision}.
92
+ *
93
+ * The model states the outcome and not the decider: which human surface this
94
+ * is gets attributed by the dispatcher that chose to render this dialog, so
95
+ * the two cannot disagree about the surface.
92
96
  */
93
97
  export function reducePrompt(
94
98
  config: PromptModelConfig,
@@ -1,9 +1,11 @@
1
+ import type { DecisionSource } from "#src/authority/decision-source";
1
2
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
3
  import type {
3
4
  ForwardedAccessFacts,
4
5
  ForwardedSessionApproval,
5
6
  } from "#src/authority/permission-forwarding";
6
7
  import type { PromptPayload } from "#src/presentation/prompt-payload";
8
+ import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
7
9
  import type { ReviewLogger } from "#src/session-logger";
8
10
  import type { TerminalAuthorizer } from "./authorizer";
9
11
 
@@ -27,13 +29,13 @@ export interface PromptPermissionDetails {
27
29
  requestId: string;
28
30
  source: PermissionReviewSource;
29
31
  agentName: string | null;
30
- message: string;
31
32
  /**
32
33
  * The complete structured description of this ask (ADR 0011 §2).
33
34
  *
34
35
  * 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.
36
+ * than a convention each gate has to remember. Every consumer — the dialog,
37
+ * the wire, the broadcast, the review log, the agent-facing denial text — is
38
+ * a render over it, so no two of them can disagree.
37
39
  */
38
40
  payload: PromptPayload;
39
41
  toolCallId?: string;
@@ -130,6 +132,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
130
132
  ? "confirmation_unavailable"
131
133
  : decision.state,
132
134
  denialReason: decision.denialReason,
135
+ decidedBy: decision.decidedBy,
133
136
  },
134
137
  );
135
138
 
@@ -138,18 +141,24 @@ export class PermissionPrompter implements PermissionPrompterApi {
138
141
 
139
142
  // ── Private helpers ──────────────────────────────────────────────────────
140
143
 
144
+ /**
145
+ * The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
146
+ * a `null` there would read as "decided by nobody" rather than "not yet".
147
+ */
141
148
  private writeReviewEntry(
142
149
  event: string,
143
150
  details: PromptPermissionDetails & {
144
151
  resolution?: string;
145
152
  denialReason?: string;
153
+ decidedBy?: DecisionSource;
146
154
  },
147
155
  ): void {
148
156
  this.deps.logger.review(event, {
157
+ ...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
149
158
  requestId: details.requestId,
150
159
  source: details.source,
151
160
  agentName: details.agentName,
152
- message: details.message,
161
+ ...renderReviewLogFacts(details.payload),
153
162
  toolCallId: details.toolCallId ?? null,
154
163
  toolName: details.toolName ?? null,
155
164
  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";
@@ -48,6 +47,15 @@ export function describeBashExternalDirectoryGate(
48
47
  if (uncoveredPaths.length === 0) {
49
48
  return {
50
49
  action: "allow",
50
+ // A whole-command bypass covers every external path at once, and each
51
+ // may have matched a different session pattern -- so the surface is one
52
+ // value and the pattern is not. The entry's `externalPaths` lists what
53
+ // was covered.
54
+ decidedBy: {
55
+ kind: "session_approval",
56
+ surface: "external_directory",
57
+ pattern: null,
58
+ },
51
59
  log: {
52
60
  event: "permission_request.session_approved",
53
61
  details: {
@@ -85,26 +93,17 @@ export function describeBashExternalDirectoryGate(
85
93
  toolName: tcc.toolName,
86
94
  matchedPattern: preCheck.matchedPattern,
87
95
  });
88
- const bashExtMessage = renderLegacyMessage(payload);
89
96
 
90
97
  const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
91
98
 
92
99
  return {
93
100
  surface: "external_directory",
94
101
  input: {},
95
- denialContext: {
96
- kind: "bash_external_directory",
97
- command,
98
- externalPaths: disclosures,
99
- cwd: tcc.cwd,
100
- agentName: tcc.agentName ?? undefined,
101
- },
102
+ payload,
102
103
  sessionApproval: SessionApproval.multiple("external_directory", patterns),
103
104
  promptDetails: {
104
105
  source: "tool_call",
105
106
  agentName: tcc.agentName,
106
- message: bashExtMessage,
107
- payload,
108
107
  toolCallId: tcc.toolCallId,
109
108
  toolName: tcc.toolName,
110
109
  command,
@@ -117,7 +116,6 @@ export function describeBashExternalDirectoryGate(
117
116
  agentName: tcc.agentName,
118
117
  command,
119
118
  externalPaths: uncoveredPaths,
120
- message: bashExtMessage,
121
119
  },
122
120
  decision: {
123
121
  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";
@@ -85,6 +84,14 @@ export function describeBashPathGate(
85
84
  if (allSessionCovered) {
86
85
  return {
87
86
  action: "allow",
87
+ // Every token was covered, each possibly by a different session pattern
88
+ // -- the surface is one value and the pattern is not. The entry's
89
+ // `tokens` lists what was covered.
90
+ decidedBy: {
91
+ kind: "session_approval",
92
+ surface: "path",
93
+ pattern: null,
94
+ },
88
95
  log: {
89
96
  event: "permission_request.session_approved",
90
97
  details: {
@@ -120,23 +127,15 @@ export function describeBashPathGate(
120
127
  agentName: tcc.agentName,
121
128
  matchedPattern: worstCheck.matchedPattern,
122
129
  });
123
- const askMessage = renderLegacyMessage(payload);
124
130
 
125
131
  return {
126
132
  surface: "path",
127
133
  input: { path: worstToken },
128
- denialContext: {
129
- kind: "bash_path",
130
- command,
131
- pathValue: worstToken,
132
- agentName: tcc.agentName ?? undefined,
133
- },
134
+ payload,
134
135
  sessionApproval: SessionApproval.single("path", pattern),
135
136
  promptDetails: {
136
137
  source: "tool_call",
137
138
  agentName: tcc.agentName,
138
- message: askMessage,
139
- payload,
140
139
  toolCallId: tcc.toolCallId,
141
140
  toolName: tcc.toolName,
142
141
  command,
@@ -1,6 +1,7 @@
1
+ import type { DecisionSource } from "#src/authority/decision-source";
1
2
  import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
2
- import type { DenialContext } from "#src/denial-messages";
3
3
  import type { PermissionDecisionEvent } from "#src/permission-events";
4
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
4
5
  import type { SessionApproval } from "#src/session-approval";
5
6
  import type { PermissionCheckResult, PermissionState } from "#src/types";
6
7
 
@@ -18,16 +19,27 @@ export interface GateDescriptor {
18
19
  surface: string;
19
20
  /** Input passed to checkPermission. */
20
21
  input: unknown;
21
- /** Structured denial context — the runner formats messages from this. */
22
- denialContext: DenialContext;
22
+ /**
23
+ * The complete structured description of this ask (ADR 0011 §2).
24
+ *
25
+ * The descriptor's one presentation fact: every render over it — the dialog,
26
+ * the agent-facing denial text, the review log — reads this and nothing
27
+ * else, so a gate states its facts once.
28
+ */
29
+ payload: PromptPayload;
23
30
  /**
24
31
  * Session-approval suggestion for the "for this session" option.
25
32
  * Wraps either a single pattern or multiple patterns behind a unified
26
33
  * interface — the runner never needs to know which case applies.
27
34
  */
28
35
  sessionApproval?: SessionApproval;
29
- /** Details passed to the interactive permission prompt (requestId is added by the runner). */
30
- promptDetails: Omit<PromptPermissionDetails, "requestId">;
36
+ /**
37
+ * Details passed to the interactive permission prompt.
38
+ *
39
+ * The runner stamps both `requestId` (which it mints) and `payload` (which
40
+ * the descriptor owns), so neither is a gate's to supply twice.
41
+ */
42
+ promptDetails: Omit<PromptPermissionDetails, "requestId" | "payload">;
31
43
  /** Extra context fields written to the review log alongside gate outcomes. */
32
44
  logContext: Record<string, unknown>;
33
45
  /** Surface and value for the decision event (may differ from the check surface). */
@@ -68,6 +80,14 @@ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
68
80
  */
69
81
  export interface GateBypass {
70
82
  action: "allow";
83
+ /**
84
+ * What decided this short-circuit.
85
+ *
86
+ * The gate that bypasses *is* the decider, so it states its own provenance
87
+ * and the runner relays it onto the log entry rather than inferring one from
88
+ * the event name (#726). Required, so a bypass added later cannot omit it.
89
+ */
90
+ decidedBy: DecisionSource;
71
91
  /** Optional review log entry to emit. */
72
92
  log?: { event: string; details: Record<string, unknown> };
73
93
  /** Optional decision event to emit. */