@gotgenes/pi-permission-system 26.2.2 → 26.3.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,19 @@ 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
+ ## [26.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.2...pi-permission-system-v26.3.0) (2026-08-18)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** broadcast a terminal decision when a gate error blocks a tool call ([2ccb8e5](https://github.com/gotgenes/pi-packages/commit/2ccb8e50a4ef376ced8448efba2d5f9f3658e98f)), closes [#753](https://github.com/gotgenes/pi-packages/issues/753)
14
+ * **pi-permission-system:** broadcast the terminal decision for a served forwarded ask ([f2d6b17](https://github.com/gotgenes/pi-packages/commit/f2d6b176abdd47a2b3ce5c3cf6973ba316d5eea6)), closes [#610](https://github.com/gotgenes/pi-packages/issues/610)
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** document the served forwarded decision broadcast ([fc2b00b](https://github.com/gotgenes/pi-packages/commit/fc2b00bb801468b450a725e0cb4e9da7e5279844))
20
+
8
21
  ## [26.2.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.1...pi-permission-system-v26.2.2) (2026-08-18)
9
22
 
10
23
 
package/README.md CHANGED
@@ -19,9 +19,9 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
19
19
  - **Gates MCP and skill access** at server, tool, and skill-name granularity
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
- - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
22
+ - **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
23
23
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
24
- - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
24
+ - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI, and every prompt it announces — including one forwarded up from a subagent — is answered by a `permissions:decision` on the same bus
25
25
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
26
26
 
27
27
  ## Install
package/dist/public.d.ts CHANGED
@@ -225,7 +225,9 @@ interface PermissionUiPromptEvent {
225
225
  forwarding: ForwardedPromptContext | null;
226
226
  }
227
227
  /** How a permission decision was reached. */
228
- type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable";
228
+ type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
229
+ /** The gate threw, or an escalation failed, and the request was blocked. */
230
+ | "gate_error";
229
231
  /** Payload emitted on `permissions:decision`. */
230
232
  interface PermissionDecisionEvent {
231
233
  /**
@@ -248,6 +250,16 @@ interface PermissionDecisionEvent {
248
250
  agentName: string | null;
249
251
  /** Matched pattern from the winning rule (when available). */
250
252
  matchedPattern: string | null;
253
+ /**
254
+ * Forwarding context for a decision this session made while serving another
255
+ * session's forwarded request; absent on an ordinary local decision.
256
+ *
257
+ * The same `ForwardedPromptContext` the request's `permissions:ui_prompt`
258
+ * carried, so a consumer that never saw the prompt can still tell a served
259
+ * ask from a local one. Requester identity beyond it — the requester's cwd
260
+ * and principal — stays off the bus.
261
+ */
262
+ forwarding?: ForwardedPromptContext | null;
251
263
  }
252
264
 
253
265
  /**
@@ -315,6 +315,7 @@ It is not a generic "permission request entered waiting state" event, and it doe
315
315
  Policy decisions that resolve without an active UI prompt, such as `policy_allow`, `policy_deny`, `session_approved`, `infrastructure_auto_allowed`, or `auto_approved`, do not emit this event.
316
316
  Non-UI child sessions also do not emit this event when they create a forwarded permission request; the parent UI session emits it immediately before showing the forwarded permission dialog.
317
317
  A forwarded request the parent's own recorded policy decides (a matching `allow` or `deny`) is answered without a prompt and emits no event; the event fires only when the parent is actually about to ask the human.
318
+ The matching terminal `permissions:decision` is emitted in the parent session too, so a consumer that reacts to this event has a signal on the same bus telling it the prompt is over.
318
319
  Forwarded prompts that do reach the human are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
319
320
 
320
321
  The payload is lean by design — `surface`/`value` are the normalized display projection a notification consumer reads, not a mirror of the internal review log.
@@ -393,7 +394,13 @@ The stability guarantee is additive, so any can be reintroduced in a later minor
393
394
  Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
394
395
  This is useful for dashboards, telemetry, or audit overlays.
395
396
 
397
+ A session serving another session's forwarded request emits one too, on its own bus, for every forwarded ask it escalates.
398
+ That is what makes a forwarded prompt clearable: the ask is gated in the requesting session — a different process for an out-of-process subagent — so without it the serving session broadcasts a `permissions:ui_prompt` whose outcome never appears.
399
+ A forwarded request the serving session's own policy allows or denies is answered without a prompt and broadcasts nothing, matching the UI-prompt channel.
400
+ A served decision carries a non-null `forwarding` context; the requesting session still emits its own decision when the answer comes back.
401
+
396
402
  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.
403
+ A request that reaches a prompt is answered by exactly one terminal event on that prompt's own bus, including when the dialog itself fails.
397
404
  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.
398
405
  Use the review log's `toolCallId` to join back to the Pi transcript.
399
406
 
@@ -407,16 +414,17 @@ pi.events.on("permissions:decision", (raw) => {
407
414
 
408
415
  ### Payload Fields
409
416
 
410
- | Field | Type | Description |
411
- | ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
412
- | `requestId` | `string` | Id of the permission request this decision resolves |
413
- | `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
414
- | `value` | `string` | Value evaluated (command, tool name, skill name, path) |
415
- | `result` | `"allow" \| "deny"` | Final outcome |
416
- | `resolution` | `string` | How the outcome was reached (see table below) |
417
- | `origin` | `string \| null` | Config scope that contributed the winning rule |
418
- | `agentName` | `string \| null` | Active agent name when known |
419
- | `matchedPattern` | `string \| null` | Pattern from the winning rule |
417
+ | Field | Type | Description |
418
+ | ---------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
419
+ | `requestId` | `string` | Id of the permission request this decision resolves |
420
+ | `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
421
+ | `value` | `string` | Value evaluated (command, tool name, skill name, path) |
422
+ | `result` | `"allow" \| "deny"` | Final outcome |
423
+ | `resolution` | `string` | How the outcome was reached (see table below) |
424
+ | `origin` | `string \| null` | Config scope that contributed the winning rule |
425
+ | `agentName` | `string \| null` | Active agent name when known |
426
+ | `matchedPattern` | `string \| null` | Pattern from the winning rule |
427
+ | `forwarding` | `ForwardedPromptContext \| null` (optional) | Requesting subagent, on a decision made while serving a forwarded request; absent on a local decision |
420
428
 
421
429
  ### Resolution Values
422
430
 
@@ -431,6 +439,7 @@ pi.events.on("permissions:decision", (raw) => {
431
439
  | `user_denied` | User denied via dialog |
432
440
  | `auto_approved` | Yolo mode — approved automatically without dialog |
433
441
  | `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
442
+ | `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
434
443
 
435
444
  ---
436
445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "26.2.2",
3
+ "version": "26.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,6 +14,11 @@ import {
14
14
  type PermissionForwardingLocation,
15
15
  } from "#src/authority/permission-forwarding";
16
16
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
17
+ import type { DecisionBroadcaster } from "#src/decision-reporter";
18
+ import type {
19
+ PermissionDecisionEvent,
20
+ PermissionDecisionResolution,
21
+ } from "#src/permission-events";
17
22
  import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
18
23
  import { SessionApproval } from "#src/session-approval";
19
24
  import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
@@ -70,6 +75,14 @@ export interface ForwardedRequestServerDeps {
70
75
  policy: ServingPolicy;
71
76
  /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
72
77
  escalator: AskEscalator;
78
+ /**
79
+ * Terminal-decision broadcast for an ask this session served.
80
+ *
81
+ * A forwarded ask is prompted here but gated in the requesting session — on
82
+ * another event bus for an out-of-process child — so without this the
83
+ * parent's own consumers observe a prompt that never ends (#610).
84
+ */
85
+ broadcaster: DecisionBroadcaster;
73
86
  /**
74
87
  * The serving session's `SessionRules`. Records a whole-session grant when a
75
88
  * human approves a forwarded request for the entire serving session.
@@ -143,6 +156,68 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
143
156
  };
144
157
  }
145
158
 
159
+ /**
160
+ * Build the terminal `permissions:decision` for an ask this session served.
161
+ *
162
+ * Rendered from the same {@link PromptPermissionDetails} the `ui_prompt`
163
+ * broadcast was built from, so prompt and decision carry one projection by
164
+ * construction rather than by convention — which is what makes them joinable
165
+ * beyond the shared request id.
166
+ *
167
+ * `origin` and `matchedPattern` are `null` by construction: an escalated
168
+ * request is one recorded authority did *not* decide, so no rule won. The
169
+ * decider stays off the bus, which discloses request facts and verdicts only
170
+ * (ADR 0011 §6, #726).
171
+ */
172
+ function buildServedDecisionEvent(
173
+ details: PromptPermissionDetails,
174
+ decision: PermissionPromptDecision,
175
+ ): PermissionDecisionEvent {
176
+ const facts = details.payload.request;
177
+ return {
178
+ requestId: details.requestId,
179
+ // The child's display projection, falling back to the payload's own facts
180
+ // for a version-skewed request that carried none. Both are non-nullable
181
+ // there, so the event's non-null contract holds without a sentinel.
182
+ surface: details.surface ?? facts.surface,
183
+ value: details.value ?? facts.value,
184
+ agentName: details.agentName,
185
+ result: decision.approved ? "allow" : "deny",
186
+ resolution: servedResolution(decision),
187
+ origin: null,
188
+ matchedPattern: null,
189
+ forwarding: details.forwarding ?? null,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Name how a served ask resolved, reading the decision's own stamp rather than
195
+ * re-deriving it from the outcome: the site that decided already recorded what
196
+ * it was (#726).
197
+ *
198
+ * The grant scope is reported as the human chose it. {@link applyGrantScope}
199
+ * rewrites a whole-serving-session grant to a plain approval on the wire, but
200
+ * that translation is about what the *child* records, not about what was
201
+ * allowed here.
202
+ */
203
+ function servedResolution(
204
+ decision: PermissionPromptDecision,
205
+ ): PermissionDecisionResolution {
206
+ if (decision.decidedBy.kind === "gate_error") {
207
+ return "gate_error";
208
+ }
209
+ if (decision.confirmationUnavailable) {
210
+ return "confirmation_unavailable";
211
+ }
212
+ if (!decision.approved) {
213
+ return "user_denied";
214
+ }
215
+ return decision.state === "approved_for_session" ||
216
+ decision.state === "approved_for_serving_session"
217
+ ? "user_approved_for_session"
218
+ : "user_approved";
219
+ }
220
+
146
221
  // ── ForwardedRequestServer ────────────────────────────────────────────────
147
222
 
148
223
  /**
@@ -158,6 +233,7 @@ export class ForwardedRequestServer implements InboxProcessor {
158
233
  private readonly logger: DebugReviewLogger;
159
234
  private readonly policy: ServingPolicy;
160
235
  private readonly escalator: AskEscalator;
236
+ private readonly broadcaster: DecisionBroadcaster;
161
237
  private readonly recorder: SessionApprovalRecorder;
162
238
  private readonly registry: SubagentSessionRegistry | undefined;
163
239
 
@@ -166,6 +242,7 @@ export class ForwardedRequestServer implements InboxProcessor {
166
242
  this.logger = deps.logger;
167
243
  this.policy = deps.policy;
168
244
  this.escalator = deps.escalator;
245
+ this.broadcaster = deps.broadcaster;
169
246
  this.recorder = deps.recorder;
170
247
  this.registry = deps.registry;
171
248
  }
@@ -406,16 +483,35 @@ export class ForwardedRequestServer implements InboxProcessor {
406
483
  }
407
484
 
408
485
  this.logger.review("forwarded_permission.prompted", logDetails);
486
+ const details = buildForwardedAskDetails(request);
487
+ const decision = await this.escalateAsk(details);
488
+ // Announced before the grant-scope translation and before the response is
489
+ // written: the ask this session broadcast is over once someone here has
490
+ // answered it, whatever becomes of the file the child polls for (#610).
491
+ this.broadcaster.emitDecision(buildServedDecisionEvent(details, decision));
492
+ return decision;
493
+ }
494
+
495
+ /**
496
+ * Escalate a forwarded ask to the serving session's selected `Authorizer`,
497
+ * failing closed instead of throwing: an escalation that breaks is nobody's
498
+ * denial, so the node records itself as the decider.
499
+ *
500
+ * Separate from {@link resolveDecision} so the ask's details outlive the
501
+ * call — every record of the served ask is a render over that one object.
502
+ */
503
+ private async escalateAsk(
504
+ details: PromptPermissionDetails,
505
+ ): Promise<PermissionPromptDecision> {
409
506
  try {
410
- return await this.escalator.escalate(buildForwardedAskDetails(request));
507
+ return await this.escalator.escalate(details);
411
508
  } catch (error) {
412
509
  const reason = formatUnknownErrorMessage(error);
413
510
  logPermissionForwardingError(
414
511
  this.logger,
415
- `Failed to escalate forwarded permission request '${request.id}'`,
512
+ `Failed to escalate forwarded permission request '${details.requestId}'`,
416
513
  error,
417
514
  );
418
- // Nobody denied this; the escalation broke and the node failed closed.
419
515
  return {
420
516
  approved: false,
421
517
  state: "denied",
@@ -5,14 +5,25 @@ import {
5
5
  } from "./permission-events";
6
6
  import type { SessionLogger } from "./session-logger";
7
7
 
8
+ /**
9
+ * Broadcasts a terminal permission decision on the `permissions:decision`
10
+ * channel.
11
+ *
12
+ * Narrow by design (ISP): a collaborator that only announces an outcome — the
13
+ * serving session answering another session's forwarded request — depends on
14
+ * this rather than on the review-log half it never writes.
15
+ */
16
+ export interface DecisionBroadcaster {
17
+ emitDecision(event: PermissionDecisionEvent): void;
18
+ }
19
+
8
20
  /**
9
21
  * Reports a permission gate's outcome to the review log and the decision
10
22
  * channel. Groups the two side effects that always travel together:
11
23
  * writing a structured review-log entry and broadcasting a decision event.
12
24
  */
13
- export interface DecisionReporter {
25
+ export interface DecisionReporter extends DecisionBroadcaster {
14
26
  writeReviewLog(event: string, details: Record<string, unknown>): void;
15
- emitDecision(event: PermissionDecisionEvent): void;
16
27
  }
17
28
 
18
29
  /**
@@ -66,7 +66,12 @@ export function createFailClosedToolCall(
66
66
  * The block below this must be reached: the SDK does not catch a throwing
67
67
  * handler, so an exception escaping the recording work would leave the command
68
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.
69
+ * have come from anywhere in the pipeline, so no gate's id is available — and
70
+ * shared by both records, so the terminal broadcast joins the review entry.
71
+ *
72
+ * The broadcast carries what the raw event yields and nothing inferred: no
73
+ * rule won, and the boundary holds no session, so `origin`, `matchedPattern`,
74
+ * and `agentName` are `null` (#753).
70
75
  */
71
76
  function recordGateError(
72
77
  reporter: DecisionReporter,
@@ -77,15 +82,28 @@ function recordGateError(
77
82
  try {
78
83
  audit.recordError();
79
84
  const reason = errorMessage(error);
85
+ const requestId = createPermissionRequestId();
86
+ const toolName = bestEffortToolName(event);
87
+ const command = bestEffortCommand(event);
80
88
  reporter.writeReviewLog("permission_request.blocked", {
81
- requestId: createPermissionRequestId(),
82
- toolName: bestEffortToolName(event),
83
- command: bestEffortCommand(event),
89
+ requestId,
90
+ toolName,
91
+ command,
84
92
  resolution: "gate_error",
85
93
  error: reason,
86
94
  // The boundary decided, by failing closed -- no rule and no human did.
87
95
  decidedBy: { kind: "gate_error", reason },
88
96
  });
97
+ reporter.emitDecision({
98
+ requestId,
99
+ surface: toolName,
100
+ value: command ?? toolName,
101
+ result: "deny",
102
+ resolution: "gate_error",
103
+ origin: null,
104
+ agentName: null,
105
+ matchedPattern: null,
106
+ });
89
107
  } catch {
90
108
  // The block is the guarantee; its bookkeeping is not.
91
109
  }
package/src/index.ts CHANGED
@@ -181,11 +181,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
181
181
  ),
182
182
  };
183
183
 
184
+ // Constructed here rather than beside the gate runner below: the serving
185
+ // side broadcasts its own decisions, so both readers share one reporter over
186
+ // this session's event bus.
187
+ const reporter = new GateDecisionReporter(logger, pi.events);
188
+
184
189
  const requestServer = new ForwardedRequestServer({
185
190
  forwardingDir: paths.forwardingDir,
186
191
  logger,
187
192
  policy: servingPolicy,
188
193
  escalator: authorizerSelection,
194
+ // The forwarded ask's own gate lives in the requesting session, so the
195
+ // serving side announces the terminal decision on this session's bus.
196
+ broadcaster: reporter,
189
197
  // Records a whole-session grant into the same SessionRules the resolver and
190
198
  // gate runner read, so a serving-scope grant governs the parent and future
191
199
  // forwarded resolutions.
@@ -275,7 +283,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
275
283
  },
276
284
  );
277
285
 
278
- const reporter = new GateDecisionReporter(logger, pi.events);
279
286
  const gateRunner = new GateRunner(
280
287
  resolver,
281
288
  sessionRules,
@@ -110,7 +110,9 @@ export type PermissionDecisionResolution =
110
110
  | "user_approved_for_session"
111
111
  | "user_denied"
112
112
  | "auto_approved"
113
- | "confirmation_unavailable";
113
+ | "confirmation_unavailable"
114
+ /** The gate threw, or an escalation failed, and the request was blocked. */
115
+ | "gate_error";
114
116
 
115
117
  /** Payload emitted on `permissions:decision`. */
116
118
  export interface PermissionDecisionEvent {
@@ -134,6 +136,16 @@ export interface PermissionDecisionEvent {
134
136
  agentName: string | null;
135
137
  /** Matched pattern from the winning rule (when available). */
136
138
  matchedPattern: string | null;
139
+ /**
140
+ * Forwarding context for a decision this session made while serving another
141
+ * session's forwarded request; absent on an ordinary local decision.
142
+ *
143
+ * The same `ForwardedPromptContext` the request's `permissions:ui_prompt`
144
+ * carried, so a consumer that never saw the prompt can still tell a served
145
+ * ask from a local one. Requester identity beyond it — the requester's cwd
146
+ * and principal — stays off the bus.
147
+ */
148
+ forwarding?: ForwardedPromptContext | null;
137
149
  }
138
150
 
139
151
  // ── Emit helpers ───────────────────────────────────────────────────────────