@gotgenes/pi-permission-system 26.0.0 → 26.2.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,39 @@ 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.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.1.0...pi-permission-system-v26.2.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** publish a serving heartbeat while draining the inbox ([2055e35](https://github.com/gotgenes/pi-packages/commit/2055e353e147af6738ddc23c2c755f56f8032f8b)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **pi-permission-system:** fail fast when an out-of-process parent is not serving ([e190af9](https://github.com/gotgenes/pi-packages/commit/e190af9f06c49375432f19bb228e068ade01c912)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
19
+
20
+
21
+ ### Documentation
22
+
23
+ * **pi-permission-system:** document out-of-process forwarding liveness ([b5a50d5](https://github.com/gotgenes/pi-packages/commit/b5a50d535c97ca0ec388c5f482afb395e15289fa)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
24
+
25
+ ## [26.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.0.0...pi-permission-system-v26.1.0) (2026-08-17)
26
+
27
+
28
+ ### Features
29
+
30
+ * **pi-permission-system:** attribute absent-authority denials ([225412d](https://github.com/gotgenes/pi-packages/commit/225412d485e68e886b6e958b2f53a87f9ff6cf25)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
31
+ * **pi-permission-system:** carry decision provenance across the forwarding boundary ([0dbf13f](https://github.com/gotgenes/pi-packages/commit/0dbf13f86c9f2d070e8b33eb4ffe808331c9185c)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
32
+ * **pi-permission-system:** name the authorizer link that decided an ask ([8556724](https://github.com/gotgenes/pi-packages/commit/8556724e5453ee002b24c48bebaf4d96f6502fe5)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
33
+ * **pi-permission-system:** record the decider on non-prompting resolutions ([5e24abf](https://github.com/gotgenes/pi-packages/commit/5e24abf6311d13928858e730b14a712e155f2bc2)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
34
+ * **pi-permission-system:** record the human decider on prompted decisions ([9f540ba](https://github.com/gotgenes/pi-packages/commit/9f540ba285b546976432733fced08b9fe9dc973c)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
35
+
36
+
37
+ ### Documentation
38
+
39
+ * **pi-permission-system:** record decision provenance and mark Phase 13 Step 6 complete ([e2739e7](https://github.com/gotgenes/pi-packages/commit/e2739e7f188669ee4878bbe3732958d4e1c54cbd)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
40
+
8
41
  ## [26.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.4.0...pi-permission-system-v26.0.0) (2026-08-16)
9
42
 
10
43
 
@@ -97,20 +97,20 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
97
97
 
98
98
  ## Runtime Knobs
99
99
 
100
- | Key | Default | Description |
101
- | --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
- | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
103
- | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
104
- | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
105
- | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
106
- | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose in-process parent is not draining its inbox gives up in ~2 s regardless. |
107
- | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
108
- | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
109
- | `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
110
- | `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
111
- | `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
112
- | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
113
- | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
100
+ | Key | Default | Description |
101
+ | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
103
+ | `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
104
+ | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
105
+ | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
106
+ | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
107
+ | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
108
+ | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
109
+ | `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
110
+ | `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
111
+ | `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
112
+ | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
113
+ | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
114
114
 
115
115
  Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
116
116
  No debug output is printed to the terminal.
@@ -35,7 +35,20 @@ For in-process child sessions, detection and forwarding use the event-driven reg
35
35
  ### When nobody answers
36
36
 
37
37
  A forwarded request is only useful if some session is draining the inbox it was written into.
38
- The polling session publishes the session id it polls, and an in-process child checks that its target is published before committing to a long wait.
38
+ The polling session publishes the session id it polls, and a child checks that its target is published before committing to a long wait.
39
+
40
+ The announcement goes out on two channels, because a child cannot always reach the same one.
41
+ A child running inside its parent's process reads a process-global registry.
42
+ A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
43
+
44
+ For an out-of-process target, four things count as "not draining":
45
+
46
+ | What the child finds | What it means |
47
+ | ------------------------------------------------- | ---------------------------------------------------------------------------- |
48
+ | No record | The parent exited, never served, or runs a version that does not publish one |
49
+ | A record naming a process that is gone | The parent was killed rather than shut down |
50
+ | A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
51
+ | A record for a different session id | The child is forwarding somewhere nobody is listening |
39
52
 
40
53
  If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
41
54
 
@@ -47,6 +60,7 @@ permission requests.
47
60
 
48
61
  The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
49
62
  A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
63
+ That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
50
64
 
51
65
  Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
52
66
  None of them is reported as a user denial, because no user was ever asked.
@@ -54,8 +68,27 @@ None of them is reported as a user denial, because no user was ever asked.
54
68
  The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
55
69
  When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
56
70
 
57
- This liveness signal is process-local, so it applies to in-process children only.
58
- A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) cannot observe its parent's polling and still waits the full timeout.
71
+ When a forwarded request *is* answered, the child's own terminal entry names both which session answered and what within it decided.
72
+ The serving node records its decider on the response — a rule of its own (with the surface, pattern, and origin that matched), the link that ruled, or the human who answered its dialog and the child records it nested under a `forwarded` frame:
73
+
74
+ ```json
75
+ {
76
+ "kind": "forwarded",
77
+ "responderSessionId": "019ff969-c34c-70be-9034-fae19c852932",
78
+ "decision": { "kind": "user", "via": "dialog" }
79
+ }
80
+ ```
81
+
82
+ That is the difference between a human approving a subagent's request and the parent's policy approving it on their behalf — two outcomes that were previously indistinguishable in the log.
83
+ An older parent that sends no decider yields `"decision": null`: the hop is still recorded, and the answer is still honored.
84
+
85
+ ### Upgrading
86
+
87
+ Upgrade the parent before relying on the out-of-process signal — in practice, restart the interactive session after upgrading the package.
88
+
89
+ A parent session still running a version that predates the heartbeat publishes none, and a child on a version that expects one reads that absence as "not draining" and gives up in about two seconds.
90
+ That only happens in the window where an upgrade lands while a parent session is already running, and it resolves as soon as that session restarts.
91
+ Nothing needs to be edited, and in-process children are unaffected: parent and child there are the same running copy by construction.
59
92
 
60
93
  ---
61
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "26.0.0",
3
+ "version": "26.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,10 +19,12 @@ import {
19
19
  sleep,
20
20
  writeJsonFileAtomic,
21
21
  } from "#src/authority/forwarding-io";
22
+ import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
22
23
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
23
24
  import {
24
25
  type ForwardedAccessFacts,
25
26
  type ForwardedPermissionRequest,
27
+ type ForwardedPermissionResponse,
26
28
  type ForwardedPromptDisplay,
27
29
  type ForwardedSessionApproval,
28
30
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
@@ -32,7 +34,6 @@ import {
32
34
  resolvePermissionForwardingTarget,
33
35
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
34
36
  } from "#src/authority/permission-forwarding";
35
- import type { ServingLookup } from "#src/authority/serving-registry";
36
37
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
37
38
  import { createPermissionRequestId } from "#src/permission-request-id";
38
39
  import { buildUiPrompt } from "#src/permission-ui-prompt";
@@ -96,8 +97,8 @@ export interface ParentAuthorizerDeps {
96
97
  forwardingDir: string;
97
98
  /** In-process subagent session registry for forwarding target resolution. */
98
99
  registry?: SubagentSessionRegistry;
99
- /** Whether the resolved target is draining its inbox (in-process targets only). */
100
- serving: ServingLookup;
100
+ /** Whether the resolved target is draining its inbox, on whichever channel can say. */
101
+ serving: TargetServingLookup;
101
102
  /** How long to wait for the target's answer, read live so config edits apply. */
102
103
  getTimeoutMs: () => number;
103
104
  logger: DebugReviewLogger;
@@ -110,6 +111,9 @@ export interface ParentAuthorizerDeps {
110
111
  * `confirmationUnavailable` is what keeps this out of the "User denied …"
111
112
  * message (#719): a user who was never asked denied nothing. `denialReason`
112
113
  * names which path gave up, and the gate renders it to the model.
114
+ *
115
+ * The provenance record reuses that same string rather than restating it, so
116
+ * what the model is told and what the log attributes cannot drift (#726).
113
117
  */
114
118
  function abandon(denialReason: string): PermissionPromptDecision {
115
119
  return {
@@ -117,6 +121,31 @@ function abandon(denialReason: string): PermissionPromptDecision {
117
121
  state: "denied",
118
122
  confirmationUnavailable: true,
119
123
  denialReason,
124
+ decidedBy: { kind: "unavailable", reason: denialReason },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Adopt the responder's answer, recording the hop it came through.
130
+ *
131
+ * The requester's own terminal entry has to answer two questions, and they are
132
+ * different: *which session* answered, and *what within it* decided. Nesting
133
+ * keeps both rather than flattening the responder's source into this node's
134
+ * record, where it would read as a local decision (#726).
135
+ *
136
+ * A responder that sent no usable source yields `decision: null` — the hop is
137
+ * still a fact, and an older parent is not an error.
138
+ */
139
+ function relayDecision(
140
+ response: ForwardedPermissionResponse,
141
+ ): PermissionPromptDecision {
142
+ return {
143
+ ...response,
144
+ decidedBy: {
145
+ kind: "forwarded",
146
+ responderSessionId: response.responderSessionId,
147
+ decision: response.decidedBy ?? null,
148
+ },
120
149
  };
121
150
  }
122
151
 
@@ -152,7 +181,7 @@ function forwardableRequestId(requesterRequestId: string): string {
152
181
  export class ParentAuthorizer implements TerminalAuthorizer {
153
182
  private readonly forwardingDir: string;
154
183
  private readonly registry: SubagentSessionRegistry | undefined;
155
- private readonly serving: ServingLookup;
184
+ private readonly serving: TargetServingLookup;
156
185
  private readonly getTimeoutMs: () => number;
157
186
  private readonly logger: DebugReviewLogger;
158
187
 
@@ -335,6 +364,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
335
364
  this.logger,
336
365
  responsePath,
337
366
  );
367
+ const relayed = response ? relayDecision(response) : null;
338
368
  this.logger.review("forwarded_permission.response_received", {
339
369
  requestId,
340
370
  approved: response?.approved ?? null,
@@ -343,10 +373,11 @@ export class ParentAuthorizer implements TerminalAuthorizer {
343
373
  responderSessionId: response?.responderSessionId ?? null,
344
374
  targetSessionId,
345
375
  responsePath,
376
+ decidedBy: relayed?.decidedBy,
346
377
  });
347
378
  this.discardRequest(location, requestPath, responsePath);
348
379
  return (
349
- response ??
380
+ relayed ??
350
381
  abandon("The parent session's permission response could not be read")
351
382
  );
352
383
  }
@@ -356,11 +387,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
356
387
  unservedSince !== null &&
357
388
  Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
358
389
  ) {
390
+ const observation = this.serving.describe(target);
359
391
  this.logger.review("forwarded_permission.no_serving_session", {
360
392
  requestId,
361
393
  requesterSessionId: request.requesterSessionId,
362
394
  targetSessionId,
363
- servingSessionIds: this.serving.servingIds(),
395
+ // Which channel answered, and what it saw: the difference between a
396
+ // parent that exited, one that was killed, and one polling under a
397
+ // different session id is the whole diagnosis of a stalled forward.
398
+ servingChannel: observation.channel,
399
+ servingState: observation.state,
400
+ servingSessionIds: observation.servingIds,
364
401
  });
365
402
  this.discardRequest(location, requestPath);
366
403
  return abandon(
@@ -390,21 +427,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
390
427
  /**
391
428
  * Track how long the target has looked unserved, or `null` while it looks fine.
392
429
  *
393
- * Only an in-process target (`source: "registry"`) can be judged: a target in
394
- * another process shares no serving registry with this one, so its absence
395
- * from the registry says nothing (#719, follow-up in #721).
430
+ * Which channel can answer for this target is the judge's decision, not this
431
+ * one's: a target it cannot judge answers `null`, which resets the window
432
+ * exactly as "serving" does, so an unjudgeable target waits out the timeout.
396
433
  */
397
434
  private checkServingLiveness(
398
435
  target: PermissionForwardingTarget,
399
436
  unservedSince: number | null,
400
437
  ): number | null {
401
- if (target.source !== "registry") {
402
- return null;
403
- }
404
- if (this.serving.isServing(target.sessionId)) {
405
- return null;
406
- }
407
- return unservedSince ?? Date.now();
438
+ return this.serving.isServing(target) === false
439
+ ? (unservedSince ?? Date.now())
440
+ : null;
408
441
  }
409
442
 
410
443
  /**
@@ -1,10 +1,14 @@
1
+ import type { DecisionSource } from "#src/authority/decision-source";
1
2
  import type { AuthorizerLog, PermissionQuery } from "#src/service";
2
3
  import type {
3
- Authorizer,
4
4
  AuthorizerVerdict,
5
+ NamedAuthorizer,
5
6
  TerminalAuthorizer,
6
7
  } from "./authorizer";
7
- import { createDeniedPermissionDecision } from "./permission-dialog";
8
+ import {
9
+ createDeniedPermissionDecision,
10
+ type PermissionPromptDecision,
11
+ } from "./permission-dialog";
8
12
 
9
13
  /**
10
14
  * Compose the live-authority chain (ADR 0007): try each non-terminal `link`
@@ -12,9 +16,9 @@ import { createDeniedPermissionDecision } from "./permission-dialog";
12
16
  * context-selected `terminal` that always decides.
13
17
  *
14
18
  * The signature is the type-level terminal-cannot-defer invariant: `links` are
15
- * deferring {@link Authorizer}s while `terminal` is a {@link TerminalAuthorizer}
16
- * (returns a full decision), so a deferring link cannot occupy the terminal
17
- * slot.
19
+ * deferring {@link NamedAuthorizer}s while `terminal` is a
20
+ * {@link TerminalAuthorizer} (returns a full decision), so a deferring link
21
+ * cannot occupy the terminal slot.
18
22
  *
19
23
  * Each link is handed the session-scoped `query` and the review-log `log` at
20
24
  * `authorize` time (ADR 0007 §3) so it queries the deterministic engine at gate
@@ -24,7 +28,7 @@ import { createDeniedPermissionDecision } from "./permission-dialog";
24
28
  * ships until a link registers.
25
29
  */
26
30
  export function composeAuthorizerChain(
27
- links: readonly Authorizer[],
31
+ links: readonly NamedAuthorizer[],
28
32
  terminal: TerminalAuthorizer,
29
33
  query: PermissionQuery,
30
34
  log: AuthorizerLog,
@@ -36,7 +40,7 @@ export function composeAuthorizerChain(
36
40
  async authorize(details) {
37
41
  for (const link of links) {
38
42
  const verdict = await link.authorize(details, query, log);
39
- const decision = decideFromVerdict(verdict);
43
+ const decision = decideFromVerdict(link.name, verdict);
40
44
  if (decision) {
41
45
  return decision;
42
46
  }
@@ -47,16 +51,40 @@ export function composeAuthorizerChain(
47
51
  };
48
52
  }
49
53
 
50
- /** Map a link's decisive verdict to a decision; `defer` yields `null`. */
51
- function decideFromVerdict(verdict: AuthorizerVerdict) {
54
+ /**
55
+ * Map a link's decisive verdict to a decision; `defer` yields `null`.
56
+ *
57
+ * The deciding link is named on the decision, not merely counted among the
58
+ * consulted set the selection already records: a link ahead of it that
59
+ * deferred decided nothing and must not be credited (#726).
60
+ */
61
+ function decideFromVerdict(
62
+ name: string,
63
+ verdict: AuthorizerVerdict,
64
+ ): PermissionPromptDecision | null {
52
65
  switch (verdict.kind) {
53
66
  case "allow":
54
67
  // A link grant is non-persistent (state `approved`, never
55
68
  // `approved_for_session`), per ADR 0007's off-by-default envelope.
56
- return { approved: true, state: "approved" } as const;
69
+ return {
70
+ approved: true,
71
+ state: "approved",
72
+ decidedBy: decidedByLink(name, "allow", null),
73
+ };
57
74
  case "deny":
58
- return createDeniedPermissionDecision(verdict.reason);
75
+ return {
76
+ ...createDeniedPermissionDecision(verdict.reason),
77
+ decidedBy: decidedByLink(name, "deny", verdict.reason ?? null),
78
+ };
59
79
  case "defer":
60
80
  return null;
61
81
  }
62
82
  }
83
+
84
+ function decidedByLink(
85
+ name: string,
86
+ verdict: "allow" | "deny",
87
+ reason: string | null,
88
+ ): DecisionSource {
89
+ return { kind: "authorizer", name, verdict, reason };
90
+ }
@@ -2,8 +2,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
3
  import type { PermissionQuery } from "#src/service";
4
4
  import {
5
- type Authorizer,
6
5
  type AuthorizerSelectionDeps,
6
+ type NamedAuthorizer,
7
7
  type SelectedAuthority,
8
8
  selectAuthorizer,
9
9
  } from "./authorizer";
@@ -93,7 +93,7 @@ export class AuthorizerSelection
93
93
  private linksFor(
94
94
  authority: SelectedAuthority,
95
95
  requestId: string,
96
- ): Authorizer[] {
96
+ ): NamedAuthorizer[] {
97
97
  const configured = this.deps.getAuthorizerChain();
98
98
  if (configured.length === 0) {
99
99
  return [];
@@ -123,8 +123,8 @@ export class AuthorizerSelection
123
123
  private resolveConfiguredLinks(
124
124
  configured: readonly string[],
125
125
  requestId: string,
126
- ): Authorizer[] {
127
- const links: Authorizer[] = [];
126
+ ): NamedAuthorizer[] {
127
+ const links: NamedAuthorizer[] = [];
128
128
  const resolved: string[] = [];
129
129
  for (const name of configured) {
130
130
  const authorize = this.deps.authorizerRegistry.get(name);
@@ -136,7 +136,7 @@ export class AuthorizerSelection
136
136
  continue;
137
137
  }
138
138
  resolved.push(name);
139
- links.push({ authorize: encloseInDelegationEnvelope(authorize) });
139
+ links.push({ name, authorize: encloseInDelegationEnvelope(authorize) });
140
140
  }
141
141
  if (resolved.length > 0) {
142
142
  this.deps.logger.review("authorizer_chain_resolved", {
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
2
3
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
4
  import type {
4
5
  PromptPreferences,
5
6
  requestPermissionDecision,
6
7
  } from "#src/authority/permission-prompt-component";
7
- import type { ServingLookup } from "#src/authority/serving-registry";
8
8
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
9
9
  import type { PermissionEventBus } from "#src/permission-events";
10
10
  import type { AuthorizerLog, PermissionQuery } from "#src/service";
@@ -42,6 +42,19 @@ export interface Authorizer {
42
42
  ): Promise<AuthorizerVerdict>;
43
43
  }
44
44
 
45
+ /**
46
+ * A resolved chain link together with the operator-configured name it came
47
+ * from.
48
+ *
49
+ * `AuthorizerRegistry` already keys links by name, and `AuthorizerSelection`
50
+ * has the name in scope when it resolves the operator's `authorizerChain`; the
51
+ * name is carried through composition so a decision record can say *which*
52
+ * link decided rather than only which links were consulted.
53
+ */
54
+ export interface NamedAuthorizer extends Authorizer {
55
+ readonly name: string;
56
+ }
57
+
45
58
  /**
46
59
  * The terminal link: on `ask`, rules on a single request and is told the
47
60
  * decision. Structurally cannot defer — it always returns a full
@@ -95,8 +108,8 @@ export interface AuthorizerSelectionDeps {
95
108
  forwardingDir: string;
96
109
  /** In-process subagent session registry for forwarding target resolution. */
97
110
  registry?: SubagentSessionRegistry;
98
- /** Which sessions are draining a forwarded-permission inbox. */
99
- servingRegistry: ServingLookup;
111
+ /** Whether a forwarding target is draining its inbox, on whichever channel can say. */
112
+ serving: TargetServingLookup;
100
113
  /** The forwarding timeout, read live so a config edit applies to the next ask. */
101
114
  getForwardingTimeoutMs: () => number;
102
115
  logger: DebugReviewLogger;
@@ -132,7 +145,7 @@ export function selectAuthorizer(
132
145
  terminal: new ParentAuthorizer(ctx, {
133
146
  forwardingDir: deps.forwardingDir,
134
147
  registry: deps.registry,
135
- serving: deps.servingRegistry,
148
+ serving: deps.serving,
136
149
  getTimeoutMs: deps.getForwardingTimeoutMs,
137
150
  logger: deps.logger,
138
151
  }),