@gotgenes/pi-permission-system 29.1.0 → 29.3.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 +33 -0
- package/dist/public.d.ts +24 -0
- package/docs/configuration.md +4 -0
- package/docs/cross-extension-api.md +36 -0
- package/docs/subagent-integration.md +16 -4
- package/package.json +6 -6
- package/schemas/permissions.schema.json +73 -1
- package/src/access-intent/tool-input-path.ts +38 -7
- package/src/authority/inherited-registrations.ts +163 -0
- package/src/config-schema.ts +89 -43
- package/src/config-store.ts +0 -2
- package/src/handlers/gates/external-directory.ts +17 -24
- package/src/handlers/gates/helpers.ts +66 -0
- package/src/handlers/gates/path.ts +16 -17
- package/src/index.ts +21 -2
- package/src/normalize.ts +5 -0
- package/src/permissions-service.ts +20 -2
- package/src/service-lifecycle.ts +13 -1
- package/src/service.ts +26 -0
- package/src/tool-access-extractor-registry.ts +24 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.2.0...pi-permission-system-v29.3.0) (2026-09-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** name and document the well-known permission surfaces ([#808](https://github.com/gotgenes/pi-packages/issues/808)) ([95ef9ab](https://github.com/gotgenes/pi-packages/commit/95ef9ab9c00ee09d4dda820d43abc7fe7e5ff1b2)), closes [#808](https://github.com/gotgenes/pi-packages/issues/808)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** document the named permission surfaces ([#808](https://github.com/gotgenes/pi-packages/issues/808)) ([03c6a52](https://github.com/gotgenes/pi-packages/commit/03c6a5228aa050ac940d851e6c219e62062acbd6))
|
|
18
|
+
|
|
19
|
+
### Miscellaneous Chores
|
|
20
|
+
|
|
21
|
+
* upgrade lint tooling and pin rumdl below the MD013 reflow regression ([0ee1ad8](https://github.com/gotgenes/pi-packages/commit/0ee1ad886815e6e50d99f66050fbbcf5a9f0319a)), closes [#866](https://github.com/gotgenes/pi-packages/issues/866)
|
|
22
|
+
* upgrade fallow to 3.22.0 ([1b2a562](https://github.com/gotgenes/pi-packages/commit/1b2a5620bf5d7f1014676b47e58df8b43ff00496)), closes [#866](https://github.com/gotgenes/pi-packages/issues/866)
|
|
23
|
+
|
|
24
|
+
## [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)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** expose the registered extractor and formatter for a tool ([9d9952d](https://github.com/gotgenes/pi-packages/commit/9d9952d1ee235086e9809d6cb50e1b31ae2fc3f6))
|
|
30
|
+
* **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)
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** record the fact-shaping inheritance rule (ADR 0012) ([dbdd9f1](https://github.com/gotgenes/pi-packages/commit/dbdd9f19f29a1a67d282fdc050a71b462021eb56))
|
|
35
|
+
|
|
36
|
+
<!-- Entries below this point were generated by release-please, which this
|
|
37
|
+
repository used until 2026-09. Some record releases made in the
|
|
38
|
+
packages' predecessor repositories, before the monorepo existed. See
|
|
39
|
+
docs/decisions/0002-git-cliff-release-automation.md. -->
|
|
40
|
+
|
|
8
41
|
## [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
42
|
|
|
10
43
|
|
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
|
*
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
|
@@ -1202,4 +1204,6 @@ npx --yes ajv-cli@5 validate \
|
|
|
1202
1204
|
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json"
|
|
1203
1205
|
```
|
|
1204
1206
|
|
|
1207
|
+
The well-known surface keys — `*`, `path`, `external_directory`, `bash`, `mcp`, `skill`, and the four directional keys — are named properties in the schema, so an editor completes them and shows each key's own documentation on hover; any other registered tool name still validates as a surface.
|
|
1208
|
+
|
|
1205
1209
|
The schema is generated from the extension's zod source of truth (`src/config-schema.ts`); regenerate it with `pnpm run gen:schema` after changing the config shape.
|
|
@@ -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
|
|
@@ -81,11 +81,22 @@ Three statements hold:
|
|
|
81
81
|
Nothing throws and nothing warns per child start.
|
|
82
82
|
2. **Excluding an extension from children is an optimization, never a correctness requirement.**
|
|
83
83
|
Excluding a link-only extension saves load time; the adjudicating node's own instance still judges every descendant ask.
|
|
84
|
-
3. **Excluding a provider of access extractors
|
|
85
|
-
|
|
86
|
-
A gap
|
|
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.
|
|
87
88
|
|
|
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.
|
|
94
|
+
|
|
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).
|
|
89
100
|
|
|
90
101
|
## What this package does on both ends
|
|
91
102
|
|
|
@@ -257,5 +268,6 @@ permission:
|
|
|
257
268
|
|
|
258
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.
|
|
259
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
|
|
260
272
|
[ADR 0012]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md
|
|
261
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.
|
|
3
|
+
"version": "29.3.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -67,15 +67,15 @@
|
|
|
67
67
|
"@earendil-works/pi-tui": ">=0.79.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@biomejs/biome": "^2.
|
|
70
|
+
"@biomejs/biome": "^2.5.11",
|
|
71
71
|
"@earendil-works/pi-coding-agent": "0.79.1",
|
|
72
72
|
"@earendil-works/pi-tui": "0.79.1",
|
|
73
73
|
"@types/node": "^22.15.3",
|
|
74
|
-
"rollup": "^4.
|
|
75
|
-
"rollup-plugin-dts": "^6.
|
|
76
|
-
"rumdl": "
|
|
74
|
+
"rollup": "^4.63.1",
|
|
75
|
+
"rollup-plugin-dts": "^6.5.1",
|
|
76
|
+
"rumdl": "0.2.24",
|
|
77
77
|
"typescript": "^6.0.3",
|
|
78
|
-
"vitest": "^4.1.
|
|
78
|
+
"vitest": "^4.1.11"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
81
|
"tree-sitter-bash": "^0.25.1",
|
|
@@ -102,6 +102,30 @@
|
|
|
102
102
|
"permission": {
|
|
103
103
|
"type": "object",
|
|
104
104
|
"properties": {
|
|
105
|
+
"*": {
|
|
106
|
+
"description": "Universal fallback — the action used when no surface-specific rule matches. Omitted, it defaults to ask.",
|
|
107
|
+
"markdownDescription": "Universal fallback — the action used when **no** surface-specific rule matches.\n\n`{ \"*\": \"ask\" }` is the least-privilege posture, and is what an omitted `\"*\"` means anyway. It replaces `defaultPolicy.tools` from the legacy config format.\n\nA surface-specific rule always beats it, whatever the key order in the file.",
|
|
108
|
+
"anyOf": [
|
|
109
|
+
{
|
|
110
|
+
"$ref": "#/$defs/permissionState"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"$ref": "#/$defs/permissionMap"
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
"path": {
|
|
118
|
+
"description": "Cross-cutting gate for file access by path pattern, across every path-aware tool. Sugar for both directions.",
|
|
119
|
+
"markdownDescription": "Cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor).\n\nA `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from every path-aware tool at once.\n\nThis bare key is **sugar**: it expands at load into `path_read` and `path_write`, its entries placed first, so an explicit directional entry always has the final say whatever the key order in the file.",
|
|
120
|
+
"anyOf": [
|
|
121
|
+
{
|
|
122
|
+
"$ref": "#/$defs/permissionState"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"$ref": "#/$defs/permissionMap"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
},
|
|
105
129
|
"path_read": {
|
|
106
130
|
"description": "Cross-cutting gate for reading a file, by path pattern. The useful directional grant.",
|
|
107
131
|
"markdownDescription": "Cross-cutting gate for **reading** a file, matched by path pattern across all path-aware tools.\n\nThis is the directional key worth granting: `\"path_read\": { \"~/dev/*\": \"allow\" }` permits reads without permitting writes.\n\nA bare `\"path\"` key is sugar that expands into this key **and** `path_write`, with its entries placed first — so an explicit `path_read` entry always has the final say, whatever the key order in the file.",
|
|
@@ -126,6 +150,18 @@
|
|
|
126
150
|
}
|
|
127
151
|
]
|
|
128
152
|
},
|
|
153
|
+
"external_directory": {
|
|
154
|
+
"description": "Boundary gate for access outside the session working directory. Sugar for both directions.",
|
|
155
|
+
"markdownDescription": "Boundary gate for access **outside** the session working directory.\n\nGive it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `{ \"*\": \"ask\", \"~/.cargo/registry/*\": \"allow\" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself.\n\nBecause layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\nThis bare key is **sugar**: it expands at load into `external_directory_read` and `external_directory_write`, its entries placed first.",
|
|
156
|
+
"anyOf": [
|
|
157
|
+
{
|
|
158
|
+
"$ref": "#/$defs/permissionState"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"$ref": "#/$defs/permissionMap"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
129
165
|
"external_directory_read": {
|
|
130
166
|
"description": "Boundary gate for reading outside the working directory. The relief most asks want.",
|
|
131
167
|
"markdownDescription": "Boundary gate for **reading** a path outside the session working directory.\n\nThe one-line grant for an external root: `\"external_directory_read\": { \"~/dev/*\": \"allow\" }` silences repeated read prompts on a directory outside the tree while a write to the same path still prompts. No parallel `path_read` entry is needed.",
|
|
@@ -149,6 +185,42 @@
|
|
|
149
185
|
"$ref": "#/$defs/permissionMap"
|
|
150
186
|
}
|
|
151
187
|
]
|
|
188
|
+
},
|
|
189
|
+
"bash": {
|
|
190
|
+
"description": "Shell command execution, matched per top-level command in a chain. Most restrictive wins.",
|
|
191
|
+
"markdownDescription": "Shell command execution, matched by **command pattern**.\n\nA chain (`&&`, `||`, `;`, `|`, newline) is split into its top-level commands and each is matched independently, most-restrictive-wins — so `cd /repo && npm install x` is denied when `npm *` is. A command nested in a substitution, process substitution, or subshell is matched too, since it really runs.\n\nA leading env-var assignment is stripped before matching (`AWS_PROFILE=prod aws …` matches `aws *`), and a pattern ending in ` *` (space + wildcard) also matches the bare command (`git *` matches `git`). A pattern containing a chain operator never matches — write one pattern per command.\n\nA shell wrapper (`bash -c`, `eval`, `sudo`, `xargs`) is floored from `allow` to `ask`, so an opaque payload cannot ride a permissive rule.",
|
|
192
|
+
"anyOf": [
|
|
193
|
+
{
|
|
194
|
+
"$ref": "#/$defs/permissionState"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"$ref": "#/$defs/permissionMap"
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
},
|
|
201
|
+
"mcp": {
|
|
202
|
+
"description": "Registered MCP proxy tools, matched against targets derived from the tool input.",
|
|
203
|
+
"markdownDescription": "Registered MCP proxy tools, matched against targets derived from the tool input: a baseline op (`mcp_status`, `mcp_list`, `mcp_search`, `mcp_describe`, `mcp_connect`), a server name (`myServer`), a server/tool combination (`myServer:search`, `myServer_search`), or the generic `mcp_call`.\n\nBaseline discovery targets auto-allow whenever any explicit `mcp` allow rule exists.",
|
|
204
|
+
"anyOf": [
|
|
205
|
+
{
|
|
206
|
+
"$ref": "#/$defs/permissionState"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"$ref": "#/$defs/permissionMap"
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
},
|
|
213
|
+
"skill": {
|
|
214
|
+
"description": "Skill invocation, matched by skill name. The surface is `skill`, not `skills`.",
|
|
215
|
+
"markdownDescription": "Skill invocation, matched by skill name — the surface is `skill`, not `skills`.\n\nWildcards behave as everywhere else: `{ \"*\": \"ask\", \"dangerous-*\": \"deny\", \"librarian\": \"allow\" }`.",
|
|
216
|
+
"anyOf": [
|
|
217
|
+
{
|
|
218
|
+
"$ref": "#/$defs/permissionState"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"$ref": "#/$defs/permissionMap"
|
|
222
|
+
}
|
|
223
|
+
]
|
|
152
224
|
}
|
|
153
225
|
},
|
|
154
226
|
"additionalProperties": {
|
|
@@ -162,7 +234,7 @@
|
|
|
162
234
|
]
|
|
163
235
|
},
|
|
164
236
|
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
165
|
-
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface
|
|
237
|
+
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface: the `\"*\"` fallback, a well-known surface documented below, or any registered tool name.\n\nA **string** value is shorthand for `{ \"*\": action }` (a surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
166
238
|
"examples": [
|
|
167
239
|
{
|
|
168
240
|
"*": "ask",
|
|
@@ -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
|
-
):
|
|
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?.
|
|
66
|
+
const custom = extractors?.resolve(toolName);
|
|
46
67
|
if (custom) {
|
|
47
|
-
return
|
|
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,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
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -79,57 +79,103 @@ const surfaceValueSchema = z.union([
|
|
|
79
79
|
]);
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* The
|
|
83
|
-
* (ADR 0013 §3), named so editors offer autocomplete and hover documentation.
|
|
82
|
+
* The legal spellings of a directional surface key (ADR 0013 §3).
|
|
84
83
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
84
|
+
* This is the loader's allowlist — {@link rejectUnusableSurfaceKeys} rejects a
|
|
85
|
+
* key shaped like a directional surface that is not one of these — and the
|
|
86
|
+
* schema documents exactly these four as named properties. Nothing structural
|
|
87
|
+
* holds the two halves together, so a test in `config-schema.test.ts` does.
|
|
88
88
|
*/
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
external_directory_write: {
|
|
112
|
-
description: "Boundary gate for writing outside the working directory.",
|
|
113
|
-
markdownDescription:
|
|
114
|
-
'Boundary gate for **writing** to a path outside the session working directory.\n\nA bare `"external_directory"` key is sugar that expands into this key and `external_directory_read`; write this one only to give the two directions different answers.',
|
|
115
|
-
},
|
|
116
|
-
};
|
|
89
|
+
const DIRECTIONAL_SURFACE_KEYS = [
|
|
90
|
+
"path_read",
|
|
91
|
+
"path_write",
|
|
92
|
+
"external_directory_read",
|
|
93
|
+
"external_directory_write",
|
|
94
|
+
] as const;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One documented, optional property for a well-known permission surface.
|
|
98
|
+
*
|
|
99
|
+
* A named property is what an editor completes and hovers; the enclosing
|
|
100
|
+
* object's `.catchall(...)` is what keeps every other tool name valid. Each
|
|
101
|
+
* surface's prose lives here rather than on the object, so a reader gets the
|
|
102
|
+
* key under their cursor and not all nine of its neighbours.
|
|
103
|
+
*/
|
|
104
|
+
function surfaceProperty(meta: {
|
|
105
|
+
description: string;
|
|
106
|
+
markdownDescription: string;
|
|
107
|
+
}) {
|
|
108
|
+
return surfaceValueSchema.optional().meta(meta);
|
|
109
|
+
}
|
|
117
110
|
|
|
118
111
|
const permissionSchema = z
|
|
119
|
-
.object(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
),
|
|
126
|
-
|
|
112
|
+
.object({
|
|
113
|
+
"*": surfaceProperty({
|
|
114
|
+
description:
|
|
115
|
+
"Universal fallback — the action used when no surface-specific rule matches. Omitted, it defaults to ask.",
|
|
116
|
+
markdownDescription:
|
|
117
|
+
'Universal fallback — the action used when **no** surface-specific rule matches.\n\n`{ "*": "ask" }` is the least-privilege posture, and is what an omitted `"*"` means anyway. It replaces `defaultPolicy.tools` from the legacy config format.\n\nA surface-specific rule always beats it, whatever the key order in the file.',
|
|
118
|
+
}),
|
|
119
|
+
path: surfaceProperty({
|
|
120
|
+
description:
|
|
121
|
+
"Cross-cutting gate for file access by path pattern, across every path-aware tool. Sugar for both directions.",
|
|
122
|
+
markdownDescription:
|
|
123
|
+
"Cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor).\n\nA `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from every path-aware tool at once.\n\nThis bare key is **sugar**: it expands at load into `path_read` and `path_write`, its entries placed first, so an explicit directional entry always has the final say whatever the key order in the file.",
|
|
124
|
+
}),
|
|
125
|
+
path_read: surfaceProperty({
|
|
126
|
+
description:
|
|
127
|
+
"Cross-cutting gate for reading a file, by path pattern. The useful directional grant.",
|
|
128
|
+
markdownDescription:
|
|
129
|
+
'Cross-cutting gate for **reading** a file, matched by path pattern across all path-aware tools.\n\nThis is the directional key worth granting: `"path_read": { "~/dev/*": "allow" }` permits reads without permitting writes.\n\nA bare `"path"` key is sugar that expands into this key **and** `path_write`, with its entries placed first — so an explicit `path_read` entry always has the final say, whatever the key order in the file.',
|
|
130
|
+
}),
|
|
131
|
+
path_write: surfaceProperty({
|
|
132
|
+
description:
|
|
133
|
+
"Cross-cutting gate for writing a file, by path pattern. Earns its keep as a restriction.",
|
|
134
|
+
markdownDescription:
|
|
135
|
+
'Cross-cutting gate for **writing** a file, matched by path pattern across all path-aware tools.\n\nThis key earns its keep as a *restriction* rather than a grant: `"path_write": { "*": "deny" }` is a coherent read-only-agent posture. A `"path_write": "allow"` on its own does not silence an `edit`, which also reads — grant `path_read` too, or use the bare `"path"` key.',
|
|
136
|
+
}),
|
|
137
|
+
external_directory: surfaceProperty({
|
|
138
|
+
description:
|
|
139
|
+
"Boundary gate for access outside the session working directory. Sugar for both directions.",
|
|
140
|
+
markdownDescription:
|
|
141
|
+
'Boundary gate for access **outside** the session working directory.\n\nGive it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `{ "*": "ask", "~/.cargo/registry/*": "allow" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself.\n\nBecause layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\nThis bare key is **sugar**: it expands at load into `external_directory_read` and `external_directory_write`, its entries placed first.',
|
|
142
|
+
}),
|
|
143
|
+
external_directory_read: surfaceProperty({
|
|
144
|
+
description:
|
|
145
|
+
"Boundary gate for reading outside the working directory. The relief most asks want.",
|
|
146
|
+
markdownDescription:
|
|
147
|
+
'Boundary gate for **reading** a path outside the session working directory.\n\nThe one-line grant for an external root: `"external_directory_read": { "~/dev/*": "allow" }` silences repeated read prompts on a directory outside the tree while a write to the same path still prompts. No parallel `path_read` entry is needed.',
|
|
148
|
+
}),
|
|
149
|
+
external_directory_write: surfaceProperty({
|
|
150
|
+
description: "Boundary gate for writing outside the working directory.",
|
|
151
|
+
markdownDescription:
|
|
152
|
+
'Boundary gate for **writing** to a path outside the session working directory.\n\nA bare `"external_directory"` key is sugar that expands into this key and `external_directory_read`; write this one only to give the two directions different answers.',
|
|
153
|
+
}),
|
|
154
|
+
bash: surfaceProperty({
|
|
155
|
+
description:
|
|
156
|
+
"Shell command execution, matched per top-level command in a chain. Most restrictive wins.",
|
|
157
|
+
markdownDescription:
|
|
158
|
+
"Shell command execution, matched by **command pattern**.\n\nA chain (`&&`, `||`, `;`, `|`, newline) is split into its top-level commands and each is matched independently, most-restrictive-wins — so `cd /repo && npm install x` is denied when `npm *` is. A command nested in a substitution, process substitution, or subshell is matched too, since it really runs.\n\nA leading env-var assignment is stripped before matching (`AWS_PROFILE=prod aws …` matches `aws *`), and a pattern ending in ` *` (space + wildcard) also matches the bare command (`git *` matches `git`). A pattern containing a chain operator never matches — write one pattern per command.\n\nA shell wrapper (`bash -c`, `eval`, `sudo`, `xargs`) is floored from `allow` to `ask`, so an opaque payload cannot ride a permissive rule.",
|
|
159
|
+
}),
|
|
160
|
+
mcp: surfaceProperty({
|
|
161
|
+
description:
|
|
162
|
+
"Registered MCP proxy tools, matched against targets derived from the tool input.",
|
|
163
|
+
markdownDescription:
|
|
164
|
+
"Registered MCP proxy tools, matched against targets derived from the tool input: a baseline op (`mcp_status`, `mcp_list`, `mcp_search`, `mcp_describe`, `mcp_connect`), a server name (`myServer`), a server/tool combination (`myServer:search`, `myServer_search`), or the generic `mcp_call`.\n\nBaseline discovery targets auto-allow whenever any explicit `mcp` allow rule exists.",
|
|
165
|
+
}),
|
|
166
|
+
skill: surfaceProperty({
|
|
167
|
+
description:
|
|
168
|
+
"Skill invocation, matched by skill name. The surface is `skill`, not `skills`.",
|
|
169
|
+
markdownDescription:
|
|
170
|
+
'Skill invocation, matched by skill name — the surface is `skill`, not `skills`.\n\nWildcards behave as everywhere else: `{ "*": "ask", "dangerous-*": "deny", "librarian": "allow" }`.',
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
127
173
|
.catchall(surfaceValueSchema)
|
|
128
174
|
.meta({
|
|
129
175
|
description:
|
|
130
176
|
"Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
131
177
|
markdownDescription:
|
|
132
|
-
'Flat permission policy.\n\nEach top-level key is a surface
|
|
178
|
+
'Flat permission policy.\n\nEach top-level key is a surface: the `"*"` fallback, a well-known surface documented below, or any registered tool name.\n\nA **string** value is shorthand for `{ "*": action }` (a surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `"read": { "*": "allow", "*.env": "deny" }` allows reads but denies `.env` files.\n\nWhen Pi\'s current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd "$DIR"` style commands remain conservative.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.',
|
|
133
179
|
examples: [
|
|
134
180
|
{
|
|
135
181
|
"*": "ask",
|
|
@@ -176,7 +222,7 @@ function rejectUnusableSurfaceKeys(
|
|
|
176
222
|
permission: Record<string, unknown>,
|
|
177
223
|
ctx: z.core.$RefinementCtx,
|
|
178
224
|
): void {
|
|
179
|
-
const legalDirectionalKeys =
|
|
225
|
+
const legalDirectionalKeys: readonly string[] = DIRECTIONAL_SURFACE_KEYS;
|
|
180
226
|
for (const key of Object.keys(permission)) {
|
|
181
227
|
if (key === "") {
|
|
182
228
|
ctx.addIssue({
|
package/src/config-store.ts
CHANGED
|
@@ -140,8 +140,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
|
140
140
|
*
|
|
141
141
|
* Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
|
|
142
142
|
*/
|
|
143
|
-
// Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
|
|
144
|
-
// fallow-ignore-next-line unused-class-member
|
|
145
143
|
save(
|
|
146
144
|
next: PermissionSystemExtensionConfig,
|
|
147
145
|
ctx: ExtensionCommandContext,
|
|
@@ -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 {
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
ServingHeartbeatStore,
|
|
18
18
|
} from "./authority/forwarding-liveness";
|
|
19
19
|
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
20
|
+
import {
|
|
21
|
+
AncestorNodes,
|
|
22
|
+
InheritingToolAccessExtractorLookup,
|
|
23
|
+
InheritingToolInputFormatterLookup,
|
|
24
|
+
} from "./authority/inherited-registrations";
|
|
20
25
|
import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
|
|
21
26
|
import { requestPermissionDecision } from "./authority/permission-prompt-component";
|
|
22
27
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
@@ -322,11 +327,25 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
322
327
|
reporter,
|
|
323
328
|
isYoloEnabled,
|
|
324
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
|
+
);
|
|
325
341
|
const toolCallGatePipeline = new ToolCallGatePipeline(
|
|
326
342
|
resolver,
|
|
327
343
|
session,
|
|
328
|
-
formatterRegistry,
|
|
329
|
-
|
|
344
|
+
new InheritingToolInputFormatterLookup(formatterRegistry, ancestorNodes),
|
|
345
|
+
new InheritingToolAccessExtractorLookup(
|
|
346
|
+
accessExtractorRegistry,
|
|
347
|
+
ancestorNodes,
|
|
348
|
+
),
|
|
330
349
|
);
|
|
331
350
|
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
|
|
332
351
|
const gates = new PermissionGateHandler(
|
package/src/normalize.ts
CHANGED
|
@@ -35,6 +35,11 @@ export function expandDirectionalSugar(
|
|
|
35
35
|
const expanded: FlatPermissionConfig = {};
|
|
36
36
|
for (const [surface, value] of Object.entries(permission)) {
|
|
37
37
|
// A key present with an explicit `undefined` value carries no rules.
|
|
38
|
+
// `Object.entries` resolves to the catchall's non-optional value type, so
|
|
39
|
+
// the type cannot see this case even though a named optional surface
|
|
40
|
+
// admits it; dropping the guard expands `{ path: undefined }` into two
|
|
41
|
+
// empty directional surfaces. Pinned in `normalize.test.ts`.
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime-reachable; see above
|
|
38
43
|
if (value === undefined) continue;
|
|
39
44
|
const members = surfaceFamilyMembers(surface);
|
|
40
45
|
if (members === null) {
|
|
@@ -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
|
-
|
|
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"],
|
package/src/service-lifecycle.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
85
|
+
resolve(toolName: string): ResolvedToolAccessExtractor | undefined {
|
|
86
|
+
const extractor = this.extractors.get(toolName);
|
|
87
|
+
return extractor ? { extractor, origin: "local" } : undefined;
|
|
67
88
|
}
|
|
68
89
|
}
|