@gotgenes/pi-permission-system 25.1.0 → 25.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 +17 -0
- package/README.md +1 -0
- package/docs/configuration.md +12 -0
- package/package.json +1 -1
- package/src/authority/authorizer-selection.ts +65 -14
- package/src/authority/authorizer.ts +47 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
## [25.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.1.0...pi-permission-system-v25.2.0) (2026-08-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** record which chain links were consulted on each ask ([8bb52ff](https://github.com/gotgenes/pi-packages/commit/8bb52ffe26cfb0c995e8299ebe737a09f1717c57)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** stop reporting a delegated subagent chain as unregistered links ([2a1c082](https://github.com/gotgenes/pi-packages/commit/2a1c08292131e9f7dd71adeac380a997ccc0069a)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
* **pi-permission-system:** document one-chain-per-node adjudication semantics ([c36980a](https://github.com/gotgenes/pi-packages/commit/c36980ae2cf6d1b115b4b3f20e9513dac61c3d1b)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
|
|
24
|
+
|
|
8
25
|
## [25.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.0.0...pi-permission-system-v25.1.0) (2026-08-13)
|
|
9
26
|
|
|
10
27
|
|
package/README.md
CHANGED
|
@@ -116,6 +116,7 @@ The optional `shellTools` field records which non-`bash` tools carry shell seman
|
|
|
116
116
|
|
|
117
117
|
The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
|
|
118
118
|
A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
|
|
119
|
+
A subagent's ask is reviewed by the chain of the session serving it, one hop up, rather than inside the subagent — see the same section.
|
|
119
120
|
[`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge) is a first-party reference implementation of such a link — a deny-first reviewer that auto-denies mistyped out-of-directory paths.
|
|
120
121
|
|
|
121
122
|
For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
|
package/docs/configuration.md
CHANGED
|
@@ -215,6 +215,18 @@ The excluded surface is the **gate** surface the rule fired on, not the tool nam
|
|
|
215
215
|
This holds for an ask forwarded up from a subagent exactly as it does for a local one.
|
|
216
216
|
See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
|
|
217
217
|
|
|
218
|
+
When a **subagent** raises the ask, the chain runs one hop up.
|
|
219
|
+
The subagent forwards the request to the session serving it, and that session resolves it against its own rules and then runs *its* chain over the same evidence — so your configured links do review a subagent's asks, in the session you are watching.
|
|
220
|
+
The subagent itself resolves no links (an extension cannot register one in a child session at all), and records `authorizer_chain_delegated` in the review log to say so.
|
|
221
|
+
|
|
222
|
+
Three review-log records make the chain observable, all keyed by the ask's `requestId`:
|
|
223
|
+
|
|
224
|
+
| Record | Meaning |
|
|
225
|
+
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
|
|
226
|
+
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
|
|
227
|
+
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
|
|
228
|
+
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
|
|
229
|
+
|
|
218
230
|
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
|
219
231
|
Registration returns a disposer, and only one link may hold a given name.
|
|
220
232
|
For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
|
package/package.json
CHANGED
|
@@ -4,8 +4,8 @@ import type { PermissionQuery } from "#src/service";
|
|
|
4
4
|
import {
|
|
5
5
|
type Authorizer,
|
|
6
6
|
type AuthorizerSelectionDeps,
|
|
7
|
+
type SelectedAuthority,
|
|
7
8
|
selectAuthorizer,
|
|
8
|
-
type TerminalAuthorizer,
|
|
9
9
|
} from "./authorizer";
|
|
10
10
|
import { composeAuthorizerChain } from "./authorizer-chain";
|
|
11
11
|
import type { AuthorizerLookup } from "./authorizer-registry";
|
|
@@ -54,7 +54,7 @@ export interface AskEscalator {
|
|
|
54
54
|
export class AuthorizerSelection
|
|
55
55
|
implements AskEscalator, AuthorizerSelectionLifecycle
|
|
56
56
|
{
|
|
57
|
-
private
|
|
57
|
+
private authority: SelectedAuthority | null = null;
|
|
58
58
|
|
|
59
59
|
constructor(
|
|
60
60
|
private readonly deps: AuthorizerSelectionDeps & {
|
|
@@ -69,13 +69,43 @@ export class AuthorizerSelection
|
|
|
69
69
|
) {}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* Select the
|
|
72
|
+
* Select the live authority for `ctx` and store it. The non-terminal
|
|
73
73
|
* chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
|
|
74
74
|
* link register in a `permissions:ready` handler that may fire after
|
|
75
75
|
* activation, so link resolution is deferred to the session's first ask.
|
|
76
76
|
*/
|
|
77
77
|
activate(ctx: ExtensionContext): void {
|
|
78
|
-
this.
|
|
78
|
+
this.authority = selectAuthorizer(ctx, this.deps);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The chain links for this ask.
|
|
83
|
+
*
|
|
84
|
+
* A node that adjudicates locally resolves its configured names; a relaying
|
|
85
|
+
* node resolves none. Its terminal hands the ask to a serving node, which
|
|
86
|
+
* resolves the request against its own recorded authority and escalates it
|
|
87
|
+
* through *its* chain over the same child-fixed facts (#635) — so running
|
|
88
|
+
* links here would adjudicate one ask twice, and a relaying node cannot host
|
|
89
|
+
* a link in the first place (#699). The delegation is recorded rather than
|
|
90
|
+
* reported as a fail-safe skip: an absent link is the design here, not the
|
|
91
|
+
* misconfiguration `authorizer_chain_unregistered_link` exists to surface.
|
|
92
|
+
*/
|
|
93
|
+
private linksFor(
|
|
94
|
+
authority: SelectedAuthority,
|
|
95
|
+
requestId: string,
|
|
96
|
+
): Authorizer[] {
|
|
97
|
+
const configured = this.deps.getAuthorizerChain();
|
|
98
|
+
if (configured.length === 0) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
if (!authority.adjudicatesLocally) {
|
|
102
|
+
this.deps.logger.review("authorizer_chain_delegated", {
|
|
103
|
+
requestId,
|
|
104
|
+
links: configured,
|
|
105
|
+
});
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
return this.resolveConfiguredLinks(configured, requestId);
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
/**
|
|
@@ -84,32 +114,52 @@ export class AuthorizerSelection
|
|
|
84
114
|
* warning (invariant 2 — more prompting, never less); each resolved link is
|
|
85
115
|
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
|
|
86
116
|
* surface cannot exceed the operator's policy.
|
|
117
|
+
*
|
|
118
|
+
* The resolved names are recorded against the ask before any link runs — a
|
|
119
|
+
* link that defers decides nothing and would otherwise leave no evidence it
|
|
120
|
+
* was consulted at all, which is what makes "the judge never ran" and "the
|
|
121
|
+
* judge ran and deferred" indistinguishable in the review log.
|
|
87
122
|
*/
|
|
88
|
-
private resolveConfiguredLinks(
|
|
123
|
+
private resolveConfiguredLinks(
|
|
124
|
+
configured: readonly string[],
|
|
125
|
+
requestId: string,
|
|
126
|
+
): Authorizer[] {
|
|
89
127
|
const links: Authorizer[] = [];
|
|
90
|
-
|
|
128
|
+
const resolved: string[] = [];
|
|
129
|
+
for (const name of configured) {
|
|
91
130
|
const authorize = this.deps.authorizerRegistry.get(name);
|
|
92
131
|
if (authorize === undefined) {
|
|
93
|
-
this.deps.logger.review("authorizer_chain_unregistered_link", {
|
|
132
|
+
this.deps.logger.review("authorizer_chain_unregistered_link", {
|
|
133
|
+
requestId,
|
|
134
|
+
name,
|
|
135
|
+
});
|
|
94
136
|
continue;
|
|
95
137
|
}
|
|
138
|
+
resolved.push(name);
|
|
96
139
|
links.push({ authorize: encloseInDelegationEnvelope(authorize) });
|
|
97
140
|
}
|
|
141
|
+
if (resolved.length > 0) {
|
|
142
|
+
this.deps.logger.review("authorizer_chain_resolved", {
|
|
143
|
+
requestId,
|
|
144
|
+
links: resolved,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
98
147
|
return links;
|
|
99
148
|
}
|
|
100
149
|
|
|
101
150
|
/** Clear the stored selection. */
|
|
102
151
|
deactivate(): void {
|
|
103
|
-
this.
|
|
152
|
+
this.authority = null;
|
|
104
153
|
}
|
|
105
154
|
|
|
106
155
|
/**
|
|
107
156
|
* Escalate an ask through the composed chain and return its decision.
|
|
108
157
|
*
|
|
109
|
-
* Resolves
|
|
158
|
+
* Resolves this ask's links freshly (so a link registered any time before
|
|
110
159
|
* this first ask is honored) and composes them ahead of the selected
|
|
111
|
-
* terminal. With zero links
|
|
112
|
-
*
|
|
160
|
+
* terminal. With zero links — no chain configured, or a relaying node that
|
|
161
|
+
* delegates adjudication to the serving node — the composed value **is** the
|
|
162
|
+
* terminal instance, so behavior is identical to a bare terminal escalation.
|
|
113
163
|
*
|
|
114
164
|
* Rejects if no terminal has been selected — i.e. before the session was
|
|
115
165
|
* activated. Implements {@link AskEscalator}.
|
|
@@ -117,14 +167,15 @@ export class AuthorizerSelection
|
|
|
117
167
|
escalate(
|
|
118
168
|
details: PromptPermissionDetails,
|
|
119
169
|
): Promise<PermissionPromptDecision> {
|
|
120
|
-
|
|
170
|
+
const authority = this.authority;
|
|
171
|
+
if (authority === null) {
|
|
121
172
|
return Promise.reject(
|
|
122
173
|
new Error("escalate called before the session was activated"),
|
|
123
174
|
);
|
|
124
175
|
}
|
|
125
176
|
const chain = composeAuthorizerChain(
|
|
126
|
-
this.
|
|
127
|
-
|
|
177
|
+
this.linksFor(authority, details.requestId),
|
|
178
|
+
authority.terminal,
|
|
128
179
|
this.deps.getPermissionQuery(),
|
|
129
180
|
this.deps.logger,
|
|
130
181
|
);
|
|
@@ -59,6 +59,28 @@ export interface TerminalAuthorizer {
|
|
|
59
59
|
): Promise<PermissionPromptDecision>;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The node's live-authority selection: who decides this node's asks, and
|
|
64
|
+
* whether this node adjudicates them with its own chain.
|
|
65
|
+
*
|
|
66
|
+
* The chain role is the selection's product, not a discriminator a consumer
|
|
67
|
+
* re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
|
|
68
|
+
* subagent that has its own UI decides locally, and re-deriving the role from
|
|
69
|
+
* `detection.isSubagent(ctx)` alone would get that case wrong.
|
|
70
|
+
*/
|
|
71
|
+
export interface SelectedAuthority {
|
|
72
|
+
/** The terminal that decides this node's asks, or relays them upward. */
|
|
73
|
+
readonly terminal: TerminalAuthorizer;
|
|
74
|
+
/**
|
|
75
|
+
* False when the terminal relays the ask to a serving node
|
|
76
|
+
* (`ParentAuthorizer`): that node resolves the request against its own
|
|
77
|
+
* recorded authority and escalates it through *its* chain over the same
|
|
78
|
+
* child-fixed facts (#635), so resolving links here would adjudicate one ask
|
|
79
|
+
* twice.
|
|
80
|
+
*/
|
|
81
|
+
readonly adjudicatesLocally: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
/** Construction inputs for {@link selectAuthorizer}. */
|
|
63
85
|
export interface AuthorizerSelectionDeps {
|
|
64
86
|
/** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
|
|
@@ -81,8 +103,9 @@ export interface AuthorizerSelectionDeps {
|
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
/**
|
|
84
|
-
* Select the
|
|
85
|
-
* three-way `hasUI` / `isSubagent` / deny dispatch
|
|
106
|
+
* Select the live authority for the current context: the single owner of the
|
|
107
|
+
* three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
|
|
108
|
+
* dispatch implies.
|
|
86
109
|
*
|
|
87
110
|
* Evaluated once per session activation (`AuthorizerSelection.activate`),
|
|
88
111
|
* replacing the re-derivation of the same predicates across
|
|
@@ -91,24 +114,30 @@ export interface AuthorizerSelectionDeps {
|
|
|
91
114
|
export function selectAuthorizer(
|
|
92
115
|
ctx: ExtensionContext,
|
|
93
116
|
deps: AuthorizerSelectionDeps,
|
|
94
|
-
):
|
|
117
|
+
): SelectedAuthority {
|
|
95
118
|
if (ctx.hasUI) {
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
return {
|
|
120
|
+
terminal: new LocalUserAuthorizer({
|
|
121
|
+
ui: ctx.ui,
|
|
122
|
+
mode: ctx.mode,
|
|
123
|
+
events: deps.events,
|
|
124
|
+
getPromptPreferences: deps.getPromptPreferences,
|
|
125
|
+
requestPermissionDecision: deps.requestPermissionDecision,
|
|
126
|
+
}),
|
|
127
|
+
adjudicatesLocally: true,
|
|
128
|
+
};
|
|
103
129
|
}
|
|
104
130
|
if (deps.detection.isSubagent(ctx)) {
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
return {
|
|
132
|
+
terminal: new ParentAuthorizer(ctx, {
|
|
133
|
+
forwardingDir: deps.forwardingDir,
|
|
134
|
+
registry: deps.registry,
|
|
135
|
+
serving: deps.servingRegistry,
|
|
136
|
+
getTimeoutMs: deps.getForwardingTimeoutMs,
|
|
137
|
+
logger: deps.logger,
|
|
138
|
+
}),
|
|
139
|
+
adjudicatesLocally: false,
|
|
140
|
+
};
|
|
112
141
|
}
|
|
113
|
-
return new DenyingAuthorizer();
|
|
142
|
+
return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
|
|
114
143
|
}
|