@gotgenes/pi-permission-system 29.0.0 → 29.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 CHANGED
@@ -5,6 +5,35 @@ 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.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.1.0...pi-permission-system-v29.2.0) (2026-09-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** expose the registered extractor and formatter for a tool ([9d9952d](https://github.com/gotgenes/pi-packages/commit/9d9952d1ee235086e9809d6cb50e1b31ae2fc3f6))
14
+ * **pi-permission-system:** resolve a child's missing extractor from its parent node ([91b3ef1](https://github.com/gotgenes/pi-packages/commit/91b3ef1918a827f0ee3f8c0acf13cee5ba5f72ef)), closes [#793](https://github.com/gotgenes/pi-packages/issues/793)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** record the fact-shaping inheritance rule (ADR 0012) ([dbdd9f1](https://github.com/gotgenes/pi-packages/commit/dbdd9f19f29a1a67d282fdc050a71b462021eb56))
19
+
20
+ <!-- Entries below this point were generated by release-please, which this
21
+ repository used until 2026-09. Some record releases made in the
22
+ packages' predecessor repositories, before the monorepo existed. See
23
+ docs/decisions/0002-git-cliff-release-automation.md. -->
24
+
25
+ ## [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)
26
+
27
+
28
+ ### Features
29
+
30
+ * **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)
31
+
32
+
33
+ ### Documentation
34
+
35
+ * **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)
36
+
8
37
  ## [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
38
 
10
39
 
package/dist/public.d.ts CHANGED
@@ -572,6 +572,30 @@ interface PermissionsService extends PermissionQuery {
572
572
  * or `undefined` to decline.
573
573
  */
574
574
  registerToolAccessExtractor(toolName: string, extractor: ToolAccessExtractor): () => void;
575
+ /**
576
+ * The access extractor registered on this node for `toolName`, or
577
+ * `undefined` when it has none.
578
+ *
579
+ * This is the read face of a **fact-shaping** registry, and unlike the
580
+ * authority surfaces it is meant to be read across a node boundary: an
581
+ * extractor produces a fact about a call (the path it touches) and decides
582
+ * nothing, so an in-process child whose own registry has no entry may
583
+ * resolve an ancestor node's service and use its answer to complete the
584
+ * child's own fact-gathering (ADR 0012 decision 1).
585
+ *
586
+ * The same does not hold for {@link registerAuthorizer}: a link produces a
587
+ * verdict, and live authority converges at the adjudicating node (ADR 0007
588
+ * §7). There is deliberately no reader for it.
589
+ */
590
+ getToolAccessExtractor(toolName: string): ToolAccessExtractor | undefined;
591
+ /**
592
+ * The preview formatter registered on this node for `toolName`, or
593
+ * `undefined` when it has none.
594
+ *
595
+ * Fact-shaping, and cross-node readable for the same reason as
596
+ * {@link getToolAccessExtractor}.
597
+ */
598
+ getToolInputFormatter(toolName: string): ToolInputFormatter | undefined;
575
599
  /**
576
600
  * Register a named live-authority chain link (ADR 0007 §4).
577
601
  *
@@ -1147,6 +1147,8 @@ Additional behaviors:
1147
1147
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1148
1148
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
1149
1149
  - Permission review logs include `toolInputPreview` values for non-bash/non-MCP tool calls, with sensitive-keyed values masked and every value bounded by `reviewLogFieldMaxWidth` (see [Log file sensitivity](#log-file-sensitivity))
1150
+ - A tool whose path came from an extractor registered in an **ancestor** session rather than this one records `extractorSource: "inherited"` beside the decision; the field is absent for every path this session resolved itself.
1151
+ This happens in a subagent child when the extractor's provider was kept out of the child but the tool's own package was not — the child borrows the declaration so its `path` and `external_directory` gates still see the path (see [Subagent Integration](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/subagent-integration.md#loading-asymmetry))
1150
1152
 
1151
1153
  ---
1152
1154
 
@@ -92,6 +92,22 @@ interface PermissionsService {
92
92
  toolName: string,
93
93
  extractor: (input: Record<string, unknown>) => string | undefined,
94
94
  ): () => void;
95
+
96
+ /**
97
+ * The access extractor registered on this node for `toolName`, or
98
+ * `undefined` when it has none.
99
+ */
100
+ getToolAccessExtractor(
101
+ toolName: string,
102
+ ): ((input: Record<string, unknown>) => string | undefined) | undefined;
103
+
104
+ /**
105
+ * The preview formatter registered on this node for `toolName`, or
106
+ * `undefined` when it has none.
107
+ */
108
+ getToolInputFormatter(
109
+ toolName: string,
110
+ ): ((input: Record<string, unknown>) => string | undefined) | undefined;
95
111
  }
96
112
  ```
97
113
 
@@ -280,6 +296,22 @@ const dispose = permissions.registerToolAccessExtractor("ffgrep", (input) =>
280
296
  Registration rules mirror `registerToolInputFormatter`: one extractor per tool name (a second `register` for the same name throws), and the returned disposer is identity-guarded.
281
297
  The extractor must not throw — guard your parsing and return `undefined` on anything unexpected.
282
298
 
299
+ #### `getToolAccessExtractor` and `getToolInputFormatter`
300
+
301
+ Read back what a node has registered for a tool.
302
+
303
+ ```typescript
304
+ getToolAccessExtractor(toolName: string): ToolAccessExtractor | undefined;
305
+ getToolInputFormatter(toolName: string): ToolInputFormatter | undefined;
306
+ ```
307
+
308
+ These are the read face of the two **fact-shaping** registries, and unlike every other surface here they are meant to be read across a node boundary.
309
+ An extractor produces a fact about a call (the path it touches) and a formatter produces display text; neither decides anything, so a node whose own registry has no entry may resolve an ancestor's service and use its answer.
310
+ The permission system does exactly that internally: a subagent child that is missing an extractor for a tool falls back to its ancestors in the same process, so excluding an extractor's provider from child sessions cannot leave that tool's path invisible to the child's gates ([ADR 0012] decision 1, the fact-shaping clause).
311
+
312
+ There is deliberately **no** equivalent reader for `registerAuthorizer`.
313
+ A chain link returns a verdict, and live authority converges at the adjudicating node ([ADR 0007] §7) — inheriting one would run authority an operator's own extension exclusion removed.
314
+
283
315
  #### Subagent session registration
284
316
 
285
317
  Subagent registration is announcement-driven, and the spawner makes no service call.
@@ -514,3 +546,7 @@ pi.on("session_shutdown", () => {
514
546
 
515
547
  A registration needs no branch on `adjudicatesLocally`.
516
548
  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.
549
+ Registering on _every_ node also stays the best practice for a formatter or extractor provider: the ancestor fallback is a repair for a node that could not register, not a reason to register in one place on purpose.
550
+
551
+ [ADR 0007]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0007-model-judge-authorizer-chain-adr.md
552
+ [ADR 0012]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md
@@ -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:
@@ -62,17 +81,28 @@ Three statements hold:
62
81
  Nothing throws and nothing warns per child start.
63
82
  2. **Excluding an extension from children is an optimization, never a correctness requirement.**
64
83
  Excluding a link-only extension saves load time; the adjudicating node's own instance still judges every descendant ask.
65
- 3. **Excluding a provider of access extractors can weaken a child's own gates.**
66
- This is the one real hazard, and it is narrower than it sounds: excluding a package also keeps that package's tools out of children, so a package that supplies both a tool and that tool's extractor takes both away and leaves no gap.
67
- A gap needs the tool and its extractor to come from *different* packages, with only the extractor's package excluded.
84
+ 3. **Excluding a provider of access extractors no longer weakens an in-process child's gates.**
85
+ The hazard was narrower than it sounded: excluding a package also keeps that package's tools out of children, so a package supplying both a tool and that tool's extractor takes both away and leaves no gap.
86
+ A gap needed the tool and its extractor to come from *different* packages, with only the extractor's package excluded.
87
+ That case is now closed — a child whose own registry has no extractor for a tool resolves one from its ancestors in the same process, and the same holds for preview formatters.
88
+
89
+ Registration is unchanged by that: an extractor still lands only in the registry of the node whose extension registered it.
90
+ What crosses a node boundary is the **lookup**, and only for fact-shaping registrations — an extractor produces a path and a formatter produces display text, so neither carries authority.
91
+ Chain links are excluded by category: a link returns a verdict, and live authority converges at the adjudicating node ([ADR 0007] §7).
92
+
93
+ A decision that used an inherited extractor records `extractorSource: "inherited"` in the review log, so a child's dependence on another node's registration is visible where the decision is.
68
94
 
69
- The full condition, with a worked example, is documented where the setting lives: [Excluding package extensions from children](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/configuration.md#excluding-package-extensions-from-children).
95
+ One residual: this repair is in-process only.
96
+ A child in its own process shares no `globalThis`, so it reaches no ancestor's service, and an extractor is a closure that cannot be serialized to one.
97
+ No current implementation spawns out-of-process children with an asymmetric extension set, but for one that did, the by-hand check below would still be the only cover.
98
+
99
+ The condition, with a worked example, is documented where the setting lives: [Excluding package extensions from children](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/configuration.md#excluding-package-extensions-from-children).
70
100
 
71
101
  ## What this package does on both ends
72
102
 
73
103
  The announcement is all an implementation provides; this section is what it buys.
74
104
 
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`.
105
+ 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
106
  Because the event bus dispatches synchronously, that registration completes before `bindExtensions()` proceeds.
77
107
 
78
108
  The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`.
@@ -83,7 +113,10 @@ What the announcement enables:
83
113
 
84
114
  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
115
  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.
116
+ 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.
117
+ The parent records a `child_node_absent` review entry for every such child and warns once per session.
118
+ 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.
119
+ 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
120
  The parent approves or denies, and the child resumes.
88
121
  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
122
 
@@ -162,7 +195,7 @@ Nothing needs to be edited, and in-process children are unaffected: parent and c
162
195
 
163
196
  ## Conformance of known implementations
164
197
 
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.
198
+ 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
199
 
167
200
  | Extension | Shape | Adopts the convention | Visibility key |
168
201
  | ----------------------------------------------------------------------------------- | ---------- | --------------------------------- | ---------------------------------- |
@@ -177,6 +210,9 @@ Adopting the convention is a one-line change at their spawn site.
177
210
 
178
211
  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
212
 
213
+ `@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.
214
+ The others forfeit that alarm without forfeiting conformance.
215
+
180
216
  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
217
 
182
218
  ---
@@ -232,5 +268,6 @@ permission:
232
268
 
233
269
  In this example the subagent extension restricts visibility to `bash` and `read`, and the permission system then gates every `bash` call with an `ask` prompt - both rules apply independently.
234
270
 
271
+ [ADR 0007]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0007-model-judge-authorizer-chain-adr.md
235
272
  [ADR 0012]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md
236
273
  [ADR-0002]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md
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.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,24 @@ export function getPathBearingToolPath(
13
13
  return getNonEmptyString(toRecord(input).path);
14
14
  }
15
15
 
16
+ /**
17
+ * What supplied a tool call's path.
18
+ *
19
+ * `"convention"` covers both the built-in `input.path` / `input.arguments.path`
20
+ * shapes and the default an unregistered extension tool falls back to — in
21
+ * every case, nobody declared it.
22
+ */
23
+ export type ToolPathSource =
24
+ | "convention"
25
+ | "local_extractor"
26
+ | "inherited_extractor";
27
+
28
+ /** A tool call's path together with what supplied it. */
29
+ export interface ToolInputPathResult {
30
+ path: string | null;
31
+ source: ToolPathSource;
32
+ }
33
+
16
34
  /**
17
35
  * Extract the filesystem path a tool will access, for the cross-cutting `path`
18
36
  * and `external_directory` gates.
@@ -25,28 +43,41 @@ export function getPathBearingToolPath(
25
43
  * - `mcp` → `input.arguments.path`.
26
44
  * - Any other tool → a registered {@link ToolAccessExtractor}'s path, else the
27
45
  * default `input.path` convention.
46
+ *
47
+ * The result names what supplied the path, because an extractor resolved from
48
+ * an ancestor node is a fact the gates record (ADR 0012 decision 1).
28
49
  */
29
50
  export function getToolInputPath(
30
51
  toolName: string,
31
52
  input: unknown,
32
53
  extractors?: ToolAccessExtractorLookup,
33
- ): string | null {
54
+ ): ToolInputPathResult {
34
55
  const record = toRecord(input);
35
56
 
36
57
  switch (classifyToolKind(toolName)) {
37
58
  case "bash":
38
- return null;
59
+ return byConvention(null);
39
60
  case "path":
40
- return getNonEmptyString(record.path);
61
+ return byConvention(getNonEmptyString(record.path));
41
62
  case "mcp":
42
- return getNonEmptyString(toRecord(record.arguments).path);
63
+ return byConvention(getNonEmptyString(toRecord(record.arguments).path));
43
64
  case "skill":
44
65
  case "extension": {
45
- const custom = extractors?.get(toolName);
66
+ const custom = extractors?.resolve(toolName);
46
67
  if (custom) {
47
- return getNonEmptyString(custom(record));
68
+ return {
69
+ path: getNonEmptyString(custom.extractor(record)),
70
+ source:
71
+ custom.origin === "inherited"
72
+ ? "inherited_extractor"
73
+ : "local_extractor",
74
+ };
48
75
  }
49
- return getNonEmptyString(record.path);
76
+ return byConvention(getNonEmptyString(record.path));
50
77
  }
51
78
  }
52
79
  }
80
+
81
+ function byConvention(path: string | null): ToolInputPathResult {
82
+ return { path, source: "convention" };
83
+ }
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * inherited-registrations.ts — Complete a child node's fact-shaping lookups
3
+ * from its ancestors in the same process.
4
+ *
5
+ * Registrations are node-local (ADR 0012 decision 1): an extractor or formatter
6
+ * lands in the registry of the node whose extension registered it. That is
7
+ * unchanged here. What this module adds is the decision's fact-shaping clause —
8
+ * a **lookup** may cross an in-process node boundary, because an extractor
9
+ * produces a path and a formatter produces display text, and neither decides
10
+ * anything.
11
+ *
12
+ * The gap it closes is the split-provider condition (#793). Excluding a package
13
+ * from child sessions normally removes its tools and their extractors together,
14
+ * so nothing is weakened. But when package A registers a tool whose path lives
15
+ * under a non-standard key and package B registers the extractor for it,
16
+ * excluding B alone leaves the child with the tool and no way to see its path:
17
+ * the `path` and `external_directory` gates never run for that call, and the
18
+ * parent's own gating is unaffected, so the weakening is visible nowhere.
19
+ *
20
+ * Why this direction is safe, and why it stops here:
21
+ *
22
+ * - It is monotone. Without an extractor the gate does not run at all, so
23
+ * resolving one can only add a check, and the four path layers compose
24
+ * most-restrictive-wins — an inherited extractor can never loosen a decision.
25
+ * - It carries no authority. Live authority converges at the adjudicating node
26
+ * (ADR 0007 §7), so there is deliberately **no** equivalent for the authorizer
27
+ * registry: a link returns a verdict, and inheriting one would run authority
28
+ * an operator's own `excludedExtensionPackages` removed.
29
+ *
30
+ * In-process only, by construction: an out-of-process child shares no
31
+ * `globalThis`, so it reaches no ancestor service, and an extractor is a
32
+ * closure that cannot be serialized to one.
33
+ */
34
+
35
+ import type { PermissionsService } from "#src/service";
36
+ import type {
37
+ ResolvedToolAccessExtractor,
38
+ ToolAccessExtractorLookup,
39
+ } from "#src/tool-access-extractor-registry";
40
+ import type {
41
+ ToolInputFormatter,
42
+ ToolInputFormatterLookup,
43
+ } from "#src/tool-input-formatter-registry";
44
+
45
+ /** This node's own session id, or `null` when the host exposes none. */
46
+ export interface NodeIdentity {
47
+ currentSessionId(): string | null;
48
+ }
49
+
50
+ /**
51
+ * The read shape {@link AncestorNodes} needs from the subagent registry (ISP):
52
+ * which node spawned the one named by `sessionId`.
53
+ */
54
+ export interface ParentChainRegistry {
55
+ get(sessionId: string): { parentSessionId?: string } | undefined;
56
+ }
57
+
58
+ /** Resolves a node's published service by session id. */
59
+ export type PermissionsServiceLocator = (
60
+ sessionId: string,
61
+ ) => PermissionsService | undefined;
62
+
63
+ /**
64
+ * This node's ancestors in the current process, nearest first.
65
+ *
66
+ * Binds the three collaborators the walk needs so each lookup below takes one
67
+ * of these rather than repeating them.
68
+ */
69
+ export class AncestorNodes {
70
+ constructor(
71
+ private readonly node: NodeIdentity,
72
+ private readonly registry: ParentChainRegistry,
73
+ private readonly locate: PermissionsServiceLocator,
74
+ ) {}
75
+
76
+ /**
77
+ * The first non-`undefined` answer `pick` gives for an ancestor's service.
78
+ *
79
+ * The walk is transitive rather than one hop: an exclusion applies to every
80
+ * descendant equally, so in a nested spawn the grandchild's own parent is
81
+ * missing the same registration it is.
82
+ *
83
+ * A hop that published no service is stepped over rather than ending the
84
+ * walk — the registry still names that node's own parent — and each hop is
85
+ * resolved through the locator per call, never cached, so a torn-down node
86
+ * simply stops answering.
87
+ */
88
+ findFirst<T>(
89
+ pick: (service: PermissionsService) => T | undefined,
90
+ ): T | undefined {
91
+ let sessionId = this.node.currentSessionId();
92
+ // A malformed chain must not hang a tool call; a node is consulted once.
93
+ const visited = new Set<string>();
94
+
95
+ while (sessionId !== null && !visited.has(sessionId)) {
96
+ visited.add(sessionId);
97
+ const parentSessionId = this.registry.get(sessionId)?.parentSessionId;
98
+ if (parentSessionId === undefined) {
99
+ return undefined;
100
+ }
101
+ const service = this.locate(parentSessionId);
102
+ const answer = service ? pick(service) : undefined;
103
+ if (answer !== undefined) {
104
+ return answer;
105
+ }
106
+ sessionId = parentSessionId;
107
+ }
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * An extractor lookup that falls back to this node's ancestors.
114
+ *
115
+ * The local registry always wins, so a child that registers its own extractor
116
+ * for a tool keeps it, and the fallback is consulted only on a miss.
117
+ */
118
+ export class InheritingToolAccessExtractorLookup
119
+ implements ToolAccessExtractorLookup
120
+ {
121
+ constructor(
122
+ private readonly local: ToolAccessExtractorLookup,
123
+ private readonly ancestors: AncestorNodes,
124
+ ) {}
125
+
126
+ resolve(toolName: string): ResolvedToolAccessExtractor | undefined {
127
+ const own = this.local.resolve(toolName);
128
+ if (own) {
129
+ return own;
130
+ }
131
+ const inherited = this.ancestors.findFirst((service) =>
132
+ service.getToolAccessExtractor(toolName),
133
+ );
134
+ return inherited
135
+ ? { extractor: inherited, origin: "inherited" }
136
+ : undefined;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * A formatter lookup that falls back to this node's ancestors.
142
+ *
143
+ * Unlike the extractor above it reports no origin, because its consumer records
144
+ * none: a formatter's effect is the rendered preview itself, and the child
145
+ * builds that preview and forwards it to whichever node renders the ask.
146
+ */
147
+ export class InheritingToolInputFormatterLookup
148
+ implements ToolInputFormatterLookup
149
+ {
150
+ constructor(
151
+ private readonly local: ToolInputFormatterLookup,
152
+ private readonly ancestors: AncestorNodes,
153
+ ) {}
154
+
155
+ get(toolName: string): ToolInputFormatter | undefined {
156
+ return (
157
+ this.local.get(toolName) ??
158
+ this.ancestors.findFirst((service) =>
159
+ service.getToolInputFormatter(toolName),
160
+ )
161
+ );
162
+ }
163
+ }
@@ -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
  }
@@ -7,7 +7,11 @@ import { SessionApproval } from "#src/session-approval";
7
7
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
10
- import { accessFactsFromPath } from "./helpers";
10
+ import {
11
+ accessFactsFromPath,
12
+ buildPathGateLogContext,
13
+ buildPathGatePromptDetails,
14
+ } from "./helpers";
11
15
  import type { ToolCallContext } from "./types";
12
16
 
13
17
  /**
@@ -25,7 +29,7 @@ export function describeExternalDirectoryGate(
25
29
  normalizer: PathNormalizer,
26
30
  extractors?: ToolAccessExtractorLookup,
27
31
  ): GateResult {
28
- const externalDirectoryPath = getToolInputPath(
32
+ const { path: externalDirectoryPath, source: pathSource } = getToolInputPath(
29
33
  tcc.toolName,
30
34
  tcc.input,
31
35
  extractors,
@@ -49,13 +53,11 @@ export function describeExternalDirectoryGate(
49
53
  decidedBy: { kind: "infrastructure_read" },
50
54
  log: {
51
55
  event: "permission_request.infrastructure_auto_allowed",
52
- details: {
53
- source: "tool_call",
54
- toolCallId: tcc.toolCallId,
55
- toolName: tcc.toolName,
56
- agentName: tcc.agentName,
57
- path: externalDirectoryPath,
58
- },
56
+ details: buildPathGateLogContext(
57
+ tcc,
58
+ externalDirectoryPath,
59
+ pathSource,
60
+ ),
59
61
  },
60
62
  decision: {
61
63
  surface: tcc.toolName,
@@ -101,21 +103,12 @@ export function describeExternalDirectoryGate(
101
103
  preCheck,
102
104
  payload,
103
105
  sessionApproval: SessionApproval.single(surface, pattern),
104
- promptDetails: {
105
- source: "tool_call",
106
- agentName: tcc.agentName,
107
- toolCallId: tcc.toolCallId,
108
- toolName: tcc.toolName,
109
- path: externalDirectoryPath,
110
- accessIntent: accessFactsFromPath(surface, accessPath),
111
- },
112
- logContext: {
113
- source: "tool_call",
114
- toolCallId: tcc.toolCallId,
115
- toolName: tcc.toolName,
116
- agentName: tcc.agentName,
117
- path: externalDirectoryPath,
118
- },
106
+ promptDetails: buildPathGatePromptDetails(
107
+ tcc,
108
+ externalDirectoryPath,
109
+ accessFactsFromPath(surface, accessPath),
110
+ ),
111
+ logContext: buildPathGateLogContext(tcc, externalDirectoryPath, pathSource),
119
112
  decision: {
120
113
  surface,
121
114
  value: externalDirectoryPath,
@@ -1,9 +1,75 @@
1
1
  import type { AccessPath } from "#src/access-intent/access-path";
2
+ import type { ToolPathSource } from "#src/access-intent/tool-input-path";
2
3
  import { classifyToolKind } from "#src/access-intent/tool-kind";
3
4
  import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
5
+ import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
4
6
  import type { PermissionDecisionResolution } from "#src/permission-events";
5
7
  import type { PermissionCheckResult } from "#src/types";
6
8
  import type { DecisionEventFacts } from "./descriptor";
9
+ import type { ToolCallContext } from "./types";
10
+
11
+ /**
12
+ * The identity fields every path-shaped tool gate reports about its call.
13
+ *
14
+ * Narrower than {@link ToolCallContext} (ISP): the fact builders below read
15
+ * who asked and which call it was, never the raw input or the cwd.
16
+ */
17
+ type PathGateRequestFacts = Pick<
18
+ ToolCallContext,
19
+ "toolCallId" | "toolName" | "agentName"
20
+ >;
21
+
22
+ /**
23
+ * Build the review-log context for a path-shaped tool gate.
24
+ *
25
+ * The `path` and `external_directory` gates report the same five facts about a
26
+ * call, so they share one builder — a field added here reaches both, and the
27
+ * two cannot drift.
28
+ * The request facts and the request id are stamped by the runner, not here.
29
+ *
30
+ * `pathSource` adds `extractorSource` only when the path came from an
31
+ * **inherited** extractor — a decision that depended on another node's
32
+ * registration says so, and every other decision stays exactly as wide as it
33
+ * was. Stamping the ordinary case too would put a constant on effectively
34
+ * every record in the log.
35
+ */
36
+ export function buildPathGateLogContext(
37
+ tcc: PathGateRequestFacts,
38
+ path: string,
39
+ pathSource?: ToolPathSource,
40
+ ): Record<string, unknown> {
41
+ return {
42
+ source: "tool_call",
43
+ toolCallId: tcc.toolCallId,
44
+ toolName: tcc.toolName,
45
+ agentName: tcc.agentName,
46
+ path,
47
+ ...(pathSource === "inherited_extractor"
48
+ ? { extractorSource: "inherited" }
49
+ : {}),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Build the prompt details for a path-shaped tool gate.
55
+ *
56
+ * The same five facts as {@link buildPathGateLogContext}, plus the child-fixed
57
+ * access facts the ask carries onto the wire.
58
+ */
59
+ export function buildPathGatePromptDetails(
60
+ tcc: PathGateRequestFacts,
61
+ path: string,
62
+ accessIntent: ForwardedAccessFacts,
63
+ ): Omit<PromptPermissionDetails, "requestId" | "payload"> {
64
+ return {
65
+ source: "tool_call",
66
+ agentName: tcc.agentName,
67
+ toolCallId: tcc.toolCallId,
68
+ toolName: tcc.toolName,
69
+ path,
70
+ accessIntent,
71
+ };
72
+ }
7
73
 
8
74
  /**
9
75
  * Build the child-fixed access facts for a path-shaped gate from its
@@ -6,7 +6,11 @@ import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
6
6
  import { SessionApproval } from "#src/session-approval";
7
7
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
8
8
  import type { GateDescriptor, GateResult } from "./descriptor";
9
- import { accessFactsFromPath } from "./helpers";
9
+ import {
10
+ accessFactsFromPath,
11
+ buildPathGateLogContext,
12
+ buildPathGatePromptDetails,
13
+ } from "./helpers";
10
14
  import type { ToolCallContext } from "./types";
11
15
 
12
16
  /**
@@ -23,7 +27,11 @@ export function describePathGate(
23
27
  normalizer: PathNormalizer,
24
28
  extractors?: ToolAccessExtractorLookup,
25
29
  ): GateResult {
26
- const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
30
+ const { path: filePath, source: pathSource } = getToolInputPath(
31
+ tcc.toolName,
32
+ tcc.input,
33
+ extractors,
34
+ );
27
35
  if (!filePath) return null;
28
36
 
29
37
  // The narrowest `path`-family surface this tool's identity proves. A tool
@@ -66,21 +74,12 @@ export function describePathGate(
66
74
  input: { path: filePath },
67
75
  payload,
68
76
  sessionApproval: SessionApproval.single(surface, pattern),
69
- promptDetails: {
70
- source: "tool_call",
71
- agentName: tcc.agentName,
72
- toolCallId: tcc.toolCallId,
73
- toolName: tcc.toolName,
74
- path: filePath,
75
- accessIntent: accessFactsFromPath(surface, accessPath),
76
- },
77
- logContext: {
78
- source: "tool_call",
79
- toolCallId: tcc.toolCallId,
80
- toolName: tcc.toolName,
81
- agentName: tcc.agentName,
82
- path: filePath,
83
- },
77
+ promptDetails: buildPathGatePromptDetails(
78
+ tcc,
79
+ filePath,
80
+ accessFactsFromPath(surface, accessPath),
81
+ ),
82
+ logContext: buildPathGateLogContext(tcc, filePath, pathSource),
84
83
  decision: {
85
84
  surface,
86
85
  value: filePath,
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,
@@ -16,6 +17,11 @@ import {
16
17
  ServingHeartbeatStore,
17
18
  } from "./authority/forwarding-liveness";
18
19
  import { ForwardingManager } from "./authority/forwarding-manager";
20
+ import {
21
+ AncestorNodes,
22
+ InheritingToolAccessExtractorLookup,
23
+ InheritingToolInputFormatterLookup,
24
+ } from "./authority/inherited-registrations";
19
25
  import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
20
26
  import { requestPermissionDecision } from "./authority/permission-prompt-component";
21
27
  import { PermissionPrompter } from "./authority/permission-prompter";
@@ -50,7 +56,7 @@ import { PermissionResolver } from "./permission-resolver";
50
56
  import { PermissionSession } from "./permission-session";
51
57
  import { LocalPermissionsService } from "./permissions-service";
52
58
  import { resolveRenderBudget } from "./presentation/dialog-renderer";
53
- import type { PermissionsService } from "./service";
59
+ import { getPermissionsService, type PermissionsService } from "./service";
54
60
  import { PermissionServiceLifecycle } from "./service-lifecycle";
55
61
  import { PermissionSessionLogger } from "./session-logger";
56
62
  import { SessionRules } from "./session-rules";
@@ -259,10 +265,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
259
265
  );
260
266
 
261
267
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
262
- // sessions register/unregister without the core calling us (ADR 0002).
268
+ // sessions register/unregister without the core calling us (ADR 0002), and
269
+ // so a child that bound its extensions without loading one of ours is
270
+ // reported rather than silently ungated (#792). The lookup is a thunk over
271
+ // the locator, never a cached reference, per the guidance in service.ts.
272
+ const childNodeAudit = new ChildNodeAudit(
273
+ (sessionId) => getPermissionsService(sessionId) !== undefined,
274
+ logger,
275
+ );
263
276
  const unsubSubagentLifecycle = subscribeSubagentLifecycle(
264
277
  pi.events,
265
278
  subagentRegistry,
279
+ childNodeAudit,
266
280
  );
267
281
 
268
282
  // PermissionServiceLifecycle owns the process-global service publication:
@@ -313,11 +327,25 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
313
327
  reporter,
314
328
  isYoloEnabled,
315
329
  );
330
+ // This node's ancestors in the current process. The gates read their
331
+ // fact-shaping registrations through the inheriting lookups below, so a
332
+ // child whose own registry is missing an extractor still sees the path its
333
+ // tool touches (ADR 0012 decision 1, the fact-shaping clause; #793).
334
+ // Registration itself is untouched: the service's registrars still write to
335
+ // the undecorated registries, so an entry lands in this node alone.
336
+ const ancestorNodes = new AncestorNodes(
337
+ serviceLifecycle,
338
+ subagentRegistry,
339
+ getPermissionsService,
340
+ );
316
341
  const toolCallGatePipeline = new ToolCallGatePipeline(
317
342
  resolver,
318
343
  session,
319
- formatterRegistry,
320
- accessExtractorRegistry,
344
+ new InheritingToolInputFormatterLookup(formatterRegistry, ancestorNodes),
345
+ new InheritingToolAccessExtractorLookup(
346
+ accessExtractorRegistry,
347
+ ancestorNodes,
348
+ ),
321
349
  );
322
350
  const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
323
351
  const gates = new PermissionGateHandler(
@@ -7,10 +7,12 @@ import type { PathNormalizer } from "./path-normalizer";
7
7
  import type { PermissionsService } from "./service";
8
8
  import type {
9
9
  ToolAccessExtractor,
10
+ ToolAccessExtractorLookup,
10
11
  ToolAccessExtractorRegistrar,
11
12
  } from "./tool-access-extractor-registry";
12
13
  import type {
13
14
  ToolInputFormatter,
15
+ ToolInputFormatterLookup,
14
16
  ToolInputFormatterRegistrar,
15
17
  } from "./tool-input-formatter-registry";
16
18
  import type { PermissionCheckResult, PermissionState } from "./types";
@@ -44,8 +46,10 @@ export class LocalPermissionsService implements PermissionsService {
44
46
  constructor(
45
47
  private readonly resolver: ResolverForService,
46
48
  private readonly session: PathNormalizerProvider,
47
- private readonly formatterRegistry: ToolInputFormatterRegistrar,
48
- private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
49
+ private readonly formatterRegistry: ToolInputFormatterRegistrar &
50
+ ToolInputFormatterLookup,
51
+ private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar &
52
+ ToolAccessExtractorLookup,
49
53
  private readonly authorizerRegistry: AuthorizerRegistrar,
50
54
  ) {}
51
55
 
@@ -91,6 +95,20 @@ export class LocalPermissionsService implements PermissionsService {
91
95
  return this.accessExtractorRegistry.register(toolName, extractor);
92
96
  }
93
97
 
98
+ getToolAccessExtractor(
99
+ toolName: string,
100
+ ): ReturnType<PermissionsService["getToolAccessExtractor"]> {
101
+ // The origin is the gates' concern, not a caller's: this surface answers
102
+ // the capability, and where it came from rides the gate's log context.
103
+ return this.accessExtractorRegistry.resolve(toolName)?.extractor;
104
+ }
105
+
106
+ getToolInputFormatter(
107
+ toolName: string,
108
+ ): ReturnType<PermissionsService["getToolInputFormatter"]> {
109
+ return this.formatterRegistry.get(toolName);
110
+ }
111
+
94
112
  registerAuthorizer(
95
113
  name: string,
96
114
  authorize: Authorizer["authorize"],
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AdjudicationRole } from "./authority/authorizer-selection";
3
+ import type { NodeIdentity } from "./authority/inherited-registrations";
3
4
  import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
4
5
  import {
5
6
  type PermissionsService,
@@ -45,7 +46,7 @@ export interface ReadyAnnouncer {
45
46
  * superseded `/reload` generation cannot evict the fresh one.
46
47
  */
47
48
  export class PermissionServiceLifecycle
48
- implements ServiceLifecycle, ReadyAnnouncer
49
+ implements ServiceLifecycle, ReadyAnnouncer, NodeIdentity
49
50
  {
50
51
  /** The key this instance last published under; `null` until it publishes. */
51
52
  private publishedSessionId: string | null = null;
@@ -60,6 +61,17 @@ export class PermissionServiceLifecycle
60
61
  private readonly subscriptions: readonly (() => void)[],
61
62
  ) {}
62
63
 
64
+ /**
65
+ * This node's own session id, or `null` before it has published.
66
+ *
67
+ * Satisfies `NodeIdentity`: the fact-shaping lookups need to know which node
68
+ * they run in to find their ancestors, and this class already reads it from
69
+ * the context at `activate` and holds it — so identity keeps one home.
70
+ */
71
+ currentSessionId(): string | null {
72
+ return this.publishedSessionId;
73
+ }
74
+
63
75
  activate(ctx: ExtensionContext): void {
64
76
  // Re-arm: a new session generation gets its own post-session_start
65
77
  // announcement, so a consumer that loaded after this node still hears one.
package/src/service.ts CHANGED
@@ -168,6 +168,32 @@ export interface PermissionsService extends PermissionQuery {
168
168
  extractor: ToolAccessExtractor,
169
169
  ): () => void;
170
170
 
171
+ /**
172
+ * The access extractor registered on this node for `toolName`, or
173
+ * `undefined` when it has none.
174
+ *
175
+ * This is the read face of a **fact-shaping** registry, and unlike the
176
+ * authority surfaces it is meant to be read across a node boundary: an
177
+ * extractor produces a fact about a call (the path it touches) and decides
178
+ * nothing, so an in-process child whose own registry has no entry may
179
+ * resolve an ancestor node's service and use its answer to complete the
180
+ * child's own fact-gathering (ADR 0012 decision 1).
181
+ *
182
+ * The same does not hold for {@link registerAuthorizer}: a link produces a
183
+ * verdict, and live authority converges at the adjudicating node (ADR 0007
184
+ * §7). There is deliberately no reader for it.
185
+ */
186
+ getToolAccessExtractor(toolName: string): ToolAccessExtractor | undefined;
187
+
188
+ /**
189
+ * The preview formatter registered on this node for `toolName`, or
190
+ * `undefined` when it has none.
191
+ *
192
+ * Fact-shaping, and cross-node readable for the same reason as
193
+ * {@link getToolAccessExtractor}.
194
+ */
195
+ getToolInputFormatter(toolName: string): ToolInputFormatter | undefined;
196
+
171
197
  /**
172
198
  * Register a named live-authority chain link (ADR 0007 §4).
173
199
  *
@@ -12,12 +12,32 @@ export type ToolAccessExtractor = (
12
12
  input: Record<string, unknown>,
13
13
  ) => string | undefined;
14
14
 
15
+ /**
16
+ * Which node's registration answered a lookup.
17
+ *
18
+ * `"inherited"` means the answer came from an ancestor node in this process
19
+ * rather than from this node's own registry (ADR 0012 decision 1, the
20
+ * fact-shaping clause). A gate records it so a child's dependence on another
21
+ * node's registration is never invisible.
22
+ */
23
+ export type RegistrationOrigin = "local" | "inherited";
24
+
25
+ /** An extractor together with the node whose registry supplied it. */
26
+ export interface ResolvedToolAccessExtractor {
27
+ extractor: ToolAccessExtractor;
28
+ origin: RegistrationOrigin;
29
+ }
30
+
15
31
  /**
16
32
  * Read-only lookup used by the gate pipeline (ISP — exposes only the read
17
33
  * side, not the registration surface).
34
+ *
35
+ * It answers with the origin rather than a bare function because the gates
36
+ * report where a path came from; an implementation that can only answer from
37
+ * its own registry reports `"local"`.
18
38
  */
19
39
  export interface ToolAccessExtractorLookup {
20
- get(toolName: string): ToolAccessExtractor | undefined;
40
+ resolve(toolName: string): ResolvedToolAccessExtractor | undefined;
21
41
  }
22
42
 
23
43
  /**
@@ -62,7 +82,8 @@ export class ToolAccessExtractorRegistry
62
82
  };
63
83
  }
64
84
 
65
- get(toolName: string): ToolAccessExtractor | undefined {
66
- return this.extractors.get(toolName);
85
+ resolve(toolName: string): ResolvedToolAccessExtractor | undefined {
86
+ const extractor = this.extractors.get(toolName);
87
+ return extractor ? { extractor, origin: "local" } : undefined;
67
88
  }
68
89
  }