@gotgenes/pi-permission-system 25.2.2 → 25.4.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 (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +162 -41
  5. package/docs/configuration.md +17 -2
  6. package/docs/cross-extension-api.md +14 -9
  7. package/package.json +1 -1
  8. package/schemas/permissions.schema.json +16 -0
  9. package/src/access-intent/bash/command-enumeration.ts +45 -117
  10. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  11. package/src/authority/approval-escalator.ts +26 -1
  12. package/src/authority/forwarded-request-server.ts +5 -14
  13. package/src/authority/local-user-authorizer.ts +2 -3
  14. package/src/authority/permission-prompt-component.ts +87 -47
  15. package/src/authority/permission-prompter.ts +9 -0
  16. package/src/config-loader.ts +2 -0
  17. package/src/config-schema.ts +14 -0
  18. package/src/extension-config.ts +10 -0
  19. package/src/handlers/gates/bash-command.ts +7 -2
  20. package/src/handlers/gates/bash-external-directory.ts +11 -6
  21. package/src/handlers/gates/bash-path.ts +10 -6
  22. package/src/handlers/gates/descriptor.ts +10 -1
  23. package/src/handlers/gates/external-directory.ts +13 -8
  24. package/src/handlers/gates/helpers.ts +6 -7
  25. package/src/handlers/gates/path.ts +11 -14
  26. package/src/handlers/gates/runner.ts +40 -19
  27. package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
  28. package/src/handlers/gates/skill-input.ts +5 -2
  29. package/src/handlers/gates/skill-read.ts +6 -6
  30. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
  31. package/src/handlers/gates/tool.ts +10 -5
  32. package/src/handlers/tool-call-boundary.ts +30 -7
  33. package/src/index.ts +2 -0
  34. package/src/permission-events.ts +6 -0
  35. package/src/permission-prompts.ts +4 -72
  36. package/src/permission-request-id.ts +17 -0
  37. package/src/presentation/dialog-renderer.ts +404 -0
  38. package/src/presentation/forwarded-ask-payload.ts +45 -0
  39. package/src/presentation/legacy-message.ts +117 -0
  40. package/src/presentation/line-fitting.ts +27 -0
  41. package/src/presentation/path-ask-payload.ts +128 -0
  42. package/src/presentation/prompt-payload.ts +137 -0
  43. package/src/presentation/skill-ask-payload.ts +50 -0
  44. package/src/presentation/tool-ask-payload.ts +104 -0
  45. package/src/tool-preview-formatter.ts +1 -1
  46. package/src/types.ts +6 -0
  47. package/src/handlers/gates/external-directory-messages.ts +0 -28
@@ -7,10 +7,15 @@ import {
7
7
  formatUserDeniedReason,
8
8
  } from "#src/denial-messages";
9
9
  import { applyPermissionGate } from "#src/permission-gate";
10
+ import { createPermissionRequestId } from "#src/permission-request-id";
10
11
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
11
12
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
12
13
  import type { PermissionCheckResult } from "#src/types";
13
- import type { GateDescriptor, GateResult } from "./descriptor";
14
+ import type {
15
+ DecisionEventFacts,
16
+ GateDescriptor,
17
+ GateResult,
18
+ } from "./descriptor";
14
19
  import { isGateBypass } from "./descriptor";
15
20
  import {
16
21
  buildDecisionEvent,
@@ -46,33 +51,44 @@ export class GateRunner {
46
51
  /**
47
52
  * Execute a gate: null → allow; bypass → log/emit side effects then allow;
48
53
  * descriptor → full check→log→emit→approve cycle.
54
+ *
55
+ * The request id is minted here, before the branch, so a request that never
56
+ * prompts is identified exactly as one that does.
49
57
  */
50
- async run(
51
- gate: GateResult,
52
- agentName: string | null,
53
- toolCallId: string,
54
- ): Promise<GateOutcome> {
58
+ async run(gate: GateResult, agentName: string | null): Promise<GateOutcome> {
55
59
  if (!gate) {
56
60
  return { action: "allow" };
57
61
  }
62
+ const requestId = createPermissionRequestId();
58
63
  if (isGateBypass(gate)) {
59
64
  if (gate.log) {
60
- this.reporter.writeReviewLog(gate.log.event, gate.log.details);
65
+ this.reporter.writeReviewLog(gate.log.event, {
66
+ ...gate.log.details,
67
+ requestId,
68
+ });
61
69
  }
62
70
  if (gate.decision) {
63
- this.reporter.emitDecision(gate.decision);
71
+ this.emitDecision(requestId, gate.decision);
64
72
  }
65
73
  return { action: "allow" };
66
74
  }
67
- return this.runDescriptor(gate, agentName, toolCallId);
75
+ return this.runDescriptor(gate, agentName, requestId);
68
76
  }
69
77
 
70
78
  // ── Private helpers ──────────────────────────────────────────────────────
71
79
 
80
+ /**
81
+ * The one place a decision event acquires its request id, so no emit path
82
+ * can be added that forgets it.
83
+ */
84
+ private emitDecision(requestId: string, facts: DecisionEventFacts): void {
85
+ this.reporter.emitDecision({ requestId, ...facts });
86
+ }
87
+
72
88
  private async runDescriptor(
73
89
  descriptor: GateDescriptor,
74
90
  agentName: string | null,
75
- toolCallId: string,
91
+ requestId: string,
76
92
  ): Promise<GateOutcome> {
77
93
  // 1. Resolve permission state — pre-check, pre-resolved, or via resolver
78
94
  let check: PermissionCheckResult;
@@ -94,15 +110,19 @@ export class GateRunner {
94
110
  });
95
111
  }
96
112
 
113
+ // The fields every review-log write for this gate shares, whatever the
114
+ // resolution — built once so a field added here reaches all of them.
115
+ const logContext = { ...descriptor.logContext, agentName, requestId };
116
+
97
117
  // 2. Session-hit fast path
98
118
  if (check.source === "session") {
99
119
  this.reporter.writeReviewLog("permission_request.session_approved", {
100
- ...descriptor.logContext,
101
- agentName,
120
+ ...logContext,
102
121
  resolution: "session_approved",
103
122
  sessionApprovalPattern: check.matchedPattern,
104
123
  });
105
- this.reporter.emitDecision(
124
+ this.emitDecision(
125
+ requestId,
106
126
  buildDecisionEvent(
107
127
  descriptor.decision,
108
128
  check,
@@ -121,11 +141,11 @@ export class GateRunner {
121
141
  const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
122
142
  if (yoloGrant) {
123
143
  this.reporter.writeReviewLog("permission_request.auto_approved", {
124
- ...descriptor.logContext,
125
- agentName,
144
+ ...logContext,
126
145
  resolution: "auto_approved",
127
146
  });
128
- this.reporter.emitDecision(
147
+ this.emitDecision(
148
+ requestId,
129
149
  buildDecisionEvent(
130
150
  descriptor.decision,
131
151
  yoloGrant,
@@ -159,7 +179,7 @@ export class GateRunner {
159
179
  sessionApproval: descriptor.sessionApproval?.toGateApproval(),
160
180
  promptForApproval: async () => {
161
181
  const decision = await this.prompter.escalate({
162
- requestId: toolCallId,
182
+ requestId,
163
183
  ...descriptor.promptDetails,
164
184
  ...(descriptor.sessionApproval
165
185
  ? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
@@ -171,7 +191,7 @@ export class GateRunner {
171
191
  },
172
192
  writeLog: (event, details) =>
173
193
  this.reporter.writeReviewLog(event, details),
174
- logContext: { ...descriptor.logContext, agentName },
194
+ logContext,
175
195
  messages,
176
196
  });
177
197
 
@@ -180,7 +200,8 @@ export class GateRunner {
180
200
  gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
181
201
 
182
202
  // 5. Emit decision event
183
- this.reporter.emitDecision(
203
+ this.emitDecision(
204
+ requestId,
184
205
  buildDecisionEvent(
185
206
  descriptor.decision,
186
207
  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,23 +70,12 @@ 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
  *
@@ -1,4 +1,5 @@
1
- import { formatSkillAskPrompt } from "#src/permission-prompts";
1
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
2
+ import { buildSkillAskPayload } from "#src/presentation/skill-ask-payload";
2
3
  import type { PermissionCheckResult } from "#src/types";
3
4
  import type { GateDescriptor } from "./descriptor";
4
5
  import { accessFactsFromValue } from "./helpers";
@@ -15,7 +16,8 @@ export function describeSkillInputGate(
15
16
  agentName: string | null,
16
17
  preCheck: PermissionCheckResult,
17
18
  ): GateDescriptor {
18
- const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
19
+ const payload = buildSkillAskPayload(skillName, agentName);
20
+ const message = renderLegacyMessage(payload);
19
21
  return {
20
22
  surface: "skill",
21
23
  input: { name: skillName },
@@ -29,6 +31,7 @@ export function describeSkillInputGate(
29
31
  source: "skill_input",
30
32
  agentName,
31
33
  message,
34
+ payload,
32
35
  skillName,
33
36
  accessIntent: accessFactsFromValue("skill", skillName),
34
37
  },
@@ -1,5 +1,6 @@
1
1
  import type { PathNormalizer } from "#src/path-normalizer";
2
- import { formatSkillPathAskPrompt } from "#src/permission-prompts";
2
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
3
+ import { buildSkillPathAskPayload } from "#src/presentation/skill-ask-payload";
3
4
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
5
  import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
5
6
  import { toRecord } from "#src/value-guards";
@@ -42,11 +43,8 @@ export function describeSkillReadGate(
42
43
  return null;
43
44
  }
44
45
 
45
- const skillReadMessage = formatSkillPathAskPrompt(
46
- matchedSkill,
47
- path,
48
- tcc.agentName ?? undefined,
49
- );
46
+ const payload = buildSkillPathAskPayload(matchedSkill, path, tcc.agentName);
47
+ const skillReadMessage = renderLegacyMessage(payload);
50
48
 
51
49
  return {
52
50
  surface: "skill",
@@ -61,6 +59,7 @@ export function describeSkillReadGate(
61
59
  source: "skill_read",
62
60
  agentName: tcc.agentName,
63
61
  message: skillReadMessage,
62
+ payload,
64
63
  toolCallId: tcc.toolCallId,
65
64
  toolName: tcc.toolName,
66
65
  skillName: matchedSkill.name,
@@ -69,6 +68,7 @@ export function describeSkillReadGate(
69
68
  },
70
69
  logContext: {
71
70
  source: "skill_read",
71
+ toolCallId: tcc.toolCallId,
72
72
  skillName: matchedSkill.name,
73
73
  agentName: tcc.agentName,
74
74
  path,
@@ -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,8 @@ import {
6
6
  type ShellInvocation,
7
7
  } from "#src/access-intent/tool-kind";
8
8
  import { suggestSessionPattern } from "#src/pattern-suggest";
9
- import { formatAskPrompt } from "#src/permission-prompts";
9
+ import { renderLegacyMessage } from "#src/presentation/legacy-message";
10
+ import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
10
11
  import { SessionApproval } from "#src/session-approval";
11
12
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
12
13
  import type { PermissionCheckResult } from "#src/types";
@@ -72,12 +73,15 @@ export function describeToolGate(
72
73
  deriveSuggestionValue(gateSurface, check, accessPath),
73
74
  );
74
75
 
75
- const askMessage = formatAskPrompt(
76
+ const payload = buildToolAskPayload({
76
77
  check,
77
- tcc.agentName ?? undefined,
78
- tcc.input,
78
+ agentName: tcc.agentName,
79
+ surface: gateSurface,
80
+ invokedToolName: tcc.toolName,
81
+ input: tcc.input,
79
82
  formatter,
80
- );
83
+ });
84
+ const askMessage = renderLegacyMessage(payload);
81
85
 
82
86
  const decisionValue = deriveDecisionValue(
83
87
  gateSurface,
@@ -108,6 +112,7 @@ export function describeToolGate(
108
112
  source: "tool_call",
109
113
  agentName: tcc.agentName,
110
114
  message: askMessage,
115
+ payload,
111
116
  toolCallId: tcc.toolCallId,
112
117
  toolName: tcc.toolName,
113
118
  sessionLabel: suggestion.label,
@@ -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. */
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import { PermissionManager } from "./permission-manager";
38
38
  import { PermissionResolver } from "./permission-resolver";
39
39
  import { PermissionSession } from "./permission-session";
40
40
  import { LocalPermissionsService } from "./permissions-service";
41
+ import { resolveRenderBudget } from "./presentation/dialog-renderer";
41
42
  import { PermissionServiceLifecycle } from "./service-lifecycle";
42
43
  import { PermissionSessionLogger } from "./session-logger";
43
44
  import { SessionRules } from "./session-rules";
@@ -115,6 +116,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
115
116
  events: pi.events,
116
117
  getPromptPreferences: () => ({
117
118
  doublePressToConfirm: configStore.current().doublePressToConfirm,
119
+ budget: resolveRenderBudget(configStore.current()),
118
120
  }),
119
121
  requestPermissionDecision,
120
122
  forwardingDir: paths.forwardingDir,
@@ -101,6 +101,12 @@ export type PermissionDecisionResolution =
101
101
 
102
102
  /** Payload emitted on `permissions:decision`. */
103
103
  export interface PermissionDecisionEvent {
104
+ /**
105
+ * Identifies the permission request this decision resolves, minted when the
106
+ * request was created. Distinct from the host's tool-call id: one tool call
107
+ * runs several gates and so raises several requests.
108
+ */
109
+ requestId: string;
104
110
  /** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
105
111
  surface: string;
106
112
  /** The value that was evaluated (command, tool name, skill name, path). */
@@ -1,13 +1,8 @@
1
- import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
2
- import { matchQualifier } from "./denial-messages";
3
- import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
4
- import type { ToolPreviewFormatter } from "./tool-preview-formatter";
5
- import type { PermissionCheckResult } from "./types";
6
- import { getNonEmptyString, toRecord } from "./value-guards";
1
+ import { classifyToolKind } from "./access-intent/tool-kind";
7
2
 
8
- // NOTE: formatDenyReason, formatUserDeniedReason, and
9
- // formatPermissionHardStopHint have been moved to denial-messages.ts.
10
- // This module retains only pre-check messages and user-facing ask prompts.
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.
11
6
 
12
7
  export function formatMissingToolNameReason(): string {
13
8
  return "Tool call was blocked because no tool name was provided. Use a registered tool name from pi.getAllTools().";
@@ -29,66 +24,3 @@ export function formatUnknownToolReason(
29
24
 
30
25
  return `Tool '${toolName}' is not registered in this runtime and was blocked before permission checks.${mcpHint} Registered tools: ${availableList}.`;
31
26
  }
32
-
33
- export function formatAskPrompt(
34
- result: PermissionCheckResult,
35
- agentName?: string,
36
- input?: unknown,
37
- formatter?: ToolPreviewFormatter,
38
- ): string {
39
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
40
-
41
- if (classifyToolKind(result.toolName) === "bash") {
42
- const subCommand = result.command ?? "";
43
- const qualifier = matchQualifier(
44
- result.matchedPattern,
45
- result.commandContext,
46
- );
47
- const qualifierInfo = qualifier ? ` ${qualifier}` : "";
48
- const fullCommand = getNonEmptyString(toRecord(input).command);
49
- const fullCommandInfo =
50
- fullCommand && fullCommand !== subCommand
51
- ? ` (full command: '${fullCommand}')`
52
- : "";
53
- return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
54
- }
55
-
56
- if (isMcpCheck(result) && result.target) {
57
- const patternInfo = result.matchedPattern
58
- ? ` (matched '${result.matchedPattern}')`
59
- : "";
60
- const mcpPreview = formatter
61
- ? formatter.formatToolInputForPrompt("mcp", input)
62
- : "";
63
- const previewSuffix = mcpPreview ? ` ${mcpPreview}` : "";
64
- return `${subject} requested MCP target '${result.target}'${patternInfo}${previewSuffix}. Allow this call?`;
65
- }
66
-
67
- const patternInfo = result.matchedPattern
68
- ? ` (matched '${result.matchedPattern}')`
69
- : "";
70
- const inputPreview = formatter
71
- ? formatter.formatToolInputForPrompt(result.toolName, input)
72
- : "";
73
- const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
74
- return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
75
- }
76
-
77
- export function formatSkillAskPrompt(
78
- skillName: string,
79
- agentName?: string,
80
- ): string {
81
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
82
- return `${subject} requested skill '${skillName}'. Allow loading this skill?`;
83
- }
84
-
85
- export function formatSkillPathAskPrompt(
86
- skill: SkillPromptEntry,
87
- readPath: string,
88
- agentName?: string,
89
- ): string {
90
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
91
- return `${subject} requested access to skill '${skill.name}' via '${readPath}'. Allow this read?`;
92
- }
93
-
94
- // formatSkillPathDenyReason has been moved to denial-messages.ts.
@@ -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
+ }