@gotgenes/pi-permission-system 26.2.1 → 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,26 @@ 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
+
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)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * **pi-permission-system:** derive session-approval patterns through the injected PathFlavor ([cf561de](https://github.com/gotgenes/pi-packages/commit/cf561de4c19895ed9495d82f22280fe3ba215aa5)), closes [#655](https://github.com/gotgenes/pi-packages/issues/655)
27
+
8
28
  ## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
9
29
 
10
30
 
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.1",
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
  /**
@@ -1,8 +1,8 @@
1
1
  import type { BashProgram } from "#src/access-intent/bash/program";
2
+ import type { PathNormalizer } from "#src/path-normalizer";
2
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
4
  import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
4
5
  import { SessionApproval } from "#src/session-approval";
5
- import { deriveApprovalPattern } from "#src/session-rules";
6
6
  import type { GateResult } from "./descriptor";
7
7
  import { selectUncoveredExternalPaths } from "./external-directory-policy";
8
8
  import { accessFactsFromPath } from "./helpers";
@@ -25,6 +25,7 @@ export function describeBashExternalDirectoryGate(
25
25
  tcc: ToolCallContext,
26
26
  bashProgram: BashProgram | null,
27
27
  resolver: ScopedPermissionResolver,
28
+ normalizer: PathNormalizer,
28
29
  ): GateResult {
29
30
  if (!bashProgram) return null;
30
31
  const command = bashProgram.commandText();
@@ -94,7 +95,9 @@ export function describeBashExternalDirectoryGate(
94
95
  matchedPattern: preCheck.matchedPattern,
95
96
  });
96
97
 
97
- const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
98
+ const patterns = uncoveredEntries.map(({ path }) =>
99
+ normalizer.approvalPatternFor(path),
100
+ );
98
101
 
99
102
  return {
100
103
  surface: "external_directory",
@@ -1,9 +1,9 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
2
  import type { BashProgram } from "#src/access-intent/bash/program";
3
+ import type { PathNormalizer } from "#src/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
5
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
5
6
  import { SessionApproval } from "#src/session-approval";
6
- import { deriveApprovalPattern } from "#src/session-rules";
7
7
  import type { PermissionCheckResult } from "#src/types";
8
8
  import { pickMostRestrictive } from "./candidate-check";
9
9
  import type { GateResult } from "./descriptor";
@@ -33,6 +33,7 @@ export function describeBashPathGate(
33
33
  tcc: ToolCallContext,
34
34
  bashProgram: BashProgram | null,
35
35
  resolver: ScopedPermissionResolver,
36
+ normalizer: PathNormalizer,
36
37
  ): GateResult {
37
38
  if (!bashProgram) return null;
38
39
  const command = bashProgram.commandText();
@@ -120,7 +121,7 @@ export function describeBashPathGate(
120
121
  // Derive the pattern from the lexical absolute form (the cd-aware resolved
121
122
  // path), so it matches the values a later call produces. For an unknown base
122
123
  // (`forLiteral`) `value()` is the raw token.
123
- const pattern = deriveApprovalPattern(worstEntry.path.value());
124
+ const pattern = normalizer.approvalPatternFor(worstEntry.path);
124
125
  const payload = buildPathAskPayload({
125
126
  toolName: tcc.toolName,
126
127
  pathValue: worstToken,
@@ -3,7 +3,6 @@ import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
5
5
  import { SessionApproval } from "#src/session-approval";
6
- import { deriveApprovalPattern } from "#src/session-rules";
7
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
7
  import type { GateResult } from "./descriptor";
9
8
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
@@ -78,7 +77,7 @@ export function describeExternalDirectoryGate(
78
77
  resolver,
79
78
  tcc.agentName ?? undefined,
80
79
  );
81
- const pattern = deriveApprovalPattern(accessPath.value());
80
+ const pattern = normalizer.approvalPatternFor(accessPath);
82
81
 
83
82
  const payload = buildExternalDirectoryAskPayload({
84
83
  toolName: tcc.toolName,
@@ -3,7 +3,6 @@ import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
4
4
  import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
5
5
  import { SessionApproval } from "#src/session-approval";
6
- import { deriveApprovalPattern } from "#src/session-rules";
7
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
7
  import type { GateDescriptor, GateResult } from "./descriptor";
9
8
  import { accessFactsFromPath } from "./helpers";
@@ -46,7 +45,7 @@ export function describePathGate(
46
45
 
47
46
  // Derive the approval pattern from the lexical absolute form so it matches
48
47
  // the policy values a later call produces.
49
- const pattern = deriveApprovalPattern(accessPath.value());
48
+ const pattern = normalizer.approvalPatternFor(accessPath);
50
49
 
51
50
  const payload = buildPathAskPayload({
52
51
  toolName: tcc.toolName,
@@ -1,4 +1,3 @@
1
- import type { AccessPath } from "#src/access-intent/access-path";
2
1
  import { BashProgram } from "#src/access-intent/bash/program";
3
2
  import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
4
3
  import {
@@ -24,7 +23,7 @@ import { describeExternalDirectoryGate } from "./external-directory";
24
23
  import { describePathGate } from "./path";
25
24
  import type { GateRunner } from "./runner";
26
25
  import { describeSkillReadGate } from "./skill-read";
27
- import { describeToolGate } from "./tool";
26
+ import { describeToolGate, type ToolPathAccess } from "./tool";
28
27
  import type { GateOutcome, ToolCallContext } from "./types";
29
28
 
30
29
  /**
@@ -115,10 +114,16 @@ export class ToolCallGatePipeline {
115
114
  normalizer,
116
115
  this.customExtractors,
117
116
  ),
118
- () => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
119
- () => describeBashPathGate(tcc, bashProgram, this.resolver),
117
+ () =>
118
+ describeBashExternalDirectoryGate(
119
+ tcc,
120
+ bashProgram,
121
+ this.resolver,
122
+ normalizer,
123
+ ),
124
+ () => describeBashPathGate(tcc, bashProgram, this.resolver, normalizer),
120
125
  () => {
121
- const { toolCheck, accessPath } = this.resolvePerToolCheck(
126
+ const { toolCheck, pathAccess } = this.resolvePerToolCheck(
122
127
  tcc,
123
128
  shell,
124
129
  bashProgram,
@@ -128,7 +133,7 @@ export class ToolCallGatePipeline {
128
133
  tcc,
129
134
  toolCheck,
130
135
  formatter,
131
- accessPath,
136
+ pathAccess,
132
137
  shell,
133
138
  );
134
139
  toolDescriptor.preCheck = toolCheck;
@@ -153,15 +158,16 @@ export class ToolCallGatePipeline {
153
158
  * #502); every other tool (and a path-bearing tool with no path) keeps the
154
159
  * raw `tool` intent the manager normalizes.
155
160
  *
156
- * Returns the `AccessPath` alongside the check so `describeToolGate` derives
157
- * the session-approval value from `accessPath.value()`.
161
+ * Returns the resolved path alongside the check, already paired with the
162
+ * session scope approving it grants — derived here, where the normalizer
163
+ * lives, rather than inside the gate (#655).
158
164
  */
159
165
  private resolvePerToolCheck(
160
166
  tcc: ToolCallContext,
161
167
  shell: ShellInvocation | null,
162
168
  bashProgram: BashProgram | null,
163
169
  normalizer: PathNormalizer,
164
- ): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
170
+ ): { toolCheck: PermissionCheckResult; pathAccess?: ToolPathAccess } {
165
171
  if (shell) {
166
172
  if (bashProgram) {
167
173
  return {
@@ -190,7 +196,10 @@ export class ToolCallGatePipeline {
190
196
  if (filePath !== null) {
191
197
  const accessPath = normalizer.forPath(filePath);
192
198
  return {
193
- accessPath,
199
+ pathAccess: {
200
+ path: accessPath,
201
+ approvalPattern: normalizer.approvalPatternFor(accessPath),
202
+ },
194
203
  toolCheck: this.resolver.resolve({
195
204
  kind: "access-path",
196
205
  surface: tcc.toolName,
@@ -5,7 +5,10 @@ import {
5
5
  classifyToolKind,
6
6
  type ShellInvocation,
7
7
  } from "#src/access-intent/tool-kind";
8
- import { suggestSessionPattern } from "#src/pattern-suggest";
8
+ import {
9
+ suggestPathSessionPattern,
10
+ suggestSessionPattern,
11
+ } from "#src/pattern-suggest";
9
12
  import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
10
13
  import { SessionApproval } from "#src/session-approval";
11
14
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
@@ -18,18 +21,30 @@ import {
18
21
  } from "./helpers";
19
22
  import type { ToolCallContext } from "./types";
20
23
 
24
+ /**
25
+ * A path-bearing tool call's resolved path, paired with the session scope
26
+ * approving it would grant.
27
+ *
28
+ * The pattern is derived by the pipeline's `PathNormalizer`, which owns the
29
+ * session's `PathFlavor`, rather than re-derived here from `path.value()` — so
30
+ * the gate carries the platform's separator semantics without holding them
31
+ * (#655).
32
+ */
33
+ export interface ToolPathAccess {
34
+ readonly path: AccessPath;
35
+ readonly approvalPattern: string;
36
+ }
37
+
21
38
  /**
22
39
  * Derive the value used for session-approval pattern suggestions.
23
40
  *
24
- * Bash → command string; MCP → qualified target;
25
- * path-bearing tools the `AccessPath`'s lexical absolute form (`value()`),
26
- * so the suggested pattern matches the policy values a later call produces;
27
- * others (or a path-bearing tool with no path) → catch-all wildcard.
41
+ * Bash → command string; MCP → qualified target; everything else → catch-all
42
+ * wildcard. A path-bearing tool that resolved a path never reaches here — its
43
+ * suggestion comes from the already-derived {@link ToolPathAccess} pattern.
28
44
  */
29
45
  function deriveSuggestionValue(
30
46
  toolName: string,
31
47
  check: PermissionCheckResult,
32
- accessPath?: AccessPath,
33
48
  ): string {
34
49
  switch (classifyToolKind(toolName)) {
35
50
  case "bash":
@@ -37,7 +52,7 @@ function deriveSuggestionValue(
37
52
  case "mcp":
38
53
  return check.target ?? "mcp";
39
54
  default:
40
- return accessPath ? accessPath.value() : "*";
55
+ return "*";
41
56
  }
42
57
  }
43
58
 
@@ -51,7 +66,7 @@ export function describeToolGate(
51
66
  tcc: ToolCallContext,
52
67
  check: PermissionCheckResult,
53
68
  formatter: ToolPreviewFormatter,
54
- accessPath?: AccessPath,
69
+ pathAccess?: ToolPathAccess,
55
70
  shell?: ShellInvocation | null,
56
71
  ): GateDescriptor {
57
72
  // A shell invocation (native `bash` or an aliased shell tool) is gated on the
@@ -67,10 +82,12 @@ export function describeToolGate(
67
82
  );
68
83
 
69
84
  // Compute session approval suggestion for the "for this session" option.
70
- const suggestion = suggestSessionPattern(
71
- gateSurface,
72
- deriveSuggestionValue(gateSurface, check, accessPath),
73
- );
85
+ const suggestion = pathAccess
86
+ ? suggestPathSessionPattern(gateSurface, pathAccess.approvalPattern)
87
+ : suggestSessionPattern(
88
+ gateSurface,
89
+ deriveSuggestionValue(gateSurface, check),
90
+ );
74
91
 
75
92
  const payload = buildToolAskPayload({
76
93
  check,
@@ -89,8 +106,8 @@ export function describeToolGate(
89
106
 
90
107
  // A path-bearing tool carries the AccessPath's alias set; every other surface
91
108
  // (bash command, MCP target, plain tool) carries its already-portable value.
92
- const accessIntent = accessPath
93
- ? accessFactsFromPath(gateSurface, accessPath)
109
+ const accessIntent = pathAccess
110
+ ? accessFactsFromPath(gateSurface, pathAccess.path)
94
111
  : accessFactsFromValue(gateSurface, decisionValue);
95
112
 
96
113
  return {
@@ -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,
@@ -0,0 +1,27 @@
1
+ import type { PathFlavor } from "#src/path/path-flavor";
2
+
3
+ /**
4
+ * Derive the wildcard glob to record when a user approves an accessed path for
5
+ * the session: the path's directory scope, with `*` appended.
6
+ *
7
+ * The scope is the value up to and including its last path separator, so the
8
+ * pattern is spelled with the separator the value itself carries. That matters
9
+ * on a win32 host, where Git Bash tokens are POSIX-shaped while Node's own
10
+ * `sep` is a backslash: deriving `/tmp/logs\*` from `/tmp/logs/` widens the
11
+ * grant to the parent directory once the `windowsSeparators` fold (#653)
12
+ * normalizes both operands. A value carrying no separator falls back to the
13
+ * current directory, which is what callers see only if they skipped resolving
14
+ * the path to its absolute form first (#438).
15
+ *
16
+ * The platform's separator alphabet arrives as an injected {@link PathFlavor},
17
+ * never an ambient `node:path` read, so win32 derivation is decidable — and
18
+ * testable — on a POSIX host (#655).
19
+ */
20
+ export function deriveApprovalPattern(
21
+ pathValue: string,
22
+ flavor: PathFlavor,
23
+ ): string {
24
+ const lastSeparator = flavor.lastSeparatorIndex(pathValue);
25
+ if (lastSeparator < 0) return `.${flavor.impl.sep}*`;
26
+ return `${pathValue.slice(0, lastSeparator + 1)}*`;
27
+ }
@@ -17,7 +17,8 @@ import type { WildcardMatchOptions } from "#src/wildcard-matcher";
17
17
  * fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
18
18
  * captures that mapping once so the leaves consume the resolved capability
19
19
  * instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
20
- * **semantics** — syntax ({@link hasPathSeparator}), token shape
20
+ * **semantics** — syntax ({@link hasPathSeparator} /
21
+ * {@link lastSeparatorIndex}), token shape
21
22
  * ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
22
23
  * {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
23
24
  * (lexical cleanup, alias generation, safe-system-path exclusions, rule
@@ -50,6 +51,15 @@ export interface PathFlavor {
50
51
  * POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
51
52
  */
52
53
  hasPathSeparator(token: string): boolean;
54
+ /**
55
+ * Index of the last path separator in `value`, or `-1` when it holds none.
56
+ *
57
+ * Reads the same separator alphabet as {@link hasPathSeparator}, so a caller
58
+ * that must split a path at its directory boundary uses the separator the
59
+ * value was written with rather than this platform's default `sep` — the two
60
+ * differ for a Git Bash token on a win32 host (`/dev/null`, `/tmp/logs/`).
61
+ */
62
+ lastSeparatorIndex(value: string): number;
53
63
  /**
54
64
  * The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
55
65
  * carries device / drive-mount / posix-absolute / plain semantics; on POSIX
@@ -60,6 +70,8 @@ export interface PathFlavor {
60
70
 
61
71
  class PlatformPathFlavor implements PathFlavor {
62
72
  readonly matchOptions: WildcardMatchOptions | undefined;
73
+ /** Every separator spelling this platform recognizes, the one alphabet both separator answers read. */
74
+ private readonly separators: readonly string[];
63
75
 
64
76
  constructor(
65
77
  readonly impl: PlatformPath,
@@ -68,6 +80,7 @@ class PlatformPathFlavor implements PathFlavor {
68
80
  this.matchOptions = windows
69
81
  ? { caseInsensitive: true, windowsSeparators: true }
70
82
  : undefined;
83
+ this.separators = windows ? ["/", "\\"] : ["/"];
71
84
  }
72
85
 
73
86
  fold(value: string): string {
@@ -91,7 +104,14 @@ class PlatformPathFlavor implements PathFlavor {
91
104
  }
92
105
 
93
106
  hasPathSeparator(token: string): boolean {
94
- return token.includes("/") || (this.windows && token.includes("\\"));
107
+ return this.lastSeparatorIndex(token) >= 0;
108
+ }
109
+
110
+ lastSeparatorIndex(value: string): number {
111
+ return this.separators.reduce(
112
+ (last, separator) => Math.max(last, value.lastIndexOf(separator)),
113
+ -1,
114
+ );
95
115
  }
96
116
 
97
117
  bashTokenShape(token: string): BashTokenShape {
@@ -8,6 +8,7 @@ import {
8
8
  normalizePathForComparison,
9
9
  normalizePathPolicyLiteral,
10
10
  } from "./access-intent/path-normalization";
11
+ import { deriveApprovalPattern } from "./path/approval-pattern";
11
12
  import { isPathOutsideWorkingDirectory } from "./path/path-containment";
12
13
  import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
13
14
 
@@ -99,6 +100,20 @@ export class PathNormalizer {
99
100
  }
100
101
  }
101
102
 
103
+ /**
104
+ * The session-approval glob for an accessed path: its directory scope plus
105
+ * `*`, derived through the baked flavor.
106
+ *
107
+ * Takes the already-built {@link AccessPath} — the lexical form is what a
108
+ * later tool call is matched on, so the pattern must be derived from the
109
+ * same representation the decision displayed (#438). Deriving it here rather
110
+ * than at each gate keeps the platform's separator alphabet with the object
111
+ * that owns the flavor, instead of an ambient `node:path` read (#655).
112
+ */
113
+ approvalPatternFor(accessPath: AccessPath): string {
114
+ return deriveApprovalPattern(accessPath.value(), this.flavor);
115
+ }
116
+
102
117
  /** Platform-aware absoluteness (`win32` vs `posix` rules). */
103
118
  isAbsolute(pathValue: string): boolean {
104
119
  return this.flavor.impl.isAbsolute(pathValue);
@@ -1,6 +1,5 @@
1
1
  import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
2
2
  import { prefix, stripBashCommentLines } from "./bash-arity";
3
- import { deriveApprovalPattern } from "./session-rules";
4
3
 
5
4
  /** The suggestion returned for a "Yes, for this session" dialog option. */
6
5
  export interface SessionApprovalSuggestion {
@@ -115,14 +114,16 @@ function buildLabel(pattern: string, surface: string): string {
115
114
  }
116
115
 
117
116
  /**
118
- * Suggest a session-approval pattern for the given permission surface and value.
117
+ * Suggest a session-approval pattern from a surface's own value vocabulary
118
+ * a bash command, an MCP target, a skill name.
119
119
  *
120
120
  * Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
121
- * to store in `SessionRules`, and a human-readable dialog label.
121
+ * to store in `SessionRules`, and a human-readable dialog label. Any surface
122
+ * with no vocabulary of its own falls back to the catch-all wildcard, which is
123
+ * also what a path-bearing tool invoked without a path resolves to.
122
124
  *
123
- * `value` is expected to be the canonical (cwd-resolved, absolute) path for
124
- * path surfaces callers resolve it before suggesting, so the derived pattern
125
- * matches the policy values a later tool call produces.
125
+ * A path surface goes through {@link suggestPathSessionPattern} instead: its
126
+ * pattern is a path-language product, and this module holds no path semantics.
126
127
  */
127
128
  export function suggestSessionPattern(
128
129
  surface: string,
@@ -140,22 +141,30 @@ export function suggestSessionPattern(
140
141
  case "skill":
141
142
  pattern = value;
142
143
  break;
143
- case "external_directory":
144
- pattern = deriveApprovalPattern(value);
145
- break;
146
- case "path":
147
- pattern = deriveApprovalPattern(value);
148
- break;
149
144
  default:
150
- // Path-bearing tools: derive a directory-scoped pattern from the path.
151
- if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
152
- pattern = deriveApprovalPattern(value);
153
- break;
154
- }
155
- // Extension tools / fallback.
145
+ // Extension tools, and path-bearing tools invoked without a path.
156
146
  pattern = "*";
157
147
  break;
158
148
  }
159
149
 
160
150
  return { surface, pattern, label: buildLabel(pattern, surface) };
161
151
  }
152
+
153
+ /**
154
+ * Build the suggestion for a path surface from a pattern the caller already
155
+ * derived through its `PathNormalizer` (#655).
156
+ *
157
+ * The derivation belongs to the normalizer, which owns the session's
158
+ * `PathFlavor`; this module labels the result and must not re-interpret the
159
+ * separators it is handed.
160
+ */
161
+ export function suggestPathSessionPattern(
162
+ surface: string,
163
+ approvalPattern: string,
164
+ ): SessionApprovalSuggestion {
165
+ return {
166
+ surface,
167
+ pattern: approvalPattern,
168
+ label: buildLabel(approvalPattern, surface),
169
+ };
170
+ }
@@ -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 ───────────────────────────────────────────────────────────
@@ -1,5 +1,3 @@
1
- import { dirname, sep } from "node:path";
2
-
3
1
  import type { Ruleset } from "./rule";
4
2
  import type { SessionApproval } from "./session-approval";
5
3
  import type { SessionApprovalRecorder } from "./session-approval-recorder";
@@ -48,32 +46,3 @@ export class SessionRules implements SessionApprovalRecorder {
48
46
  this.rules = [];
49
47
  }
50
48
  }
51
-
52
- /**
53
- * Derive the wildcard glob pattern to approve from a normalized path.
54
- *
55
- * Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
56
- * all paths under the approved directory — identical semantics to the former
57
- * `SessionApprovalCache` prefix matching, using the unified wildcard engine.
58
- *
59
- * For paths that already end with a separator (directories), the separator
60
- * is treated as the directory boundary and `*` is appended directly.
61
- *
62
- * The path is expected to be the canonical (cwd-resolved, absolute) form used
63
- * for policy matching, so the derived pattern matches the same policy values a
64
- * later tool call produces. Callers that hold a working directory resolve the
65
- * path to that form first; the function itself stays free of cwd state.
66
- */
67
- export function deriveApprovalPattern(normalizedPath: string): string {
68
- // If the path already ends with a separator, it's a directory — glob its contents.
69
- if (normalizedPath.endsWith(sep)) {
70
- return `${normalizedPath}*`;
71
- }
72
- const dir = dirname(normalizedPath);
73
- if (dir === normalizedPath) {
74
- // Root path — dirname('/') === '/'
75
- return `${dir}*`;
76
- }
77
- const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
78
- return `${prefix}*`;
79
- }