@gotgenes/pi-permission-system 25.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [25.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.3.0...pi-permission-system-v25.4.0) (2026-08-15)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** adopt the requester's request id as the forwarded request id ([3f8d3fd](https://github.com/gotgenes/pi-packages/commit/3f8d3fd69940211c6a92b99f8eae0396936afedf)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
14
+ * **pi-permission-system:** carry the request id on permissions:decision ([95c001c](https://github.com/gotgenes/pi-packages/commit/95c001cf6b35c68991143826d8621b1e94fc0b32)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
15
+ * **pi-permission-system:** give the gate-error review entry a request id ([172bc68](https://github.com/gotgenes/pi-packages/commit/172bc689ea0f18c88672a22c0b5e84c0f3576fd1)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
16
+ * **pi-permission-system:** mint a permission request id at request creation ([336842d](https://github.com/gotgenes/pi-packages/commit/336842de6c34e96bd90ed411eaca9649c4b9fac2)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** record the minted request id in the API and architecture docs ([84f5856](https://github.com/gotgenes/pi-packages/commit/84f5856868a548c9454ef3e23ca75c608ce3f308)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
22
+
8
23
  ## [25.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.2...pi-permission-system-v25.3.0) (2026-08-15)
9
24
 
10
25
 
package/dist/public.d.ts CHANGED
@@ -207,6 +207,12 @@ interface PermissionUiPromptEvent {
207
207
  type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable";
208
208
  /** Payload emitted on `permissions:decision`. */
209
209
  interface PermissionDecisionEvent {
210
+ /**
211
+ * Identifies the permission request this decision resolves, minted when the
212
+ * request was created. Distinct from the host's tool-call id: one tool call
213
+ * runs several gates and so raises several requests.
214
+ */
215
+ requestId: string;
210
216
  /** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
211
217
  surface: string;
212
218
  /** The value that was evaluated (command, tool name, skill name, path). */
@@ -336,15 +336,15 @@ pi.events.on("permissions:ui_prompt", (raw) => {
336
336
 
337
337
  ### Payload Fields
338
338
 
339
- | Field | Type | Description |
340
- | ------------ | -------------------------------- | ---------------------------------------------------------------------- |
341
- | `requestId` | `string` | Unique ID for the permission request being prompted |
342
- | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"` |
343
- | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
344
- | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
345
- | `agentName` | `string \| null` | Active/requesting agent name, when known |
346
- | `message` | `string` | Message displayed in the permission prompt |
347
- | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
339
+ | Field | Type | Description |
340
+ | ------------ | -------------------------------- | ----------------------------------------------------------------------- |
341
+ | `requestId` | `string` | Id of the permission request being prompted, minted when it was created |
342
+ | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"` |
343
+ | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
344
+ | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
345
+ | `agentName` | `string \| null` | Active/requesting agent name, when known |
346
+ | `message` | `string` | Message displayed in the permission prompt |
347
+ | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
348
348
 
349
349
  Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its original `source` and is identified by a non-null `forwarding` field, not by a dedicated source value.
350
350
 
@@ -367,6 +367,10 @@ The stability guarantee is additive, so any can be reintroduced in a later minor
367
367
  Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
368
368
  This is useful for dashboards, telemetry, or audit overlays.
369
369
 
370
+ The `requestId` is the same id the request's review-log entries carry, and the same one `permissions:ui_prompt` carried if the request reached a prompt — so a prompt and its outcome are joinable, as are two concurrent prompts for the same command.
371
+ It identifies a permission *request*, not a tool call: one tool call runs several gates and so raises several requests, each with its own id.
372
+ Use the review log's `toolCallId` to join back to the Pi transcript.
373
+
370
374
  ```typescript
371
375
  pi.events.on("permissions:decision", (raw) => {
372
376
  const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
@@ -379,6 +383,7 @@ pi.events.on("permissions:decision", (raw) => {
379
383
 
380
384
  | Field | Type | Description |
381
385
  | ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
386
+ | `requestId` | `string` | Id of the permission request this decision resolves |
382
387
  | `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
383
388
  | `value` | `string` | Value evaluated (command, tool name, skill name, path) |
384
389
  | `result` | `"allow" \| "deny"` | Final outcome |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "25.3.0",
3
+ "version": "25.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,6 +34,7 @@ import {
34
34
  } from "#src/authority/permission-forwarding";
35
35
  import type { ServingLookup } from "#src/authority/serving-registry";
36
36
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
37
+ import { createPermissionRequestId } from "#src/permission-request-id";
37
38
  import { buildUiPrompt } from "#src/permission-ui-prompt";
38
39
  import type { DebugReviewLogger } from "#src/session-logger";
39
40
  import { toRecord } from "#src/value-guards";
@@ -75,6 +76,12 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
75
76
  * relayed value instead of three positional optionals.
76
77
  */
77
78
  interface ForwardedRequestFacts {
79
+ /**
80
+ * The requester's own permission request id, adopted as the forwarded
81
+ * request's id so one id runs from the child's gate to the serving node's
82
+ * decision instead of a third being minted here.
83
+ */
84
+ requestId: string;
78
85
  message: string;
79
86
  display?: ForwardedPromptDisplay;
80
87
  sessionApproval?: ForwardedSessionApproval;
@@ -111,6 +118,23 @@ function abandon(denialReason: string): PermissionPromptDecision {
111
118
  };
112
119
  }
113
120
 
121
+ /** Ids this node is willing to use as a request/response filename. */
122
+ const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
123
+
124
+ /**
125
+ * The id to write on the forwarded request: the requester's own, or a fresh
126
+ * mint when that id could not safely name a file.
127
+ *
128
+ * At a relay hop the adopted id came from a request file on disk, which the
129
+ * tolerant reader validates only as a string — so this is the boundary that
130
+ * keeps an inbound id from choosing an outbound path.
131
+ */
132
+ function forwardableRequestId(requesterRequestId: string): string {
133
+ return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
134
+ ? requesterRequestId
135
+ : createPermissionRequestId();
136
+ }
137
+
114
138
  /**
115
139
  * Authorizer for a subagent session: escalate the ask up the tree to the
116
140
  * parent's authority.
@@ -146,6 +170,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
146
170
  ): Promise<PermissionPromptDecision> {
147
171
  const uiPrompt = buildUiPrompt(details);
148
172
  return this.waitForForwardedApproval(this.ctx, {
173
+ requestId: details.requestId,
149
174
  message: details.message,
150
175
  display: {
151
176
  source: uiPrompt.source,
@@ -250,7 +275,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
250
275
  requesterSessionId: string,
251
276
  targetSessionId: string,
252
277
  ): ForwardedPermissionRequest {
253
- const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
278
+ const requestId = forwardableRequestId(facts.requestId);
254
279
  const requesterAgentName =
255
280
  getActiveAgentName(ctx) ??
256
281
  getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
@@ -51,6 +51,15 @@ export interface GateDescriptor {
51
51
  preCheck?: PermissionCheckResult;
52
52
  }
53
53
 
54
+ /**
55
+ * A decision event's facts, before the runner stamps the request id it minted.
56
+ *
57
+ * A gate knows what was decided but not which request it was deciding — the id
58
+ * is minted in `GateRunner.run`. Producing this type rather than the full event
59
+ * is what routes every emit through the runner's single stamping site.
60
+ */
61
+ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
62
+
54
63
  /**
55
64
  * Early allow result — gate has determined the action without needing the runner.
56
65
  *
@@ -62,7 +71,7 @@ export interface GateBypass {
62
71
  /** Optional review log entry to emit. */
63
72
  log?: { event: string; details: Record<string, unknown> };
64
73
  /** Optional decision event to emit. */
65
- decision?: PermissionDecisionEvent;
74
+ decision?: DecisionEventFacts;
66
75
  }
67
76
 
68
77
  /** Union of possible gate function return values. */
@@ -1,11 +1,9 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import { classifyToolKind } from "#src/access-intent/tool-kind";
3
3
  import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
4
- import type {
5
- PermissionDecisionEvent,
6
- PermissionDecisionResolution,
7
- } from "#src/permission-events";
4
+ import type { PermissionDecisionResolution } from "#src/permission-events";
8
5
  import type { PermissionCheckResult } from "#src/types";
6
+ import type { DecisionEventFacts } from "./descriptor";
9
7
 
10
8
  /**
11
9
  * Build the child-fixed access facts for a path-shaped gate from its
@@ -62,11 +60,12 @@ export function deriveDecisionValue(
62
60
  }
63
61
 
64
62
  /**
65
- * Build a `PermissionDecisionEvent` from the gate's inputs.
63
+ * Build a decision event's facts from the gate's inputs.
66
64
  *
67
65
  * Centralises the `origin / agentName / matchedPattern ?? null` normalization
68
66
  * that is otherwise duplicated across the session-hit path and the gate-result
69
- * path in `runGateCheck`.
67
+ * path in `runGateCheck`. The request id is stamped by the runner, which is
68
+ * where it was minted.
70
69
  */
71
70
  export function buildDecisionEvent(
72
71
  decision: { surface: string; value: string },
@@ -74,7 +73,7 @@ export function buildDecisionEvent(
74
73
  agentName: string | null,
75
74
  result: "allow" | "deny",
76
75
  resolution: PermissionDecisionResolution,
77
- ): PermissionDecisionEvent {
76
+ ): DecisionEventFacts {
78
77
  return {
79
78
  surface: decision.surface,
80
79
  value: decision.value,
@@ -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
  *
@@ -68,6 +68,7 @@ export function describeSkillReadGate(
68
68
  },
69
69
  logContext: {
70
70
  source: "skill_read",
71
+ toolCallId: tcc.toolCallId,
71
72
  skillName: matchedSkill.name,
72
73
  agentName: tcc.agentName,
73
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
  }
@@ -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. */
@@ -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). */
@@ -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
+ }