@gotgenes/pi-permission-system 26.3.0 → 27.0.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 +40 -0
- package/README.md +38 -2
- package/dist/public.d.ts +105 -21
- package/docs/configuration.md +3 -1
- package/docs/cross-extension-api.md +112 -62
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +7 -2
- package/docs/migration/0794-keyed-service-locator.md +91 -0
- package/docs/subagent-integration.md +98 -23
- package/package.json +1 -1
- package/src/authority/authorizer-registry.ts +50 -0
- package/src/authority/authorizer-selection.ts +33 -1
- package/src/authority/permission-forwarding.ts +25 -9
- package/src/authority/subagent-context.ts +4 -10
- package/src/handlers/before-agent-start.ts +5 -14
- package/src/handlers/index.ts +1 -0
- package/src/handlers/session-turn-prep.ts +58 -0
- package/src/index.ts +35 -10
- package/src/permission-events.ts +44 -11
- package/src/service-lifecycle.ts +76 -9
- package/src/service.ts +188 -16
- package/src/session-identity.ts +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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
|
+
## [27.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.3.1...pi-permission-system-v27.0.0) (2026-08-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** `permissions:ready` now fires at least once per session and may repeat. The latch added in #787 re-emits it at each node's first `before_agent_start`, so a consumer that registers unconditionally on every emission hits a duplicate-registration throw on every session instead of the rare user-initiated `/reload` it previously hit. Guard the ready handler with a stored dispose handle so it registers once per session, and release it on `session_shutdown`.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** deprecate the zero-arg service accessor ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([511c87f](https://github.com/gotgenes/pi-packages/commit/511c87f46e1ec85137145442050d53ae41eb8cd1))
|
|
18
|
+
* **pi-permission-system:** publish each node's service under its own session id ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([d8ce4a8](https://github.com/gotgenes/pi-packages/commit/d8ce4a85ac381b6123b4d1f461d59073953a22b6))
|
|
19
|
+
* **pi-permission-system:** re-emit permissions:ready at the first before_agent_start ([#787](https://github.com/gotgenes/pi-packages/issues/787)) ([405e904](https://github.com/gotgenes/pi-packages/commit/405e904e527645776cab8162293682a71fa5ec65))
|
|
20
|
+
* **pi-permission-system:** reclaim getPermissionsService for the keyed locator ([214b30a](https://github.com/gotgenes/pi-packages/commit/214b30abf3a2ba3dc2f8f9bc3c56932a868c32dd))
|
|
21
|
+
* **pi-permission-system:** record a vacant link cell on a relaying node ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([f5f08d2](https://github.com/gotgenes/pi-packages/commit/f5f08d2c159504f508b88f9e163be2f9049d558d))
|
|
22
|
+
* **pi-permission-system:** warn when the keyed locator gets no session id ([29824aa](https://github.com/gotgenes/pi-packages/commit/29824aae61b1a4f565b5c456d9f3440d77304be5)), closes [#794](https://github.com/gotgenes/pi-packages/issues/794)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** detect a subagent from its parent-session env var ([bd698da](https://github.com/gotgenes/pi-packages/commit/bd698da14a7d44dfa8dc06be2342a47b8337324e)), closes [#789](https://github.com/gotgenes/pi-packages/issues/789)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
* **pi-permission-system:** cite the adapter convention instead of restating it ([d9ba637](https://github.com/gotgenes/pi-packages/commit/d9ba6375b29e3e25e53d9324676e7c5220c2bad7)), closes [#789](https://github.com/gotgenes/pi-packages/issues/789)
|
|
33
|
+
* **pi-permission-system:** document session-keyed service publication ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([8ed137c](https://github.com/gotgenes/pi-packages/commit/8ed137c662a22087edc3623151938c7ae0ea9a86))
|
|
34
|
+
* **pi-permission-system:** document the ready latch and its idempotency requirement ([#787](https://github.com/gotgenes/pi-packages/issues/787)) ([bc31193](https://github.com/gotgenes/pi-packages/commit/bc31193aafa8c6ef1356cc2e096e643f6357c912))
|
|
35
|
+
* **pi-permission-system:** document the reclaimed locator and the ready cadence ([ca585b4](https://github.com/gotgenes/pi-packages/commit/ca585b4fba48a95ddd4dbe51106c02486997a400)), closes [#794](https://github.com/gotgenes/pi-packages/issues/794)
|
|
36
|
+
* **pi-permission-system:** make subagent-integration the adapter convention's canonical spec ([07f9d2b](https://github.com/gotgenes/pi-packages/commit/07f9d2beffb66207aa3b01985b1a28312d431da3)), closes [#789](https://github.com/gotgenes/pi-packages/issues/789)
|
|
37
|
+
|
|
38
|
+
## [26.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.3.0...pi-permission-system-v26.3.1) (2026-08-19)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Documentation
|
|
42
|
+
|
|
43
|
+
* condense scope sections and move them below the usage material ([#775](https://github.com/gotgenes/pi-packages/issues/775)) ([99f5829](https://github.com/gotgenes/pi-packages/commit/99f58298962baac5bdfe5d3cc02dca0ca9b32395))
|
|
44
|
+
* **pi-permission-system:** document scope and non-goals ([#775](https://github.com/gotgenes/pi-packages/issues/775)) ([5fa3960](https://github.com/gotgenes/pi-packages/commit/5fa39609953ad8bf370a0e304a73fdf67df6dae5))
|
|
45
|
+
* **retro:** add retro notes for issue [#610](https://github.com/gotgenes/pi-packages/issues/610) ([2333e6a](https://github.com/gotgenes/pi-packages/commit/2333e6ae57fe77c64e322e520918404995ef1dda))
|
|
46
|
+
* route model-assisted judging to the seam, not to one link ([#775](https://github.com/gotgenes/pi-packages/issues/775)) ([3f30984](https://github.com/gotgenes/pi-packages/commit/3f30984b53eb0a14217cf70ecb05ac1daab48abb))
|
|
47
|
+
|
|
8
48
|
## [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
49
|
|
|
10
50
|
|
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ Within a surface map like `bash` or `mcp`, **last matching rule wins** — put b
|
|
|
115
115
|
The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
|
|
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
|
-
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).
|
|
118
|
+
A downstream extension registers a link via `getPermissionsService(sessionId).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
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.
|
|
120
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.
|
|
121
121
|
|
|
@@ -137,6 +137,41 @@ Commands that previously slipped through silently on the error or empty-parse pa
|
|
|
137
137
|
|
|
138
138
|
If you relied on the old permissive behavior for bash, set an explicit permissive bash policy — `"bash": { "*": "allow" }` — which also suppresses the new startup warning emitted when a top-level `"*": "allow"` leaves bash ungated.
|
|
139
139
|
|
|
140
|
+
## Scope and non-goals
|
|
141
|
+
|
|
142
|
+
**Purpose.**
|
|
143
|
+
An agent takes many actions, most of them benign, but some of which need a human to confirm they are safe or correct.
|
|
144
|
+
This package routes your attention to those, and turns each ruling into deterministic, reusable policy — enforced at the host level rather than by asking the model to police itself.
|
|
145
|
+
|
|
146
|
+
**In scope.**
|
|
147
|
+
Hardening the gates against bypass, fail-closed corrections (breaking ones included), named opt-in extension seams for downstream packages, and structural work backed by a written decision record.
|
|
148
|
+
|
|
149
|
+
**Non-goals.**
|
|
150
|
+
|
|
151
|
+
- _Sandboxing._
|
|
152
|
+
This is a decision layer, not a sandbox — it decides and records, it does not isolate.
|
|
153
|
+
If a dangerous action is reachable through an allowed tool, policy has to restrict it explicitly.
|
|
154
|
+
- _Deciding project trust._
|
|
155
|
+
A policy enforcer, not a trust oracle: whether a project is trusted is Pi's decision and yours, and this package observes it.
|
|
156
|
+
- _Permissive defaults, trust profiles, or workflow presets._
|
|
157
|
+
Your risk profile is not knowable from here, so defaults are least-privilege and common policies ship as documented recipes rather than preset keywords.
|
|
158
|
+
- _Guessing what is sensitive._
|
|
159
|
+
No built-in secret denylist, and log redaction is key-name-structural rather than predictive — a redactor that silently misses a key invites treating the log as safe to share.
|
|
160
|
+
- _Model judgment in the core._
|
|
161
|
+
This package makes no LLM call and holds no model config; model-assisted judging attaches as a chain link over the authorizer seam instead.
|
|
162
|
+
A link decides nothing until you name it in `authorizerChain`, and its `allow` on an excluded surface is downgraded to `defer`.
|
|
163
|
+
|
|
164
|
+
The [architecture doc](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/architecture/architecture.md#scope-and-non-goals) carries the full inventory, with the decision record behind each entry.
|
|
165
|
+
|
|
166
|
+
**One decision is still open.**
|
|
167
|
+
How policy may _enter_ the system — which channels are admissible, and with what precedence — is being worked out in [issue #639](https://github.com/gotgenes/pi-packages/issues/639), along with whether a capability model replaces the current surface list.
|
|
168
|
+
Several requested widenings are parked on it rather than declined, durable persistence of an approval among them.
|
|
169
|
+
|
|
170
|
+
**Where adjacent requests belong.**
|
|
171
|
+
True isolation of a permitted action → an agent sandbox.
|
|
172
|
+
Model-assisted judging of an `ask` → a chain link over the authorizer seam; [@gotgenes/pi-permission-model-judge](https://www.npmjs.com/package/@gotgenes/pi-permission-model-judge) is the first-party one, and judges mistyped paths.
|
|
173
|
+
Approve-and-steer, edit diffs, and risk explanations → a downstream package over the `permissions:decision` event and the presentation seams.
|
|
174
|
+
|
|
140
175
|
## Documentation
|
|
141
176
|
|
|
142
177
|
| Document | Contents |
|
|
@@ -144,7 +179,7 @@ If you relied on the old permissive behavior for bash, set an explicit permissiv
|
|
|
144
179
|
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
|
|
145
180
|
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
|
|
146
181
|
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
|
|
147
|
-
| [docs/subagent-integration.md](docs/subagent-integration.md) |
|
|
182
|
+
| [docs/subagent-integration.md](docs/subagent-integration.md) | The subagent adapter convention, permission forwarding, coexistence with subagent extensions |
|
|
148
183
|
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
|
|
149
184
|
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
|
|
150
185
|
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
|
|
@@ -153,6 +188,7 @@ If you relied on the old permissive behavior for bash, set an explicit permissiv
|
|
|
153
188
|
| [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md) | Project-trust gating (breaking) — project config loads only after project trust |
|
|
154
189
|
| [docs/migration/0745-prompt-payload-contracts.md](docs/migration/0745-prompt-payload-contracts.md) | Prompt payload contracts (breaking) — the forwarded wire, the `ui_prompt` broadcast, and the deprecated preview caps |
|
|
155
190
|
| [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound |
|
|
191
|
+
| [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event |
|
|
156
192
|
|
|
157
193
|
## Development
|
|
158
194
|
|
package/dist/public.d.ts
CHANGED
|
@@ -158,20 +158,48 @@ interface PromptAnnotation {
|
|
|
158
158
|
* removed or renamed without a semver-major version bump.
|
|
159
159
|
*/
|
|
160
160
|
|
|
161
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Emitted at `session_start` after the emitting node published its service, and
|
|
163
|
+
* again at that node's first `before_agent_start` (ADR 0012 decision 3).
|
|
164
|
+
*
|
|
165
|
+
* Fires at least once per session and may repeat, so a handler must be
|
|
166
|
+
* idempotent — registering on every emission hits the duplicate-registration
|
|
167
|
+
* throw.
|
|
168
|
+
*/
|
|
162
169
|
declare const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
163
170
|
/** Emitted when a permission request is committed to the active UI prompt path. */
|
|
164
171
|
declare const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
|
|
165
172
|
/** Emitted after every permission gate resolution. */
|
|
166
173
|
declare const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
|
167
174
|
/**
|
|
168
|
-
* Payload emitted on `permissions:ready
|
|
175
|
+
* Payload emitted on `permissions:ready`: plain facts about the node that
|
|
176
|
+
* emitted it (ADR 0012 decision 2).
|
|
169
177
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
178
|
+
* The bus announces; the locator provides. The payload carries data a consumer
|
|
179
|
+
* can log, serialize, and replay — never a live capability — so the service
|
|
180
|
+
* itself is fetched with `getPermissionsService(sessionId)`.
|
|
181
|
+
*
|
|
182
|
+
* There is no `protocolVersion` — the published types plus package semver
|
|
183
|
+
* define the broadcast contract.
|
|
173
184
|
*/
|
|
174
|
-
|
|
185
|
+
interface PermissionsReadyEvent {
|
|
186
|
+
/**
|
|
187
|
+
* The emitting node's session id: the key for
|
|
188
|
+
* `getPermissionsService`. `null` when the host exposed no session
|
|
189
|
+
* id, in which case this node published no keyed service.
|
|
190
|
+
*/
|
|
191
|
+
sessionId: string | null;
|
|
192
|
+
/**
|
|
193
|
+
* Whether this node adjudicates its own asks (its authorizer chain runs) or
|
|
194
|
+
* relays them to a serving node, which runs *its* chain over the same facts
|
|
195
|
+
* (ADR 0007 §7).
|
|
196
|
+
*
|
|
197
|
+
* A registration needs no branch on this: extractors and formatters are read
|
|
198
|
+
* by every node's own gates, and a chain link registered where no chain runs
|
|
199
|
+
* is accepted and recorded rather than refused (ADR 0012 decision 4).
|
|
200
|
+
*/
|
|
201
|
+
adjudicatesLocally: boolean;
|
|
202
|
+
}
|
|
175
203
|
/**
|
|
176
204
|
* Origin of a UI prompt.
|
|
177
205
|
*
|
|
@@ -405,16 +433,26 @@ type ToolAccessExtractor = (input: Record<string, unknown>) => string | undefine
|
|
|
405
433
|
type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined;
|
|
406
434
|
|
|
407
435
|
/**
|
|
408
|
-
* Cross-extension service
|
|
436
|
+
* Cross-extension service accessors backed by `Symbol.for()` on `globalThis`.
|
|
409
437
|
*
|
|
410
438
|
* `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
|
|
411
439
|
* module isolation (`moduleCache: false`). A consumer doing
|
|
412
|
-
* `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
|
|
413
|
-
*
|
|
414
|
-
*
|
|
440
|
+
* `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but the
|
|
441
|
+
* accessors here read from the same `globalThis` slots the provider wrote to —
|
|
442
|
+
* enabling direct, synchronous, type-safe function calls.
|
|
443
|
+
*
|
|
444
|
+
* There are two slots, because one process can host several **nodes** (one Pi
|
|
445
|
+
* session runtime each — a root session and its in-process subagent children
|
|
446
|
+
* all load their own instance of this extension):
|
|
447
|
+
*
|
|
448
|
+
* - A session-keyed map, written by every node under its own session id.
|
|
449
|
+
* `getPermissionsService(sessionId)` resolves the service whose
|
|
450
|
+
* registries that node's own gates and chain read (ADR 0012 decision 2).
|
|
451
|
+
* - A single legacy slot holding the process root's service, read by the
|
|
452
|
+
* deprecated `getRootPermissionsService()`.
|
|
415
453
|
*
|
|
416
|
-
* Best practice:
|
|
417
|
-
*
|
|
454
|
+
* Best practice: resolve per use rather than caching the reference — this
|
|
455
|
+
* ensures resilience across `/reload` and load-order edge cases.
|
|
418
456
|
*/
|
|
419
457
|
|
|
420
458
|
/**
|
|
@@ -466,7 +504,14 @@ interface PermissionQuery {
|
|
|
466
504
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
467
505
|
}
|
|
468
506
|
/**
|
|
469
|
-
* Public interface exposed to other extensions via
|
|
507
|
+
* Public interface exposed to other extensions via
|
|
508
|
+
* {@link getPermissionsService} (or the deprecated
|
|
509
|
+
* {@link getRootPermissionsService}).
|
|
510
|
+
*
|
|
511
|
+
* Each instance belongs to one node, and its three registration surfaces are
|
|
512
|
+
* read by that node alone: extractors and formatters by its own gates, chain
|
|
513
|
+
* links by its own chain. Resolve the service of the node whose behavior you
|
|
514
|
+
* mean to affect.
|
|
470
515
|
*
|
|
471
516
|
* `checkPermission` takes a surface + optional value + optional agent name,
|
|
472
517
|
* and delegates to `PermissionManager.checkPermission()` with current session
|
|
@@ -532,20 +577,59 @@ interface PermissionsService extends PermissionQuery {
|
|
|
532
577
|
registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
|
|
533
578
|
}
|
|
534
579
|
/**
|
|
535
|
-
* Store a `PermissionsService`
|
|
536
|
-
*
|
|
580
|
+
* Store a `PermissionsService` in the legacy process-root slot, read by
|
|
581
|
+
* `getRootPermissionsService()`.
|
|
537
582
|
*
|
|
538
583
|
* Called at `session_start` by the top-level (parent) instance only — an
|
|
539
584
|
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
540
585
|
* service. Overwrites any previously published service, which keeps `/reload`
|
|
541
586
|
* working: a reloaded parent re-publishes its fresh service.
|
|
542
587
|
*/
|
|
543
|
-
declare function
|
|
588
|
+
declare function publishRootPermissionsService(service: PermissionsService): void;
|
|
589
|
+
/**
|
|
590
|
+
* Retrieve the process root's published `PermissionsService`, or `undefined`
|
|
591
|
+
* if the permission-system extension has not loaded (or has been unloaded).
|
|
592
|
+
*
|
|
593
|
+
* @deprecated Use {@link getPermissionsService} with the `sessionId`
|
|
594
|
+
* from the `permissions:ready` payload. This accessor answers "the process
|
|
595
|
+
* root's service", which is the wrong question in every node but the root.
|
|
596
|
+
* Removal is deferred to a future major (ADR 0012 decision 7).
|
|
597
|
+
*/
|
|
598
|
+
declare function getRootPermissionsService(): PermissionsService | undefined;
|
|
599
|
+
/**
|
|
600
|
+
* Publish `service` as the service of the node whose session is `sessionId`
|
|
601
|
+
* (ADR 0012 decision 2 — node-locality).
|
|
602
|
+
*
|
|
603
|
+
* Every node publishes under its own key, including an in-process subagent
|
|
604
|
+
* child, so there is nothing to clobber: a child's sibling extension registers
|
|
605
|
+
* an extractor, formatter, or chain link into the registry the child's own
|
|
606
|
+
* gates and chain read.
|
|
607
|
+
*/
|
|
608
|
+
declare function publishPermissionsService(sessionId: string, service: PermissionsService): void;
|
|
609
|
+
/**
|
|
610
|
+
* Retrieve the service belonging to the node whose session is `sessionId`, or
|
|
611
|
+
* `undefined` when that node has published none.
|
|
612
|
+
*
|
|
613
|
+
* This is the supported way to obtain a node's service, for registration and
|
|
614
|
+
* for policy queries alike. Take `sessionId` from the `permissions:ready`
|
|
615
|
+
* payload (or from `ctx.sessionManager.getSessionId()` inside your own session
|
|
616
|
+
* handler), and resolve per use rather than caching the reference.
|
|
617
|
+
*
|
|
618
|
+
* A caller the type checker cannot reach — JavaScript, or a consumer compiled
|
|
619
|
+
* against an earlier major — may still call this with no argument. That answers
|
|
620
|
+
* `undefined` rather than another node's service, and warns once so the missing
|
|
621
|
+
* registration is not silent.
|
|
622
|
+
*/
|
|
623
|
+
declare function getPermissionsService(sessionId: string): PermissionsService | undefined;
|
|
544
624
|
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
625
|
+
* Remove the `sessionId` entry, but only when it still holds `service`
|
|
626
|
+
* (identity compare-and-delete, like {@link unpublishRootPermissionsService}).
|
|
627
|
+
*
|
|
628
|
+
* Scoping the delete to the publishing instance keeps a superseded `/reload`
|
|
629
|
+
* generation's late shutdown from wiping the new generation's freshly
|
|
630
|
+
* published service.
|
|
547
631
|
*/
|
|
548
|
-
declare function
|
|
632
|
+
declare function unpublishPermissionsService(sessionId: string, service: PermissionsService): void;
|
|
549
633
|
/**
|
|
550
634
|
* Remove `service` from `globalThis`, but only when the current slot still
|
|
551
635
|
* holds it (identity compare-and-delete).
|
|
@@ -559,7 +643,7 @@ declare function getPermissionsService(): PermissionsService | undefined;
|
|
|
559
643
|
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
560
644
|
* shutdown cannot wipe the new generation's freshly published service.
|
|
561
645
|
*/
|
|
562
|
-
declare function
|
|
646
|
+
declare function unpublishRootPermissionsService(service: PermissionsService): void;
|
|
563
647
|
|
|
564
|
-
export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, publishPermissionsService, unpublishPermissionsService };
|
|
648
|
+
export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, getRootPermissionsService, publishPermissionsService, publishRootPermissionsService, unpublishPermissionsService, unpublishRootPermissionsService };
|
|
565
649
|
export type { Authorizer, AuthorizerLog, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptAnnotation, PromptEvidence, PromptPayload, PromptPayloadKind, PromptPermissionDetails, PromptRequestFacts, PromptRequester, ToolInputFormatter };
|
package/docs/configuration.md
CHANGED
|
@@ -244,9 +244,11 @@ Three review-log records make the chain observable, all keyed by the ask's `requ
|
|
|
244
244
|
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
|
|
245
245
|
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
|
|
246
246
|
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
|
|
247
|
+
| `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted |
|
|
247
248
|
|
|
248
|
-
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)
|
|
249
|
+
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.
|
|
249
250
|
Registration returns a disposer, and only one link may hold a given name.
|
|
251
|
+
Register in every session without branching: a session that relays its asks accepts the link and records `authorizer_link_vacant` rather than refusing it.
|
|
250
252
|
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.
|
|
251
253
|
|
|
252
254
|
---
|
|
@@ -15,29 +15,45 @@ It provides direct, synchronous, type-safe function calls.
|
|
|
15
15
|
### Quick Start
|
|
16
16
|
|
|
17
17
|
```typescript
|
|
18
|
-
|
|
19
|
-
const {
|
|
20
|
-
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
18
|
+
pi.events.on("permissions:ready", (data) => {
|
|
19
|
+
const { sessionId } = data as PermissionsReadyEvent;
|
|
20
|
+
if (!sessionId) return;
|
|
21
|
+
void (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const { getPermissionsService } =
|
|
24
|
+
await import("@gotgenes/pi-permission-system");
|
|
25
|
+
const permissions = getPermissionsService(sessionId);
|
|
26
|
+
if (permissions) {
|
|
27
|
+
const result = permissions.checkPermission("bash", "git push");
|
|
28
|
+
console.log(result.state); // "allow" | "deny" | "ask"
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Not installed — graceful degradation
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
});
|
|
30
35
|
```
|
|
31
36
|
|
|
37
|
+
Inside your own `session_start` handler, `ctx.sessionManager.getSessionId()` is the same key.
|
|
38
|
+
|
|
32
39
|
### How It Works
|
|
33
40
|
|
|
34
41
|
Pi's extension loader creates a fresh [jiti](https://github.com/nicolo-ribaudo/jiti) instance per extension with `moduleCache: false`, which isolates module-level state.
|
|
35
42
|
`Symbol.for()` and `globalThis` are process-global by spec, so they survive this isolation.
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
One process can host several **nodes** — one Pi session runtime each, with its own gates and its own registries.
|
|
45
|
+
A root session and each of its in-process subagent children are separate nodes, and each loads its own instance of this extension.
|
|
46
|
+
Registrations never cross a node boundary: a formatter, an access extractor, or an authorizer link is read by the node it was registered in.
|
|
47
|
+
|
|
48
|
+
Each node therefore publishes its own service at `session_start`, into a `globalThis` map keyed by that node's session id (`Symbol.for("@gotgenes/pi-permission-system:session-services")`).
|
|
49
|
+
Consumers call `getPermissionsService(sessionId)` to retrieve it — even though their `import()` loads a fresh module copy, the accessor reads from the shared `globalThis` slot.
|
|
50
|
+
The session id arrives as a field on the `permissions:ready` broadcast, which each node emits at its own `session_start`, right after publishing — and again at that node's first `before_agent_start`, so a consumer whose own `session_start` ran later still hears it.
|
|
51
|
+
|
|
52
|
+
`getRootPermissionsService()` remains, reading a separate legacy slot that holds the **process root's** service, but it is deprecated: in any node but the root it answers the wrong question, handing an in-process child the parent's service.
|
|
53
|
+
Calling it emits a once-guarded Node `DeprecationWarning` (code `PI_PERMISSION_SYSTEM_DEP0001`); run with `--trace-deprecation` to locate your call site, or `--no-deprecation` to silence it.
|
|
54
|
+
Removal is deferred to a future major release.
|
|
55
|
+
|
|
56
|
+
Both accessors were renamed in the major that reclaimed `getPermissionsService` for the keyed locator; if you are upgrading from a release whose `getPermissionsService()` took no argument, see [migration/0794-keyed-service-locator.md](migration/0794-keyed-service-locator.md).
|
|
41
57
|
|
|
42
58
|
All types below are directly importable and type-check with `tsc` out of the box.
|
|
43
59
|
`@gotgenes/pi-permission-system`'s published `exports` resolve `import type { … }` to a self-contained, bundled declaration file with no internal module references, so a downstream `tsconfig.json` needs no special path configuration.
|
|
@@ -184,30 +200,33 @@ Register during your extension's initialization and store the disposer for teard
|
|
|
184
200
|
export default function myExtension(pi: ExtensionAPI): void {
|
|
185
201
|
let disposeFormatter: (() => void) | undefined;
|
|
186
202
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
203
|
+
pi.events.on("permissions:ready", (data) => {
|
|
204
|
+
const { sessionId } = data as PermissionsReadyEvent;
|
|
205
|
+
if (disposeFormatter || !sessionId) return;
|
|
206
|
+
void (async () => {
|
|
207
|
+
try {
|
|
208
|
+
const { getPermissionsService } =
|
|
209
|
+
await import("@gotgenes/pi-permission-system");
|
|
210
|
+
const permissions = getPermissionsService(sessionId);
|
|
211
|
+
disposeFormatter = permissions?.registerToolInputFormatter(
|
|
212
|
+
"deploy", // a tool THIS extension registers with Pi
|
|
213
|
+
(input) => {
|
|
214
|
+
const target =
|
|
215
|
+
typeof input.target === "string" ? input.target : undefined;
|
|
216
|
+
const services = Array.isArray(input.services)
|
|
217
|
+
? input.services.length
|
|
218
|
+
: undefined;
|
|
219
|
+
if (!target) return undefined; // decline → default preview
|
|
220
|
+
return services !== undefined
|
|
221
|
+
? `with target ${target} (${services} services)`
|
|
222
|
+
: `with target ${target}`;
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
// permission-system not installed — nothing to register
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
});
|
|
211
230
|
|
|
212
231
|
pi.on("session_shutdown", () => {
|
|
213
232
|
disposeFormatter?.();
|
|
@@ -265,24 +284,27 @@ The extractor must not throw — guard your parsing and return `undefined` on an
|
|
|
265
284
|
|
|
266
285
|
#### Subagent session registration
|
|
267
286
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
See [Subagent Integration](subagent-integration.md) for details.
|
|
287
|
+
Subagent registration is announcement-driven, and the spawner makes no service call.
|
|
288
|
+
The channel names, payload shapes, pre-bind ordering, and the out-of-process environment variable are specified by the subagent adapter convention in [Subagent Integration](subagent-integration.md#the-subagent-adapter-convention).
|
|
271
289
|
|
|
272
290
|
### Reload Safety
|
|
273
291
|
|
|
274
292
|
During `/reload`, all extensions re-initialize.
|
|
275
|
-
The permission-system re-publishes a fresh service at `session_start`; teardown is identity-scoped, so a superseded generation's shutdown only
|
|
293
|
+
The permission-system re-publishes a fresh service at `session_start`; teardown is identity-scoped for both slots, so a superseded generation's shutdown only removes an entry it still owns and cannot wipe the new service.
|
|
276
294
|
Consumers that re-initialize during reload naturally get the new instance.
|
|
277
295
|
|
|
278
|
-
Best practice:
|
|
296
|
+
Best practice: resolve the service per use rather than caching the reference.
|
|
279
297
|
|
|
280
298
|
### Graceful Degradation
|
|
281
299
|
|
|
282
|
-
`getPermissionsService()` returns `undefined` when the permission-system extension has not loaded (or has been unloaded).
|
|
300
|
+
`getPermissionsService(sessionId)` returns `undefined` when the permission-system extension has not loaded into that node (or has been unloaded).
|
|
283
301
|
The `import()` throws if the package is not installed.
|
|
284
302
|
Wrap both in `try/catch` + `if` guard as shown in the Quick Start example.
|
|
285
303
|
|
|
304
|
+
It also returns `undefined` when called with no session id at all — a shape TypeScript rejects but JavaScript reaches — rather than falling back to the process root's service, since answering with another node's service is the defect the keyed locator exists to prevent.
|
|
305
|
+
That call emits a once-guarded Node warning (code `PI_PERMISSION_SYSTEM_WARN0001`), because the guard above turns the missing service into a registration that silently never happens.
|
|
306
|
+
It is deliberately not a `DeprecationWarning`: `--no-deprecation` does not silence it.
|
|
307
|
+
|
|
286
308
|
---
|
|
287
309
|
|
|
288
310
|
## Event Bus
|
|
@@ -299,11 +321,11 @@ All three broadcasts are best-effort: a throwing listener cannot block permissio
|
|
|
299
321
|
|
|
300
322
|
## Channel Reference
|
|
301
323
|
|
|
302
|
-
| Channel | Direction | When
|
|
303
|
-
| ----------------------- | --------- |
|
|
304
|
-
| `permissions:ready` | Broadcast | At `session_start
|
|
305
|
-
| `permissions:ui_prompt` | Broadcast | Before active UI prompt
|
|
306
|
-
| `permissions:decision` | Broadcast | After every gate resolution
|
|
324
|
+
| Channel | Direction | When | Payload type |
|
|
325
|
+
| ----------------------- | --------- | ----------------------------------------------------------------------------------------------------- | ------------------------- |
|
|
326
|
+
| `permissions:ready` | Broadcast | At each node's `session_start` after that node publishes, and again at its first `before_agent_start` | `PermissionsReadyEvent` |
|
|
327
|
+
| `permissions:ui_prompt` | Broadcast | Before active UI prompt | `PermissionUiPromptEvent` |
|
|
328
|
+
| `permissions:decision` | Broadcast | After every gate resolution | `PermissionDecisionEvent` |
|
|
307
329
|
|
|
308
330
|
---
|
|
309
331
|
|
|
@@ -401,12 +423,13 @@ A served decision carries a non-null `forwarding` context; the requesting sessio
|
|
|
401
423
|
|
|
402
424
|
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
425
|
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.
|
|
404
|
-
It identifies a permission
|
|
426
|
+
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.
|
|
405
427
|
Use the review log's `toolCallId` to join back to the Pi transcript.
|
|
406
428
|
|
|
407
429
|
```typescript
|
|
408
430
|
pi.events.on("permissions:decision", (raw) => {
|
|
409
|
-
const event =
|
|
431
|
+
const event =
|
|
432
|
+
raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
|
|
410
433
|
console.log(event.surface, event.result, event.resolution);
|
|
411
434
|
// e.g. "bash" "allow" "user_approved_for_session"
|
|
412
435
|
});
|
|
@@ -445,20 +468,47 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
445
468
|
|
|
446
469
|
## Ready Event
|
|
447
470
|
|
|
448
|
-
|
|
449
|
-
It
|
|
471
|
+
Each node emits `permissions:ready` at its own `session_start`, right after publishing its service — so a consumer reacting to it can immediately resolve that node's service.
|
|
472
|
+
It emits again at that node's first `before_agent_start`, which runs after every extension's `session_start` and before any tool call, hence before any permission prompt.
|
|
473
|
+
|
|
474
|
+
So the channel's contract is: **fires at least once per session, and may repeat.**
|
|
475
|
+
A handler must be idempotent.
|
|
476
|
+
That guarantee is what makes the ready event alone a sufficient registration site: a consumer that needs its own config before it can register no longer has to attempt registration from `session_start` as well, hoping one of the two orderings completes the pair.
|
|
477
|
+
A new session generation (`/reload`, `/new`, `/resume`) starts the cycle over: one emission at `session_start`, one at the first turn that follows.
|
|
450
478
|
|
|
451
|
-
|
|
479
|
+
Guard your registration on the disposer you stored, as the example below does.
|
|
480
|
+
An unguarded handler that calls `registerAuthorizer`, `registerToolInputFormatter`, or `registerToolAccessExtractor` on every emission hits the duplicate-registration throw on the second one.
|
|
481
|
+
That throw is caught by Pi's event bus and reported on stderr — your first registration stays live — but the noise is avoidable, and it was already reachable before the latch existed, since `/reload` re-runs `session_start`.
|
|
482
|
+
|
|
483
|
+
The payload carries plain facts about the node that emitted it, never a live capability: the bus announces, the locator provides.
|
|
452
484
|
It carries no `protocolVersion` — the broadcast contract is defined by the published types plus package semver.
|
|
453
485
|
|
|
486
|
+
| Field | Type | Meaning |
|
|
487
|
+
| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
488
|
+
| `sessionId` | `string \| null` | The emitting node's session id — the key for `getPermissionsService`. `null` when the host exposed no session id, in which case that node published no keyed service. |
|
|
489
|
+
| `adjudicatesLocally` | `boolean` | Whether this node's authorizer chain runs, or the node relays its asks to a serving node that runs _its_ chain over the same facts. |
|
|
490
|
+
|
|
454
491
|
```typescript
|
|
455
|
-
|
|
492
|
+
let dispose: (() => void) | undefined;
|
|
493
|
+
|
|
494
|
+
pi.events.on("permissions:ready", (data) => {
|
|
495
|
+
const { sessionId } = data as PermissionsReadyEvent;
|
|
496
|
+
// Idempotent: ready may repeat, so a second emission must be a no-op.
|
|
497
|
+
if (dispose || !sessionId) return;
|
|
456
498
|
void (async () => {
|
|
457
|
-
const { getPermissionsService } =
|
|
458
|
-
"@gotgenes/pi-permission-system"
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
|
|
499
|
+
const { getPermissionsService } =
|
|
500
|
+
await import("@gotgenes/pi-permission-system");
|
|
501
|
+
const permissions = getPermissionsService(sessionId);
|
|
502
|
+
// This node published before the event fired — resolve and register now.
|
|
503
|
+
dispose = permissions?.registerAuthorizer("my-link", authorize);
|
|
462
504
|
})();
|
|
463
505
|
});
|
|
506
|
+
|
|
507
|
+
pi.on("session_shutdown", () => {
|
|
508
|
+
dispose?.();
|
|
509
|
+
dispose = undefined;
|
|
510
|
+
});
|
|
464
511
|
```
|
|
512
|
+
|
|
513
|
+
A registration needs no branch on `adjudicatesLocally`.
|
|
514
|
+
Formatters and access extractors are read by every node's own gates, and a chain link registered on a relaying node is accepted (its disposer works) and recorded in the review log as `authorizer_link_vacant` rather than refused — so registering everywhere is the correct default.
|
|
@@ -136,7 +136,8 @@ try {
|
|
|
136
136
|
const { getPermissionsService } = await import(
|
|
137
137
|
"@gotgenes/pi-permission-system"
|
|
138
138
|
);
|
|
139
|
-
|
|
139
|
+
// Your own session's id; inside a handler, ctx.sessionManager.getSessionId().
|
|
140
|
+
const permissions = getPermissionsService(sessionId);
|
|
140
141
|
if (permissions) {
|
|
141
142
|
const result = permissions.checkPermission("bash", "git push", "Worker");
|
|
142
143
|
console.log(result.state); // "allow" | "deny" | "ask"
|
|
@@ -146,7 +147,11 @@ try {
|
|
|
146
147
|
}
|
|
147
148
|
```
|
|
148
149
|
|
|
149
|
-
|
|
150
|
+
Query the service belonging to the session whose policy you mean.
|
|
151
|
+
Each session that loads the permission system publishes its own — a subagent child's config (and cwd) may differ from its parent's, so the parent's service is not a stand-in for the child's.
|
|
152
|
+
The deprecated `getRootPermissionsService()` always answers with the process root's service, which is why it is the wrong call inside a child.
|
|
153
|
+
|
|
154
|
+
If `pi-permission-system` is not installed, `import()` throws; if that session has published no service yet (or has been unloaded), `getPermissionsService(sessionId)` returns `undefined`.
|
|
150
155
|
Guard both cases as shown above.
|
|
151
156
|
|
|
152
157
|
Prompt forwarding for headless child agents is an internal subagent-to-parent mechanism, not a public cross-extension operation — there is no service-accessor equivalent to call directly.
|