@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
@@ -1,34 +1,56 @@
1
1
  import type { ForwardedPermissionRequest } from "#src/authority/permission-forwarding";
2
- import type { PromptPayload } from "#src/presentation/prompt-payload";
2
+ import type {
3
+ PromptPayload,
4
+ PromptRequester,
5
+ } from "#src/presentation/prompt-payload";
3
6
 
4
7
  /**
5
8
  * Build the payload for an ask forwarded up from a subagent.
6
9
  *
7
- * The child still ships a pre-rendered sentence, so the serving node carries it
8
- * as a single evidence entry rather than inventing facts it was not sent: what
9
- * arrives is prose, and calling it anything else would be a fiction the
10
- * bounded renderers would then have to trust.
10
+ * A projection, not a synthesizer: the child ships its own complete payload, so
11
+ * the serving node renders the child's facts under the *parent's* budget — which
12
+ * is what makes a forwarded ask and a local one consistent in kind, a forwarded
13
+ * bash ask reading `command : …` exactly as a local one does (ADR 0011 §2).
11
14
  *
12
- * When the payload replaces `message` on the wire, this builder projects the
13
- * child's own payload instead, and the serving node renders the child's facts
14
- * under its own budget — which is what makes a forwarded ask and a local one
15
- * consistent for the first time (ADR 0011 §2).
16
- *
17
- * A request missing a field renders from whatever it does carry: fail-closed
15
+ * A request carrying no payload renders from whatever it does hold: fail-closed
18
16
  * applies to presentation as it does to policy, so a version-skewed ask still
19
17
  * reaches the human rather than resolving without one (ADR 0011 §9).
20
18
  */
