@gotgenes/pi-permission-system 32.0.4 → 32.0.5

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,18 @@ 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
+ ## [32.0.5](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.4...pi-permission-system-v32.0.5) (2026-09-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** tell the operator when a configured chain link is missing ([3662541](https://github.com/gotgenes/pi-packages/commit/3662541d4ebc555d54faf2934085d22b36ee618e)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** record the unregistered-link warning ([f70b1e3](https://github.com/gotgenes/pi-packages/commit/f70b1e3ce52a38744da2a07880878efb61dfae00)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
18
+ * **pi-permission-system:** state the resolved skip in the boundary test comment ([9cd18f1](https://github.com/gotgenes/pi-packages/commit/9cd18f1ef6bd8f9dbabf6fc6ce6b9dfa58d64764)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
19
+
8
20
  ## [32.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.3...pi-permission-system-v32.0.4) (2026-09-16)
9
21
 
10
22
 
@@ -226,9 +226,11 @@ Three invariants govern the chain:
226
226
 
227
227
  1. **Config order wins, never registration order.**
228
228
  The order in `authorizerChain` — not the order extensions happen to register in — fixes the security-relevant chain order.
229
- 2. **A missing link is skipped fail-safe.**
230
- A name with no registered link is skipped with a logged warning; the `ask` still reaches the terminal.
229
+ 2. **A missing link is skipped fail-safe, and you are told.**
230
+ A name with no registered link is skipped; the `ask` still reaches the terminal.
231
231
  Absence of a judge means *more* prompting, never less.
232
+ Because you asked for that judge and did not get it, the skip also raises a warning naming the link — once per session per name, beside the per-ask review record.
233
+ Three things leave the identical absence, so the warning names the likeliest and admits the others: the extension providing the link is not loaded in this session (a subagent child's `excludedExtensionPackages` does this), it failed to load, or it declined to register because it has no configuration of its own.
232
234
  3. **Registration alone grants no authority.**
233
235
  Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
234
236
 
@@ -244,12 +246,12 @@ The subagent itself resolves no links (an extension cannot register one in a chi
244
246
 
245
247
  Three review-log records make the chain observable, all keyed by the ask's `requestId`:
246
248
 
247
- | Record | Meaning |
248
- | ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
249
- | `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
250
- | `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
251
- | `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
252
- | `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted |
249
+ | Record | Meaning |
250
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
251
+ | `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
252
+ | `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
253
+ | `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal, and the first skip of that name also warns you |
254
+ | `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted |
253
255
 
254
256
  Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`, taking `sessionId` from that event's payload; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
255
257
  Registration returns a disposer, and only one link may hold a given name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.4",
3
+ "version": "32.0.5",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * authorizer-chain-audit.ts — Report a configured `authorizerChain` link this
3
+ * node's registry could not resolve.
4
+ *
5
+ * Registrations are node-local (ADR 0012 decision 1), and a link produces a
6
+ * *verdict* rather than a fact, so live authority stays converged at the
7
+ * adjudicating node and a link is never inherited from an ancestor. A node that
8
+ * adjudicates locally therefore runs whatever links loaded in it, and skips the
9
+ * rest fail-safe (ADR 0007 §4 invariant 2) — the ask still reaches the
10
+ * terminal. That resolution is deliberate; its silence was not. The operator
11
+ * named a judge in config, did not get it, and the only trace was a line in a
12
+ * JSONL file.
13
+ *
14
+ * The two halves of the alarm fire at different rates on purpose, for two
15
+ * different readers. The review entry is the auditor's durable record and must
16
+ * be complete, so it is written for every skipped ask. The visible warning is
17
+ * for the operator about to answer the prompt the link should have answered, so
18
+ * it is latched: one per configured name, whose count is bounded by
19
+ * `authorizerChain.length`.
20
+ *
21
+ * A relaying node cannot reach this audit at all. `AuthorizerSelection` returns
22
+ * from `linksFor` before resolving anything, recording
23
+ * `authorizer_chain_delegated` instead — its ask is adjudicated one hop up
24
+ * (ADR 0007 §7), where an absent link is the design rather than a
25
+ * misconfiguration.
26
+ */
27
+
28
+ /** The narrow log seam this audit needs (ISP): a durable record and a warning. */
29
+ export interface AuthorizerChainAuditLog {
30
+ review(event: string, details?: Record<string, unknown>): void;
31
+ warn(message: string): void;
32
+ }
33
+
34
+ /** A configured chain link this node's registry could not resolve. */
35
+ export interface UnregisteredLink {
36
+ /** The operator-configured name, exactly as written in `authorizerChain`. */
37
+ name: string;
38
+ /** The ask whose chain resolution skipped it. */
39
+ requestId: string;
40
+ }
41
+
42
+ /** The audit seam `AuthorizerSelection` drives when it skips a name (ISP). */
43
+ export interface UnregisteredLinkAuditor {
44
+ auditUnregisteredLink(link: UnregisteredLink): void;
45
+ }
46
+
47
+ /**
48
+ * The agent-facing text for a configured link that is not registered here.
49
+ *
50
+ * Three causes leave the identical absence — the provider was excluded from
51
+ * this session's extensions, it failed to load, or it declined to register
52
+ * because it has no configuration of its own (the shape
53
+ * `@gotgenes/pi-permission-model-judge` uses to let an operator opt out
54
+ * per project). The message names the likeliest and admits the others, rather
55
+ * than accusing the operator of a contradiction it cannot prove.
56
+ *
57
+ * Every clause is a statement of what already happened. It deliberately does
58
+ * not claim later asks will skip the link too: registration is honored any time
59
+ * before an ask (ADR 0007 §4), so a link may yet arrive.
60
+ */
61
+ export function unregisteredLinkMessage(name: string): string {
62
+ return (
63
+ `pi-permission-system: authorizerChain names "${name}", but no link with ` +
64
+ "that name is registered in this session, so this ask is being decided " +
65
+ "without it. Most often the extension providing the link is not loaded " +
66
+ "here (a subagent child's excludedExtensionPackages does this); it may " +
67
+ "also have failed to load, or declined to register because it has no " +
68
+ "configuration of its own. Every skipped ask is recorded in the " +
69
+ "permission review log as authorizer_chain_unregistered_link."
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Records each skipped chain link, and warns the operator once per name.
75
+ *
76
+ * The latch is a plain field with no re-arm hook, because the extension factory
77
+ * is re-invoked per session generation — a `/new`, `/resume`, `/fork`, or
78
+ * `/import` switch builds a fresh audit. A `session_start` with
79
+ * `reason: "reload"` reuses this instance and deliberately does not re-warn for
80
+ * a name already reported; a name newly added to `authorizerChain` by that same
81
+ * reload has no entry yet, so it warns on its first skip.
82
+ */
83
+ export class AuthorizerChainAudit implements UnregisteredLinkAuditor {
84
+ private readonly warned = new Set<string>();
85
+
86
+ constructor(private readonly log: AuthorizerChainAuditLog) {}
87
+
88
+ auditUnregisteredLink(link: UnregisteredLink): void {
89
+ this.log.review("authorizer_chain_unregistered_link", {
90
+ requestId: link.requestId,
91
+ name: link.name,
92
+ });
93
+ if (this.warned.has(link.name)) {
94
+ return;
95
+ }
96
+ this.warned.add(link.name);
97
+ this.log.warn(unregisteredLinkMessage(link.name));
98
+ }
99
+ }
@@ -7,6 +7,7 @@ import {
7
7
  selectAuthorizer,
8
8
  } from "./authorizer";
9
9
  import { composeAuthorizerChain } from "./authorizer-chain";
10
+ import type { UnregisteredLinkAuditor } from "./authorizer-chain-audit";
10
11
  import type { AuthorizerLookup } from "./authorizer-registry";
11
12
  import { encloseInDelegationEnvelope } from "./delegation-envelope";
12
13
  import type { PermissionPromptDecision } from "./permission-dialog";
@@ -61,6 +62,28 @@ export interface AdjudicationRole {
61
62
  adjudicatesLocally(): boolean;
62
63
  }
63
64
 
65
+ /**
66
+ * Everything {@link AuthorizerSelection} is constructed with: the
67
+ * {@link AuthorizerSelectionDeps} `selectAuthorizer` itself needs, plus the
68
+ * collaborators only the class uses to resolve and run the chain.
69
+ *
70
+ * Named rather than left anonymous on the constructor because the test
71
+ * fixtures mirror it: an addition here is otherwise an addition in two places.
72
+ * `selectAuthorizer` keeps the narrower parameter type (ISP) — it resolves no
73
+ * links and must not see the chain collaborators.
74
+ */
75
+ export type AuthorizerSelectionConstructorDeps = AuthorizerSelectionDeps & {
76
+ prompter: PermissionPrompterApi;
77
+ /** The session-scoped query injected into each chain link (ADR 0007 §3). */
78
+ getPermissionQuery: () => PermissionQuery;
79
+ /** Read-only lookup of registered links by name. */
80
+ authorizerRegistry: AuthorizerLookup;
81
+ /** The operator's configured link names, read live per ask. */
82
+ getAuthorizerChain: () => string[];
83
+ /** Told about each configured name the registry could not resolve. */
84
+ chainAudit: UnregisteredLinkAuditor;
85
+ };
86
+
64
87
  /**
65
88
  * Context-owning selection root for the Authorizer spine.
66
89
  *
@@ -79,17 +102,7 @@ export class AuthorizerSelection
79
102
  private authority: SelectedAuthority | null = null;
80
103
  private relayTarget: PermissionForwardingTarget | null = null;
81
104
 
82
- constructor(
83
- private readonly deps: AuthorizerSelectionDeps & {
84
- prompter: PermissionPrompterApi;
85
- /** The session-scoped query injected into each chain link (ADR 0007 §3). */
86
- getPermissionQuery: () => PermissionQuery;
87
- /** Read-only lookup of registered links by name. */
88
- authorizerRegistry: AuthorizerLookup;
89
- /** The operator's configured link names, read live per ask. */
90
- getAuthorizerChain: () => string[];
91
- },
92
- ) {}
105
+ constructor(private readonly deps: AuthorizerSelectionConstructorDeps) {}
93
106
 
94
107
  /**
95
108
  * Select the live authority for `ctx` and store it. The non-terminal
@@ -165,10 +178,11 @@ export class AuthorizerSelection
165
178
 
166
179
  /**
167
180
  * Resolve the operator's `authorizerChain` names to registered links, in
168
- * config order (ADR 0007 invariant 1). An unregistered name is skipped with a
169
- * warning (invariant 2 — more prompting, never less); each resolved link is
170
- * wrapped in the bounded-delegation envelope so an `allow` on an excluded
171
- * surface cannot exceed the operator's policy.
181
+ * config order (ADR 0007 invariant 1). An unregistered name is skipped
182
+ * fail-safe (invariant 2 — more prompting, never less) and handed to the
183
+ * chain audit, which records it and tells the operator once per name; each
184
+ * resolved link is wrapped in the bounded-delegation envelope so an `allow`
185
+ * on an excluded surface cannot exceed the operator's policy.
172
186
  *
173
187
  * The resolved names are recorded against the ask before any link runs — a
174
188
  * link that defers decides nothing and would otherwise leave no evidence it
@@ -184,10 +198,7 @@ export class AuthorizerSelection
184
198
  for (const name of configured) {
185
199
  const authorize = this.deps.authorizerRegistry.get(name);
186
200
  if (authorize === undefined) {
187
- this.deps.logger.review("authorizer_chain_unregistered_link", {
188
- requestId,
189
- name,
190
- });
201
+ this.deps.chainAudit.auditUnregisteredLink({ requestId, name });
191
202
  continue;
192
203
  }
193
204
  resolved.push(name);
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
3
  import { warmBashParser } from "#src/access-intent/bash/parser";
4
4
  import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
5
+ import { AuthorizerChainAudit } from "#src/authority/authorizer-chain-audit";
5
6
  import {
6
7
  AuthorizerRegistry,
7
8
  ObservedAuthorizerRegistrar,
@@ -168,6 +169,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
168
169
  // resolved in config order at activation.
169
170
  authorizerRegistry,
170
171
  getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
172
+ // Records each configured name this node could not resolve, and tells the
173
+ // operator once per name — the ask is decided without the judge they
174
+ // asked for, and the review log alone never said so (#861).
175
+ chainAudit: new AuthorizerChainAudit(logger),
171
176
  });
172
177
 
173
178
  // Resolver composes the manager + session ruleset and owns the