@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
@@ -0,0 +1,215 @@
1
+ import { EXTENSION_ID } from "#src/extension-config";
2
+ import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
3
+ import {
4
+ describeBashCommandContext,
5
+ flaggedElementLabel,
6
+ flaggedElements,
7
+ } from "#src/presentation/fact-vocabulary";
8
+ import {
9
+ allEvidence,
10
+ findEvidence,
11
+ type PromptPayload,
12
+ } from "#src/presentation/prompt-payload";
13
+
14
+ /**
15
+ * The agent-facing render of a refused permission ask (ADR 0011 §7).
16
+ *
17
+ * The rule that governs this renderer and no other:
18
+ *
19
+ * > The agent renderer identifies the call; it does not reproduce it.
20
+ *
21
+ * The agent authored the tool call, and the harness returns this text as that
22
+ * call's own tool result with its arguments still in context, so echoing the
23
+ * input back tells it nothing it did not already have. What is new is the
24
+ * verdict: which surface gated the call, which rule matched, which of the
25
+ * call's operands tripped it, and what the human said.
26
+ *
27
+ * The command is the one value never rendered — it is the payload that took
28
+ * over the viewport in #710 and the context window on every denial. The
29
+ * flagged element (a path, an MCP target, a skill) *is* rendered, because
30
+ * which operand a rule fired on is below tool-call granularity and the agent
31
+ * cannot recover it from its own arguments; being agent input, it is capped
32
+ * rather than structurally bounded.
33
+ */
34
+
35
+ /** Attribution tag on every block reason this extension produces. */
36
+ export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
37
+
38
+ /** How much room the flagged element has, as the operator configured it. */
39
+ export interface AgentRenderBudget {
40
+ /** Maximum characters of the flagged element's text. */
41
+ readonly fieldMaxWidth: number;
42
+ }
43
+
44
+ /** The agent-facing render of a policy deny. */
45
+ export function renderPolicyDenial(
46
+ payload: PromptPayload,
47
+ ruleReason: string | null,
48
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
49
+ ): string {
50
+ return tagged(
51
+ `Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
52
+ ruleReason,
53
+ );
54
+ }
55
+
56
+ /** The agent-facing render of a human's denial at an interactive prompt. */
57
+ export function renderUserDenial(
58
+ payload: PromptPayload,
59
+ denialReason: string | null,
60
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
61
+ ): string {
62
+ return tagged(
63
+ `The user denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
64
+ denialReason,
65
+ );
66
+ }
67
+
68
+ /** The agent-facing render when no live authority could answer the ask. */
69
+ export function renderUnavailableDenial(
70
+ payload: PromptPayload,
71
+ denialReason: string | null,
72
+ budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
73
+ ): string {
74
+ return tagged(
75
+ `This ${identification(payload, budget, "call")} requires approval, but no interactive UI is available.`,
76
+ denialReason,
77
+ );
78
+ }
79
+
80
+ // ── Sentence assembly ──────────────────────────────────────────────────────
81
+
82
+ function tagged(sentence: string, reason: string | null): string {
83
+ return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
84
+ }
85
+
86
+ /**
87
+ * What was refused, in the order a reader needs it: the gate surface, the tool
88
+ * that reached it, who asked, which of the call's operands was flagged, and the
89
+ * rule that fired.
90
+ *
91
+ * `callWord` is the noun the verdict needs after the surface — a user or
92
+ * unavailable verdict refuses a *call*, while a policy deny refuses the
93
+ * surface itself.
94
+ */
95
+ function identification(
96
+ payload: PromptPayload,
97
+ budget: AgentRenderBudget,
98
+ callWord: string,
99
+ ): string {
100
+ return [
101
+ `'${payload.request.surface}'`,
102
+ callWord,
103
+ invokedAsClause(payload),
104
+ toolClause(payload),
105
+ agentClause(payload),
106
+ flaggedClause(payload, budget),
107
+ ruleClause(payload),
108
+ ]
109
+ .filter((clause) => clause !== "")
110
+ .join(" ");
111
+ }
112
+
113
+ /** The gated tool, named only when the surface has not already named it. */
114
+ function toolClause(payload: PromptPayload): string {
115
+ const { toolName, surface } = payload.request;
116
+ return toolName === null || toolName === surface
117
+ ? ""
118
+ : `for tool '${toolName}'`;
119
+ }
120
+
121
+ /** The name the agent actually called, when a shell alias re-exposed bash. */
122
+ function invokedAsClause(payload: PromptPayload): string {
123
+ const { invokedToolName } = payload.request;
124
+ return invokedToolName === null ? "" : `(invoked as '${invokedToolName}')`;
125
+ }
126
+
127
+ /** Which agent asked, when the ask carries a name. */
128
+ function agentClause(payload: PromptPayload): string {
129
+ const { agentName } = payload.request.requester;
130
+ return agentName ? `for agent '${agentName}'` : "";
131
+ }
132
+
133
+ /**
134
+ * Which of the call's operands the rule fired on.
135
+ *
136
+ * Omitted for a bash ask, whose flagged element is the command §7 forbids
137
+ * echoing; for a generic tool ask, whose value is the tool name an earlier
138
+ * clause already stated; and for a payload-less forwarded relay, whose value
139
+ * shape is unknown, so it cannot be shown to not be a command.
140
+ */
141
+ function flaggedClause(
142
+ payload: PromptPayload,
143
+ budget: AgentRenderBudget,
144
+ ): string {
145
+ if (payload.kind === "bash" || payload.kind === "forwarded") {
146
+ return "";
147
+ }
148
+ const label = flaggedElementLabel(payload);
149
+ const elements = flaggedElements(payload).filter(
150
+ (element) => element !== payload.request.toolName,
151
+ );
152
+ if (elements.length === 0) {
153
+ return "";
154
+ }
155
+ const noun = elements.length === 1 ? label : `${label}s`;
156
+ return `for ${noun} ${elements
157
+ .map(
158
+ (element) =>
159
+ `'${cap(element, budget)}'${resolvedAlias(payload, element)}`,
160
+ )
161
+ .join(", ")}`;
162
+ }
163
+
164
+ /** The canonical target of a flagged path, when it names somewhere else. */
165
+ function resolvedAlias(payload: PromptPayload, element: string): string {
166
+ const resolved =
167
+ findEvidence(payload, "resolves to")?.text ??
168
+ allEvidence(payload, "external path").find(
169
+ (entry) => entry.text === element,
170
+ )?.detail;
171
+ return resolved ? ` (resolves to '${resolved}')` : "";
172
+ }
173
+
174
+ /** The rule that fired, with the nested context that makes it intelligible. */
175
+ function ruleClause(payload: PromptPayload): string {
176
+ const { matchedPattern, commandContext } = payload.request;
177
+ const parts: string[] = [];
178
+ if (matchedPattern !== null) {
179
+ parts.push(`rule '${matchedPattern}'`);
180
+ }
181
+ const context = describeBashCommandContext(commandContext);
182
+ if (context !== undefined) {
183
+ parts.push(`inside ${context}`);
184
+ }
185
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
186
+ }
187
+
188
+ /** The working directory the flagged paths escaped. */
189
+ function boundaryClause(payload: PromptPayload): string {
190
+ const cwd = findEvidence(payload, "working directory")?.text;
191
+ return cwd ? `: outside working directory '${cwd}'` : "";
192
+ }
193
+
194
+ /** The path a skill read reached its skill through. */
195
+ function provenanceClause(payload: PromptPayload): string {
196
+ const readPath = findEvidence(payload, "read path")?.text;
197
+ return readPath ? `, reached via '${readPath}'` : "";
198
+ }
199
+
200
+ function reasonClause(reason: string | null): string {
201
+ return reason ? ` Reason: ${reason}.` : "";
202
+ }
203
+
204
+ /**
205
+ * Narrow the flagged element to the budget.
206
+ *
207
+ * The command is never rendered, so this bounds the only agent-supplied value
208
+ * that reaches the agent. A quantity bound applied uniformly, never a content
209
+ * filter, with the same bare-ellipsis marker the dialog uses (ADR 0011 §4).
210
+ */
211
+ function cap(text: string, budget: AgentRenderBudget): string {
212
+ return text.length <= budget.fieldMaxWidth
213
+ ? text
214
+ : `${text.slice(0, budget.fieldMaxWidth)}\u2026`;
215
+ }
@@ -1,9 +1,10 @@
1
- import { describeBashCommandContext } from "#src/denial-messages";
2
- import { fitLinesToWidth } from "#src/presentation/line-fitting";
3
1
  import {
4
- allEvidence,
5
- type PromptPayload,
6
- } from "#src/presentation/prompt-payload";
2
+ describeBashCommandContext,
3
+ flaggedElements,
4
+ valueLabel,
5
+ } from "#src/presentation/fact-vocabulary";
6
+ import { fitLinesToWidth } from "#src/presentation/line-fitting";
7
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
7
8
 
