@gotgenes/pi-permission-system 29.0.0 → 29.1.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,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
+ ## [29.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.0.0...pi-permission-system-v29.1.0) (2026-08-31)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** warn when a subagent child runs with no permission node ([223710f](https://github.com/gotgenes/pi-packages/commit/223710ff2ed81977161ba2a367deb81fe6c7f207)), closes [#792](https://github.com/gotgenes/pi-packages/issues/792)
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document the optional bound channel and the unguarded-child alarm ([9a16e1b](https://github.com/gotgenes/pi-packages/commit/9a16e1b09934ecdb529875b59f7649c50ccfd5b9)), closes [#792](https://github.com/gotgenes/pi-packages/issues/792)
19
+
8
20
  ## [29.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v28.0.1...pi-permission-system-v29.0.0) (2026-08-30)
9
21
 
10
22
 
@@ -16,13 +16,32 @@ An implementation that creates child sessions inside its own process (via `creat
16
16
  | `subagents:child:session-created` | `{ sessionId, parentSessionId? }` | After the child session is created, immediately before `bindExtensions()` |
17
17
  | `subagents:child:disposed` | `{ sessionId }` | In the run's `finally`, on success and on error alike |
18
18
 
19
+ A third channel is **optional**, and buys one diagnostic:
20
+
21
+ | Channel | Payload | When |
22
+ | ------------------------ | --------------------------------- | ------------------------------------------------------------------- |
23
+ | `subagents:child:bound` | `{ sessionId, parentSessionId? }` | After `bindExtensions()` resolves, and not at all when it throws |
24
+
19
25
  The pre-bind ordering of `session-created` is **contract, not an implementation detail**.
20
26
  Emit it synchronously, on the same call stack, before `bindExtensions()`: this package's subscriber registers the child synchronously, and the registration must land before binding proceeds so the child's own instance can detect itself the moment it loads.
21
27
  An implementation that awaits between creating the session and emitting, or that emits after binding, breaks detection for every child it spawns.
22
28
 
23
- Both are fire-and-forget broadcasts.
29
+ All are fire-and-forget broadcasts.
24
30
  Nothing is returned, nothing is awaited, and no reply travels back over the bus.
25
31
 
32
+ #### The optional `bound` channel
33
+
34
+ Emitting `subagents:child:bound` is not part of the obligation, and an implementation that never emits it is fully conformant.
35
+ What it buys is the unguarded-child alarm described under [What this package does on both ends](#what-this-package-does-on-both-ends): without it, a child that loads no instance of this package runs every tool in its `tools:` allowlist ungated and nothing says so.
36
+
37
+ The timing is the whole point, and it is the one moment that answers the question.
38
+ `bindExtensions()` awaits the child's `session_start` emit, so when it resolves every child extension has initialized and this package's child instance — if it loaded at all — has published its service.
39
+ Emit it there: after the `await`, on the success path only.
40
+ A child whose binding threw never ran, so reporting it would be a false alarm about a session that does not exist.
41
+
42
+ No other moment works.
43
+ `session-created` fires before the child's extensions have loaded, and by `disposed` the child's `session_shutdown` has already withdrawn its service — so a healthy child looks identical to an unguarded one at both.
44
+
26
45
  ### Out-of-process implementations
27
46
 
28
47
  An implementation that spawns a child as its own `pi` process sets one environment variable at spawn:
@@ -72,7 +91,7 @@ The full condition, with a worked example, is documented where the setting lives
72
91
 
73
92
  The announcement is all an implementation provides; this section is what it buys.
74
93
 
75
- On the announcing side, this package subscribes to the child lifecycle (`src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session in the `SubagentSessionRegistry` on `subagents:child:session-created`, unregistering it on `subagents:child:disposed`.
94
+ On the announcing side, this package subscribes to the child lifecycle (`src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session in the `SubagentSessionRegistry` on `subagents:child:session-created`, unregistering it on `subagents:child:disposed`, and auditing it on `subagents:child:bound`.
76
95
  Because the event bus dispatches synchronously, that registration completes before `bindExtensions()` proceeds.
77
96
 
78
97
  The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`.
@@ -83,7 +102,10 @@ What the announcement enables:
83
102
 
84
103
  1. **Deterministic child detection** — `isSubagentExecutionContext()` hits the process-global registry on the first check for an in-process child, and reads the environment for one spawned as its own process, with a session-directory heuristic behind both.
85
104
  2. **Per-agent policy enforcement** - the permission system's `before_agent_start` handler resolves the agent name from the `<active_agent>` system-prompt tag and applies per-agent `permission:` frontmatter overrides.
86
- 3. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
105
+ 3. **An unguarded child is announced** when a child finishes binding without publishing a service of its own, it has no permission node: no `tool_call` gate, no tool filtering, no `permission:` frontmatter resolution, and no ask-forwarding.
106
+ The parent records a `child_node_absent` review entry for every such child and warns once per session.
107
+ The likeliest cause is the child's own configuration — `@gotgenes/pi-subagents` excluding this package under `excludedExtensionPackages` — and a failure to load this extension in the child leaves the identical absence, which the parent cannot tell apart, so the warning names both.
108
+ 4. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
87
109
  The parent approves or denies, and the child resumes.
88
110
  When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting.
89
111
 
@@ -162,7 +184,7 @@ Nothing needs to be edited, and in-process children are unaffected: parent and c
162
184
 
163
185
  ## Conformance of known implementations
164
186
 
165
- Conformance is a property of the announcement alone — whether an implementation emits the in-process events, or sets the out-of-process variable — not of the frontmatter vocabulary it offers for tool visibility.
187
+ Conformance is a property of the announcement alone — whether an implementation emits the two in-process events, or sets the out-of-process variable — not of the frontmatter vocabulary it offers for tool visibility, and not of the optional `bound` channel.
166
188
 
167
189
  | Extension | Shape | Adopts the convention | Visibility key |
168
190
  | ----------------------------------------------------------------------------------- | ---------- | --------------------------------- | ---------------------------------- |
@@ -177,6 +199,9 @@ Adopting the convention is a one-line change at their spawn site.
177
199
 
178
200
  The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) publishes no `subagents:child:session-created` event, so its in-process children have neither deterministic detection nor `ask`-state forwarding.
179
201
 
202
+ `@gotgenes/pi-subagents` is also the only implementation that emits the optional `subagents:child:bound` channel, so it is the only one whose unguarded children are announced.
203
+ The others forfeit that alarm without forfeiting conformance.
204
+
180
205
  See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the companion convention on `permission:` frontmatter, which implementations document rather than implement.
181
206
 
182
207
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "29.0.0",
3
+ "version": "29.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * child-node-audit.ts — Report an in-process subagent child that runs with no
3
+ * permission node.
4
+ *
5
+ * Gating is node-local (ADR 0012 decision 1): each node loads its own instance
6
+ * of this extension and gates its own `tool_call`s. A child that loads none has
7
+ * no gate, no tool filtering, no `permission:` frontmatter resolution, and no
8
+ * ask-forwarding — every tool in its `tools:` allowlist runs ungated. The
9
+ * parent's own gating is unaffected, so without this audit the operator watches
10
+ * the permission system work and never learns the child is unguarded.
11
+ *
12
+ * The signal is publication: since #699 every node publishes its service under
13
+ * its own session id, and a subagent implementation announces the moment a
14
+ * child finished binding its extensions. A child that has published nothing by
15
+ * then has no node.
16
+ *
17
+ * The two halves of the alarm fire at different rates on purpose. The review
18
+ * entry is the durable record and must be complete, so it is written for every
19
+ * affected child. The visible warning is capped at one per parent session: the
20
+ * cause is a single line of configuration, and a parent that fans out ten
21
+ * children would otherwise emit ten identical warnings.
22
+ */
23
+
24
+ /** Answers whether the node whose session is `sessionId` published a service. */
25
+ export type NodePresenceLookup = (sessionId: string) => boolean;
26
+
27
+ /** The narrow log seam this audit needs (ISP): a durable record and a warning. */
28
+ export interface ChildNodeAuditLog {
29
+ review(event: string, details?: Record<string, unknown>): void;
30
+ warn(message: string): void;
31
+ }
32
+
33
+ /** Fields read from the child-bound announcement (ISP). */
34
+ export interface BoundChild {
35
+ /** Child session id — the key its node would have published under. */
36
+ sessionId: string;
37
+ parentSessionId?: string;
38
+ }
39
+
40
+ /**
41
+ * The agent-facing text for an unguarded child.
42
+ *
43
+ * The parent cannot tell a deliberate exclusion from a load failure — both
44
+ * leave the identical absence — so the message names the likelier cause and
45
+ * admits the other in the same sentence.
46
+ */
47
+ export function childNodeAbsentMessage(childSessionId: string): string {
48
+ return (
49
+ `pi-permission-system: subagent child session ${childSessionId} is ` +
50
+ "running with no permission node — this extension is not loaded in it, so " +
51
+ "its tool calls are not gated there. Most often the package is listed in " +
52
+ "pi-subagents' excludedExtensionPackages; a failure to load this extension " +
53
+ "in the child does the same. Further affected children are recorded in the " +
54
+ "permission review log as child_node_absent."
55
+ );
56
+ }
57
+
58
+ /** The audit seam the child-lifecycle subscription drives (ISP). */
59
+ export interface BoundChildAuditor {
60
+ auditBoundChild(child: BoundChild): void;
61
+ }
62
+
63
+ /**
64
+ * Audits each child that finishes binding, and alarms on one with no node.
65
+ *
66
+ * The warn-once latch is a plain field with no re-arm hook, because the
67
+ * extension factory is re-invoked per session generation — a `/new`, `/resume`,
68
+ * `/fork`, or `/import` switch builds a fresh audit. A `session_start` with
69
+ * `reason: "reload"` reuses this instance and deliberately does not re-warn:
70
+ * the operator has already been told.
71
+ */
72
+ export class ChildNodeAudit implements BoundChildAuditor {
73
+ private warned = false;
74
+
75
+ constructor(
76
+ private readonly hasNode: NodePresenceLookup,
77
+ private readonly log: ChildNodeAuditLog,
78
+ ) {}
79
+
80
+ auditBoundChild(child: BoundChild): void {
81
+ if (this.hasNode(child.sessionId)) {
82
+ return;
83
+ }
84
+ this.log.review("child_node_absent", {
85
+ childSessionId: child.sessionId,
86
+ parentSessionId: child.parentSessionId ?? null,
87
+ });
88
+ if (this.warned) {
89
+ return;
90
+ }
91
+ this.warned = true;
92
+ this.log.warn(childNodeAbsentMessage(child.sessionId));
93
+ }
94
+ }
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
3
- * lifecycle events and keep the SubagentSessionRegistry in sync.
3
+ * lifecycle events and dispatch each fact to its owner.
4
4
  *
5
5
  * @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
6
6
  * event bus (ADR 0002): it no longer calls this package's service directly.
7
- * We register the child on `session-created` and unregister it on `disposed`.
7
+ * We register the child on `session-created`, audit it for a permission node on
8
+ * `bound`, and unregister it on `disposed`.
9
+ *
10
+ * The module subscribes to the announcement and hands each fact to its owner —
11
+ * the registry for the two registration events, the audit for `bound` — so the
12
+ * channel names and payload shapes of the whole contract stay declared in one
13
+ * place.
8
14
  *
9
15
  * The channel names and payload shapes are declared independently here (the two
10
16
  * packages must not depend on each other under jiti) and MUST match the
@@ -15,13 +21,25 @@
15
21
  * event bus dispatches listeners synchronously, so a synchronous handler lands
16
22
  * the registry entry before binding proceeds. Introducing an `await` before
17
23
  * `registry.register(...)` would break the pre-bind ordering.
24
+ *
25
+ * The `bound` handler carries no such requirement — nothing waits on it — but it
26
+ * must not make the other two async either, since they share this module.
18
27
  */
19
28
 
29
+ import type { BoundChildAuditor } from "./child-node-audit";
20
30
  import type { SubagentSessionRegistry } from "./subagent-registry";
21
31
 
22
32
  /** Emitted by the core after session creation, before `bindExtensions()`. */
23
33
  export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
24
34
 
35
+ /**
36
+ * Emitted by the core once the child's extensions have bound, after every child
37
+ * `session_start` handler has run — the one moment a parent can observe what
38
+ * those extensions installed. Optional: an implementation that never emits it
39
+ * is still conformant, and simply forfeits the unguarded-child alarm.
40
+ */
41
+ export const SUBAGENT_CHILD_BOUND = "subagents:child:bound";
42
+
25
43
  /** Emitted by the core in the run's `finally` (success and error). */
26
44
  export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
27
45
 
@@ -37,6 +55,13 @@ interface ChildSessionCreatedEvent {
37
55
  parentSessionId?: string;
38
56
  }
39
57
 
58
+ /** Fields read from the `bound` payload (ISP). */
59
+ interface ChildBoundEvent {
60
+ /** Child session id — the key its node would have published under. */
61
+ sessionId: string;
62
+ parentSessionId?: string;
63
+ }
64
+
40
65
  /** Fields read from the `disposed` payload (ISP). */
41
66
  interface ChildDisposedEvent {
42
67
  /** Child session id — the registry key. Must match the publisher. */
@@ -46,12 +71,13 @@ interface ChildDisposedEvent {
46
71
  /**
47
72
  * Subscribe to the subagent child lifecycle.
48
73
  *
49
- * @returns an unsubscribe that detaches both handlers (call during
74
+ * @returns an unsubscribe that detaches every handler (call during
50
75
  * `session_shutdown`).
51
76
  */
52
77
  export function subscribeSubagentLifecycle(
53
78
  events: LifecycleEventBus,
54
79
  registry: SubagentSessionRegistry,
80
+ audit: BoundChildAuditor,
55
81
  ): () => void {
56
82
  const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
57
83
  const event = data as ChildSessionCreatedEvent;
@@ -60,6 +86,14 @@ export function subscribeSubagentLifecycle(
60
86
  });
61
87
  });
62
88
 
89
+ const unsubBound = events.on(SUBAGENT_CHILD_BOUND, (data) => {
90
+ const event = data as ChildBoundEvent;
91
+ audit.auditBoundChild({
92
+ sessionId: event.sessionId,
93
+ parentSessionId: event.parentSessionId,
94
+ });
95
+ });
96
+
63
97
  const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
64
98
  const event = data as ChildDisposedEvent;
65
99
  registry.unregister(event.sessionId);
@@ -67,6 +101,7 @@ export function subscribeSubagentLifecycle(
67
101
 
68
102
  return () => {
69
103
  unsubCreated();
104
+ unsubBound();
70
105
  unsubDisposed();
71
106
  };
72
107
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  ObservedAuthorizerRegistrar,
8
8
  } from "./authority/authorizer-registry";
9
9
  import { AuthorizerSelection } from "./authority/authorizer-selection";
10
+ import { ChildNodeAudit } from "./authority/child-node-audit";
10
11
  import {
11
12
  ForwardedRequestServer,
12
13
  type ServingPolicy,
@@ -50,7 +51,7 @@ import { PermissionResolver } from "./permission-resolver";
50
51
  import { PermissionSession } from "./permission-session";
51
52
  import { LocalPermissionsService } from "./permissions-service";
52
53
  import { resolveRenderBudget } from "./presentation/dialog-renderer";
53
- import type { PermissionsService } from "./service";
54
+ import { getPermissionsService, type PermissionsService } from "./service";
54
55
  import { PermissionServiceLifecycle } from "./service-lifecycle";
55
56
  import { PermissionSessionLogger } from "./session-logger";
56
57
  import { SessionRules } from "./session-rules";
@@ -259,10 +260,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
259
260
  );
260
261
 
261
262
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
262
- // sessions register/unregister without the core calling us (ADR 0002).
263
+ // sessions register/unregister without the core calling us (ADR 0002), and
264
+ // so a child that bound its extensions without loading one of ours is
265
+ // reported rather than silently ungated (#792). The lookup is a thunk over
266
+ // the locator, never a cached reference, per the guidance in service.ts.
267
+ const childNodeAudit = new ChildNodeAudit(
268
+ (sessionId) => getPermissionsService(sessionId) !== undefined,
269
+ logger,
270
+ );
263
271
  const unsubSubagentLifecycle = subscribeSubagentLifecycle(
264
272
  pi.events,
265
273
  subagentRegistry,
274
+ childNodeAudit,
266
275
  );
267
276
 
268
277
  // PermissionServiceLifecycle owns the process-global service publication: