@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 +13 -0
- package/README.md +2 -2
- package/dist/public.d.ts +13 -1
- package/docs/cross-extension-api.md +19 -10
- package/package.json +1 -1
- package/src/authority/forwarded-request-server.ts +99 -3
- package/src/decision-reporter.ts +13 -2
- package/src/handlers/tool-call-boundary.ts +22 -4
- package/src/index.ts +8 -1
- package/src/permission-events.ts +13 -1
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
|
|
411
|
-
| ---------------- |
|
|
412
|
-
| `requestId` | `string`
|
|
413
|
-
| `surface` | `string`
|
|
414
|
-
| `value` | `string`
|
|
415
|
-
| `result` | `"allow" \| "deny"`
|
|
416
|
-
| `resolution` | `string`
|
|
417
|
-
| `origin` | `string \| null`
|
|
418
|
-
| `agentName` | `string \| null`
|
|
419
|
-
| `matchedPattern` | `string \| null`
|
|
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
|
@@ -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(
|
|
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 '${
|
|
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",
|
package/src/decision-reporter.ts
CHANGED
|
@@ -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
|
|
82
|
-
toolName
|
|
83
|
-
command
|
|
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,
|
package/src/permission-events.ts
CHANGED
|
@@ -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 ───────────────────────────────────────────────────────────
|