@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 +12 -0
- package/docs/configuration.md +10 -8
- package/package.json +1 -1
- package/src/authority/authorizer-chain-audit.ts +99 -0
- package/src/authority/authorizer-selection.ts +30 -19
- package/src/index.ts +5 -0
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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.
|
|
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
|