21
19
  export function buildForwardedAskPayload(
22
20
  request: ForwardedPermissionRequest,
21
+ ): PromptPayload {
22
+ // The child built its payload with `localRequester` — `forwarded: false`,
23
+ // `sessionId: null`. The serving node is the only party that knows the ask
24
+ // arrived over the wire, and the request's own provenance is authoritative
25
+ // (#292); everything else is the child's fact and passes through untouched.
26
+ const requester: PromptRequester = {
27
+ agentName: request.requesterAgentName,
28
+ forwarded: true,
29
+ sessionId: request.requesterSessionId,
30
+ };
31
+
32
+ return request.payload
33
+ ? {
34
+ ...request.payload,
35
+ request: { ...request.payload.request, requester },
36
+ }
37
+ : degradedForwardedPayload(request, requester);
38
+ }
39
+
40
+ /**
41
+ * The render for an ask that arrived without a payload.
42
+ *
43
+ * `kind: "forwarded"` narrows to meaning exactly this — not "an ask from a
44
+ * subagent", which every branch above is too.
45
+ */
46
+ function degradedForwardedPayload(
47
+ request: ForwardedPermissionRequest,
48
+ requester: PromptRequester,
23
49
  ): PromptPayload {
24
50
  return {
25
51
  kind: "forwarded",
26
52
  request: {
27
- requester: {
28
- agentName: request.requesterAgentName,
29
- forwarded: true,
30
- sessionId: request.requesterSessionId,
31
- },
53
+ requester,
32
54
  // The child's display projection: what the ask was about, as the child's
33
55
  // own gate named it.
34
56
  surface: request.surface ?? "",
@@ -39,7 +61,10 @@ export function buildForwardedAskPayload(
39
61
  commandContext: null,
40
62
  executedUnit: null,
41
63
  },
42
- evidence: [{ label: "requested", text: request.message, detail: null }],
64
+ // Nothing to carry: the wire no longer relays a sentence, and inventing
65
+ // evidence the child never sent is exactly the fiction the bounded
66
+ // renderers would then have to trust.
67
+ evidence: [],
43
68
  annotations: [],
44
69
  };
45
70
  }
@@ -1,10 +1,17 @@
1
- import type { ExternalPathDisclosure } from "#src/denial-messages";
2
1
  import type {
3
2
  PromptEvidence,
4
3
  PromptPayload,
5
4
  } from "#src/presentation/prompt-payload";
6
5
  import { localRequester } from "#src/presentation/prompt-payload";
7
6
 
7
+ /** A displayed external path paired with its resolved target, when distinct. */
8
+ export interface ExternalPathDisclosure {
9
+ /** The path as displayed (typed for tools, lexical-absolute for bash). */
10
+ path: string;
11
+ /** The canonical symlink-resolved target; present only when it differs. */
12
+ resolvedPath?: string;
13
+ }
14
+
8
15
  /** The facts a path-shaped gate holds when it raises an ask. */
9
16
  interface PathAskFacts {
10
17
  toolName: string;
@@ -22,10 +22,10 @@ export interface PromptPayload {
22
22
  *
23
23
  * Present because the ask shapes are not separable by surface alone: a tool
24
24
  * external-directory ask and a bash one share the `external_directory` surface,
25
- * and the `path` gate and the per-tool gate differ only in wording. It mirrors
26
- * `DenialContext`'s discriminated union, the shape ADR 0011 §7 names as already
27
- * correct, and gives every renderer an exhaustive switch rather than a set of
28
- * string comparisons a new variant sails past.
25
+ * and the `path` gate and the per-tool gate differ only in wording. It gives
26
+ * every renderer an exhaustive switch rather than a set of string comparisons a
27
+ * new variant sails past — which is what let the parallel denial-context union
28
+ * ADR 0011 §7 described dissolve into this one (#746).
29
29
  */
30
30
  export type PromptPayloadKind =
31
31
  | "bash"
@@ -120,6 +120,167 @@ export function localRequester(agentName: string | null): PromptRequester {
120
120
  return { agentName, forwarded: false, sessionId: null };
121
121
  }
122
122
 
123
+ /** Every {@link PromptPayloadKind}, for tolerant reads of a serialized payload. */
124
+ const PROMPT_PAYLOAD_KINDS = [
125
+ "bash",
126
+ "mcp",
127
+ "tool",
128
+ "path",
129
+ "external_directory",
130
+ "bash_external_directory",
131
+ "skill",
132
+ "skill_read",
133
+ "forwarded",
134
+ ] as const satisfies readonly PromptPayloadKind[];
135
+
136
+ const BASH_COMMAND_CONTEXTS = [
137
+ "command_substitution",
138
+ "process_substitution",
139
+ "subshell",
140
+ ] as const satisfies readonly BashCommandContext[];
141
+
142
+ /**
143
+ * Narrow an unknown value to a {@link PromptPayload}, or `undefined`.
144
+ *
145
+ * Lives beside its type so a new request fact updates the guard next door
146
+ * rather than in a distant reader, following `isPermissionDecisionState`'s
147
+ * precedent.
148
+ *
149
+ * All-or-nothing: any malformed field yields `undefined` rather than a
150
+ * half-payload, so a consumer renders its own degraded view instead of
151
+ * presenting corrupt facts (ADR 0011 §9).
152
+ */
153
+ export function asPromptPayload(value: unknown): PromptPayload | undefined {
154
+ const candidate = asObject(value);
155
+ if (!candidate) return undefined;
156
+
157
+ const kind = PROMPT_PAYLOAD_KINDS.find((entry) => entry === candidate.kind);
158
+ const request = asPromptRequestFacts(candidate.request);
159
+ const evidence = asArrayOf(candidate.evidence, asPromptEvidence);
160
+ const annotations = asArrayOf(candidate.annotations, asPromptAnnotation);
161
+ if (!kind || !request || !evidence || !annotations) return undefined;
162
+
163
+ return { kind, request, evidence, annotations };
164
+ }
165
+
166
+ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
167
+ const candidate = asObject(value);
168
+ if (!candidate) return undefined;
169
+
170
+ const requester = asPromptRequester(candidate.requester);
171
+ const commandContext = asNullableMember(
172
+ candidate.commandContext,
173
+ BASH_COMMAND_CONTEXTS,
174
+ );
175
+ if (
176
+ !requester ||
177
+ commandContext === undefined ||
178
+ typeof candidate.surface !== "string" ||
179
+ typeof candidate.value !== "string" ||
180
+ !isNullableString(candidate.toolName) ||
181
+ !isNullableString(candidate.invokedToolName) ||
182
+ !isNullableString(candidate.matchedPattern) ||
183
+ !isNullableString(candidate.executedUnit)
184
+ ) {
185
+ return undefined;
186
+ }
187
+
188
+ return {
189
+ requester,
190
+ surface: candidate.surface,
191
+ toolName: candidate.toolName,
192
+ invokedToolName: candidate.invokedToolName,
193
+ value: candidate.value,
194
+ matchedPattern: candidate.matchedPattern,
195
+ commandContext: commandContext.value,
196
+ executedUnit: candidate.executedUnit,
197
+ };
198
+ }
199
+
200
+ function asPromptRequester(value: unknown): PromptRequester | undefined {
201
+ const candidate = asObject(value);
202
+ if (
203
+ !candidate ||
204
+ typeof candidate.forwarded !== "boolean" ||
205
+ !isNullableString(candidate.agentName) ||
206
+ !isNullableString(candidate.sessionId)
207
+ ) {
208
+ return undefined;
209
+ }
210
+ return {
211
+ agentName: candidate.agentName,
212
+ forwarded: candidate.forwarded,
213
+ sessionId: candidate.sessionId,
214
+ };
215
+ }
216
+
217
+ function asPromptEvidence(value: unknown): PromptEvidence | undefined {
218
+ const candidate = asObject(value);
219
+ if (
220
+ !candidate ||
221
+ typeof candidate.label !== "string" ||
222
+ typeof candidate.text !== "string" ||
223
+ !isNullableString(candidate.detail)
224
+ ) {
225
+ return undefined;
226
+ }
227
+ return {
228
+ label: candidate.label,
229
+ text: candidate.text,
230
+ detail: candidate.detail,
231
+ };
232
+ }
233
+
234
+ function asPromptAnnotation(value: unknown): PromptAnnotation | undefined {
235
+ const candidate = asObject(value);
236
+ if (
237
+ !candidate ||
238
+ typeof candidate.source !== "string" ||
239
+ typeof candidate.text !== "string"
240
+ ) {
241
+ return undefined;
242
+ }
243
+ return { source: candidate.source, text: candidate.text };
244
+ }
245
+
246
+ function asObject(value: unknown): Record<string, unknown> | undefined {
247
+ return typeof value === "object" && value !== null
248
+ ? (value as Record<string, unknown>)
249
+ : undefined;
250
+ }
251
+
252
+ /** Narrow every entry, or `undefined` when the array or any entry is malformed. */
253
+ function asArrayOf<T>(
254
+ value: unknown,
255
+ narrow: (entry: unknown) => T | undefined,
256
+ ): T[] | undefined {
257
+ if (!Array.isArray(value)) return undefined;
258
+ const narrowed: T[] = [];
259
+ for (const entry of value) {
260
+ const result = narrow(entry);
261
+ if (!result) return undefined;
262
+ narrowed.push(result);
263
+ }
264
+ return narrowed;
265
+ }
266
+
267
+ function isNullableString(value: unknown): value is string | null {
268
+ return value === null || typeof value === "string";
269
+ }
270
+
271
+ /**
272
+ * Narrow to `null` or a member of `members`, boxed so a valid `null` is
273
+ * distinguishable from the malformed `undefined`.
274
+ */
275
+ function asNullableMember<T extends string>(
276
+ value: unknown,
277
+ members: readonly T[],
278
+ ): { value: T | null } | undefined {
279
+ if (value === null) return { value: null };
280
+ const member = members.find((entry) => entry === value);
281
+ return member ? { value: member } : undefined;
282
+ }
283
+
123
284
  /** Find the evidence entry a renderer knows by label. */
124
285
  export function findEvidence(
125
286
  payload: PromptPayload,
@@ -0,0 +1,51 @@
1
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
2
+
3
+ /**
4
+ * The payload facts the permission review log persists (ADR 0011 §6).
5
+ *
6
+ * The log is a renderer over the payload like any other, and this is its
7
+ * content decision: the request facts, and only those the log's own structured
8
+ * columns do not already carry. `toolName`, `command`, `path`, `target`, and
9
+ * `toolInputPreview` are written by the gates; restating them under a second
10
+ * name would grow the log rather than sharpen it.
11
+ *
12
+ * Evidence and annotations are deliberately absent.
13
+ * `docs/decisions/0010-permission-log-secret-exposure.md` bounds what the logs
14
+ * accumulate, and evidence is exactly the unbounded part — the point of this
15
+ * render is that the log's growth is a decision, not a side effect of how a
16
+ * prompt happened to be worded.
17
+ *
18
+ * A fact the ask does not carry is omitted rather than written as `null`, so a
19
+ * line states what was true rather than enumerating what was not.
20
+ */
21
+ export function renderReviewLogFacts(
22
+ payload: PromptPayload,
23
+ ): Record<string, unknown> {
24
+ const { request } = payload;
25
+ return {
26
+ surface: request.surface,
27
+ ...present("matchedPattern", request.matchedPattern),
28
+ ...present("executedUnit", request.executedUnit),
29
+ ...present("commandContext", request.commandContext),
30
+ ...present("invokedToolName", request.invokedToolName),
31
+ ...forwardingFacts(payload),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Where the ask came from, when it came from somewhere else.
37
+ *
38
+ * A local ask is the default and states nothing; a forwarded one names the
39
+ * session that raised it, so a decision can be correlated back to the child
40
+ * that asked.
41
+ */
42
+ function forwardingFacts(payload: PromptPayload): Record<string, unknown> {
43
+ const { forwarded, sessionId } = payload.request.requester;
44
+ return forwarded
45
+ ? { forwarded: true, ...present("requesterSessionId", sessionId) }
46
+ : {};
47
+ }
48
+
49
+ function present<T>(key: string, value: T | null): Record<string, T> {
50
+ return value === null ? {} : { [key]: value };
51
+ }
package/src/service.ts CHANGED
@@ -49,6 +49,17 @@ export {
49
49
  PERMISSIONS_READY_CHANNEL,
50
50
  PERMISSIONS_UI_PROMPT_CHANNEL,
51
51
  } from "./permission-events";
52
+ // The declaration bundle already inlines these through `PromptPermissionDetails`
53
+ // and `PermissionUiPromptEvent`; the named exports are what a consumer needs to
54
+ // annotate a variable of their own.
55
+ export type {
56
+ PromptAnnotation,
57
+ PromptEvidence,
58
+ PromptPayload,
59
+ PromptPayloadKind,
60
+ PromptRequester,
61
+ PromptRequestFacts,
62
+ } from "./presentation/prompt-payload";
52
63
  export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
53
64
 
54
65
  /** Process-global key for the service slot. */
@@ -2,7 +2,6 @@ import { safeJsonStringify } from "./json-safe-stringify";
2
2
  import { redactedJsonStringify } from "./log-redaction";
3
3
 
4
4
  export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
5
- export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
6
5
  export const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
7
6
 
8
7
  export function truncateInlineText(value: string, maxLength: number): string {
@@ -1,10 +1,8 @@
1
1
  import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
2
- import type { PermissionSystemExtensionConfig } from "./extension-config";
3
2
  import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
4
3
  import {
5
4
  serializeRedactedToolInputPreview,
6
5
  serializeToolInputPreview,
7
- TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
8
6
  TOOL_INPUT_PREVIEW_MAX_LENGTH,
9
7
  TOOL_TEXT_SUMMARY_MAX_LENGTH,
10
8
  truncateInlineText,
@@ -21,27 +19,21 @@ import { getNonEmptyString, toRecord } from "./value-guards";
21
19
  export interface ToolPreviewFormatterOptions {
22
20
  toolInputPreviewMaxLength: number;
23
21
  toolTextSummaryMaxLength: number;
24
- toolInputLogPreviewMaxLength: number;
25
22
  }
26
23
 
27
- type ConfigurablePreviewLimits = Pick<
28
- PermissionSystemExtensionConfig,
29
- "toolInputPreviewMaxLength" | "toolTextSummaryMaxLength"
30
- >;
31
-
32
24
  /**
33
- * Resolve `ToolPreviewFormatterOptions` from a config object, falling back to
34
- * the built-in defaults for any field that is absent.
25
+ * The built-in `ToolPreviewFormatterOptions`.
26
+ *
27
+ * Takes no config: `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength`
28
+ * are subsumed by the renderer budgets (`promptMaxRows` / `promptFieldMaxWidth`,
29
+ * ADR 0011 §5), so an operator's values no longer take effect. The constants
30
+ * remain because they still shape a *prompt* preview; what the review log
31
+ * persists is bounded by `reviewLogFieldMaxWidth` at the writer instead.
35
32
  */
36
- export function resolveToolPreviewLimits(
37
- config: ConfigurablePreviewLimits,
38
- ): ToolPreviewFormatterOptions {
33
+ export function resolveToolPreviewLimits(): ToolPreviewFormatterOptions {
39
34
  return {
40
- toolInputPreviewMaxLength:
41
- config.toolInputPreviewMaxLength ?? TOOL_INPUT_PREVIEW_MAX_LENGTH,
42
- toolTextSummaryMaxLength:
43
- config.toolTextSummaryMaxLength ?? TOOL_TEXT_SUMMARY_MAX_LENGTH,
44
- toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
35
+ toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
36
+ toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
45
37
  };
46
38
  }
47
39
 
@@ -147,14 +139,16 @@ export class ToolPreviewFormatter {
147
139
  // ── Log formatting ──────────────────────────────────────────────────────
148
140
 
149
141
  /**
150
- * Serialize `input` to inline JSON and truncate at
151
- * `toolInputLogPreviewMaxLength`, masking sensitive-keyed values.
142
+ * Serialize `input` to inline JSON for the review log, masking
143
+ * sensitive-keyed values.
144
+ *
145
+ * Unbounded here: the writer narrows every field it persists to
146
+ * `reviewLogFieldMaxWidth`, so a second bound at the producer would be a
147
+ * limit the operator cannot see or change.
152
148
  */
153
149
  formatGenericToolInputForLog(input: unknown): string | undefined {
154
150
  const inline = serializeRedactedToolInputPreview(input);
155
- return inline
156
- ? `input ${truncateInlineText(inline, this.options.toolInputLogPreviewMaxLength)}`
157
- : undefined;
151
+ return inline ? `input ${inline}` : undefined;
158
152
  }
159
153
 
160
154
  /** Derive a loggable input preview string for the review log. */
@@ -168,16 +162,7 @@ export class ToolPreviewFormatter {
168
162
  }
169
163
 
170
164
  if (pathBearingTools.has(result.toolName)) {
171
- const inputPreview = this.formatToolInputForPrompt(
172
- result.toolName,
173
- input,
174
- );
175
- return inputPreview
176
- ? truncateInlineText(
177
- inputPreview,
178
- this.options.toolInputLogPreviewMaxLength,
179
- )
180
- : undefined;
165
+ return this.formatToolInputForPrompt(result.toolName, input) || undefined;
181
166
  }
182
167
 
183
168
  return this.formatGenericToolInputForLog(input);