8
9
  /**
9
10
  * Render a {@link PromptPayload} for a human deciding an ask (ADR 0011 §5).
@@ -31,7 +32,7 @@ export function renderPromptDialog(
31
32
  );
32
33
  const blocks = layout(
33
34
  [...core, ...evidence],
34
- flaggedTexts(payload),
35
+ flaggedElements(payload),
35
36
  paint,
36
37
  ).map((block) => fitLinesToWidth(block, budget.width));
37
38
  const fitted = fitToRows(
@@ -122,21 +123,6 @@ export function completeViewBudget(width: number): DialogBudget {
122
123
 
123
124
  const plainText: HighlightPaint = (text) => text;
124
125
 
125
- /**
126
- * What the ask is flagging.
127
- *
128
- * The decision-relevant value for every shape but one: a bash ask that escaped
129
- * the working directory flags the paths it referenced, not the command that
130
- * referenced them — the command is the context, and the paths are what the
131
- * operator is ruling on.
132
- */
133
- function flaggedTexts(payload: PromptPayload): string[] {
134
- if (payload.kind === "bash_external_directory") {
135
- return allEvidence(payload, "external path").map((entry) => entry.text);
136
- }
137
- return payload.request.value === "" ? [] : [payload.request.value];
138
- }
139
-
140
126
  /** One rendered fact. */
141
127
  interface Fact {
142
128
  readonly label: string;
@@ -242,9 +228,7 @@ function coreFacts(payload: PromptPayload): Fact[] {
242
228
  if (request.executedUnit !== null) {
243
229
  facts.push({ label: "runs", text: request.executedUnit });
244
230
  }
245
- const context = describeBashCommandContext(
246
- request.commandContext ?? undefined,
247
- );
231
+ const context = describeBashCommandContext(request.commandContext);
248
232
  if (context !== undefined) {
249
233
  facts.push({ label: "context", text: context });
250
234
  }
@@ -293,46 +277,6 @@ function toolText(payload: PromptPayload): string {
293
277
  : `${String(toolName)} (invoked as ${invokedToolName})`;
294
278
  }
295
279
 
296
- /** What the decision-relevant value is called, per ask shape. */
297
- function valueLabel(payload: PromptPayload): string {
298
- switch (payload.kind) {
299
- case "bash":
300
- case "bash_external_directory":
301
- return "command";
302
- case "mcp":
303
- return "target";
304
- case "tool":
305
- return "tool";
306
- case "path":
307
- case "external_directory":
308
- return "path";
309
- case "skill":
310
- case "skill_read":
311
- return "skill";
312
- case "forwarded":
313
- return forwardedValueLabel(payload.request.surface);
314
- }
315
- }
316
-
317
- /**
318
- * A forwarded request carries the child's *display* projection — its tool name
319
- * as the surface — rather than the child's own payload, so the label is
320
- * inferred from it and falls back to a neutral one.
321
- *
322
- * Dissolves when the payload replaces `message` on the wire (#745): the
323
- * serving node will then hold the child's real `kind`.
324
- */
325
- function forwardedValueLabel(surface: string): string {
326
- switch (surface) {
327
- case "bash":
328
- return "command";
329
- case "skill":
330
- return "skill";
331
- default:
332
- return "value";
333
- }
334
- }
335
-
336
280
  /**
337
281
  * Align the labels into a `label : value` column.
338
282
  *
@@ -0,0 +1,103 @@
1
+ import {
2
+ allEvidence,
3
+ type PromptPayload,
4
+ } from "#src/presentation/prompt-payload";
5
+ import type { BashCommandContext } from "#src/types";
6
+
7
+ /**
8
+ * The render vocabulary shared by every renderer over a {@link PromptPayload}.
9
+ *
10
+ * Which element an ask flags, what that element is called, and how a nested
11
+ * execution context reads are all answers a render needs and none of them is
12
+ * a payload fact — the payload carries `value`, `kind`, and `commandContext`,
13
+ * and this module is where they acquire a name. It lives apart from any one
14
+ * renderer so the dialog, the agent-facing text, and the review log cannot
15
+ * disagree about what a given ask is flagging.
16
+ */
17
+
18
+ /**
19
+ * What the ask is flagging.
20
+ *
21
+ * The decision-relevant value for every shape but one: a bash ask that escaped
22
+ * the working directory flags the paths it referenced, not the command that
23
+ * referenced them — the command is the context, and the paths are what the
24
+ * operator is ruling on.
25
+ */
26
+ export function flaggedElements(payload: PromptPayload): readonly string[] {
27
+ if (payload.kind === "bash_external_directory") {
28
+ return allEvidence(payload, "external path").map((entry) => entry.text);
29
+ }
30
+ return payload.request.value === "" ? [] : [payload.request.value];
31
+ }
32
+
33
+ /**
34
+ * What {@link flaggedElements} returns is called.
35
+ *
36
+ * Differs from {@link valueLabel} for exactly one shape: a bash ask that
37
+ * escaped the working directory flags paths while its value is the command,
38
+ * so the two nouns are for two different things.
39
+ */
40
+ export function flaggedElementLabel(payload: PromptPayload): string {
41
+ return payload.kind === "bash_external_directory"
42
+ ? "path"
43
+ : valueLabel(payload);
44
+ }
45
+
46
+ /** What the decision-relevant value is called, per ask shape. */
47
+ export function valueLabel(payload: PromptPayload): string {
48
+ switch (payload.kind) {
49
+ case "bash":
50
+ case "bash_external_directory":
51
+ return "command";
52
+ case "mcp":
53
+ return "target";
54
+ case "tool":
55
+ return "tool";
56
+ case "path":
57
+ case "external_directory":
58
+ return "path";
59
+ case "skill":
60
+ case "skill_read":
61
+ return "skill";
62
+ case "forwarded":
63
+ return forwardedValueLabel(payload.request.surface);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Labels the version-skew render only: a payload-bearing forwarded ask carries
69
+ * the child's real `kind` and never reaches this arm (#745).
70
+ *
71
+ * Without a payload all that survives is the child's *display* projection — its
72
+ * tool name as the surface — so the label is inferred from it and falls back to
73
+ * a neutral one.
74
+ */
75
+ function forwardedValueLabel(surface: string): string {
76
+ switch (surface) {
77
+ case "bash":
78
+ return "command";
79
+ case "skill":
80
+ return "skill";
81
+ default:
82
+ return "value";
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Human-readable label for a nested bash execution context, or `undefined` for
88
+ * a current-shell (top-level) command.
89
+ */
90
+ export function describeBashCommandContext(
91
+ context: BashCommandContext | null,
92
+ ): string | undefined {
93
+ switch (context) {
94
+ case "command_substitution":
95
+ return "command substitution";
96
+ case "process_substitution":
97
+ return "process substitution";
98
+ case "subshell":
99
+ return "subshell";
100
+ case null:
101
+ return undefined;
102
+ }
103
+ }
@@ -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;