@gotgenes/pi-permission-system 20.8.0 → 20.9.1
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 +23 -0
- package/README.md +4 -0
- package/config/config.example.json +2 -0
- package/dist/public.d.ts +212 -75
- package/docs/configuration.md +46 -9
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/authority/approval-escalator.ts +2 -2
- package/src/authority/authorizer-chain.ts +60 -0
- package/src/authority/authorizer-registry.ts +69 -0
- package/src/authority/authorizer-selection.ts +54 -8
- package/src/authority/authorizer.ts +31 -4
- package/src/authority/delegation-envelope.ts +52 -0
- package/src/authority/denying-authorizer.ts +2 -2
- package/src/authority/local-user-authorizer.ts +2 -2
- package/src/authority/permission-prompter.ts +4 -4
- package/src/config-loader.ts +5 -4
- package/src/config-schema.ts +7 -0
- package/src/extension-config.ts +5 -0
- package/src/index.ts +15 -0
- package/src/permissions-service.ts +10 -0
- package/src/service.ts +52 -14
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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
|
+
## [20.9.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.9.0...pi-permission-system-v20.9.1) (2026-07-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** cite pi-permission-model-judge as a registerAuthorizer example ([6bc1e67](https://github.com/gotgenes/pi-packages/commit/6bc1e6710ea70c7d95b87d77fb4e744f0b81e614))
|
|
14
|
+
|
|
15
|
+
## [20.9.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.8.0...pi-permission-system-v20.9.0) (2026-07-19)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** add authorizerChain config field ([#599](https://github.com/gotgenes/pi-packages/issues/599)) ([6c0bb72](https://github.com/gotgenes/pi-packages/commit/6c0bb72680d864b70e8fac5bb5c480b9a96751f8))
|
|
21
|
+
* **pi-permission-system:** add registerAuthorizer cross-extension seam ([#599](https://github.com/gotgenes/pi-packages/issues/599)) ([ea60900](https://github.com/gotgenes/pi-packages/commit/ea60900439d68ccc028ba75c3e432945e0ac3b72))
|
|
22
|
+
* **pi-permission-system:** cap link verdicts with the delegation envelope ([#599](https://github.com/gotgenes/pi-packages/issues/599)) ([28733fc](https://github.com/gotgenes/pi-packages/commit/28733fca298f1fea1f7a810b61728f9c96bf225f))
|
|
23
|
+
* **pi-permission-system:** inject a session-scoped PermissionQuery into chain links ([#599](https://github.com/gotgenes/pi-packages/issues/599)) ([29452ff](https://github.com/gotgenes/pi-packages/commit/29452ff8cbec6abc431a318b7937843ecefb724d))
|
|
24
|
+
* **pi-permission-system:** resolve the configured authorizer chain ([#599](https://github.com/gotgenes/pi-packages/issues/599)) ([fb366d9](https://github.com/gotgenes/pi-packages/commit/fb366d9aed47ab439ffc033e4967a4285d37fe8a))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** document registerAuthorizer + authorizerChain and mark Phase 12 Step 5 complete ([#599](https://github.com/gotgenes/pi-packages/issues/599)) ([1d6b228](https://github.com/gotgenes/pi-packages/commit/1d6b22889296b3277a118cc8168486664c691305))
|
|
30
|
+
|
|
8
31
|
## [20.8.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.3...pi-permission-system-v20.8.0) (2026-07-18)
|
|
9
32
|
|
|
10
33
|
|
package/README.md
CHANGED
|
@@ -112,6 +112,10 @@ Within a surface map like `bash` or `mcp`, **last matching rule wins** — put b
|
|
|
112
112
|
|
|
113
113
|
The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
|
|
114
114
|
|
|
115
|
+
The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
|
|
116
|
+
A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
|
|
117
|
+
[`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge) is a first-party reference implementation of such a link — a deny-first reviewer that auto-denies mistyped out-of-directory paths.
|
|
118
|
+
|
|
115
119
|
For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
|
|
116
120
|
|
|
117
121
|
## Upgrading
|
package/dist/public.d.ts
CHANGED
|
@@ -1,65 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Registry for custom tool access-intent extractors.
|
|
5
|
-
*
|
|
6
|
-
* Lets sibling extensions declare the filesystem path a tool will access when
|
|
7
|
-
* the tool's input shape is not the default `input.path` convention, so the
|
|
8
|
-
* cross-cutting `path` and `external_directory` gates can see it.
|
|
9
|
-
* One extractor per tool name; duplicate registration throws.
|
|
10
|
-
*/
|
|
11
|
-
/** Returns the filesystem path this tool will access, or `undefined` to decline. */
|
|
12
|
-
type ToolAccessExtractor = (input: Record<string, unknown>) => string | undefined;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Registry for custom tool-input preview formatters.
|
|
16
|
-
*
|
|
17
|
-
* Allows extensions to register a formatter for a specific tool name so
|
|
18
|
-
* permission prompts can show a human-readable summary instead of raw JSON.
|
|
19
|
-
* One formatter per tool name; duplicate registration throws.
|
|
20
|
-
*/
|
|
21
|
-
/** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
|
|
22
|
-
type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined;
|
|
23
|
-
|
|
24
|
-
declare const permissionStateSchema: z.ZodUnion<readonly [z.ZodLiteral<"allow">, z.ZodLiteral<"deny">, z.ZodLiteral<"ask">]>;
|
|
25
|
-
/** A permission decision. */
|
|
26
|
-
type PermissionState = z.infer<typeof permissionStateSchema>;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Provenance of a rule — which source contributed it.
|
|
30
|
-
*
|
|
31
|
-
* Config scopes: "global", "project", "agent", "project-agent".
|
|
32
|
-
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
33
|
-
* "baseline" (conditional MCP metadata auto-allow).
|
|
34
|
-
* Runtime: "session" (session approvals).
|
|
35
|
-
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode).
|
|
36
|
-
*/
|
|
37
|
-
type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session" | "yolo";
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Execution context of a bash command nested inside a substitution or subshell.
|
|
41
|
-
* Absent for current-shell (top-level) commands.
|
|
42
|
-
*/
|
|
43
|
-
type BashCommandContext = "command_substitution" | "process_substitution" | "subshell";
|
|
44
|
-
interface PermissionCheckResult {
|
|
45
|
-
toolName: string;
|
|
46
|
-
state: PermissionState;
|
|
47
|
-
/** Custom denial reason from a deny-with-reason pattern, when present. */
|
|
48
|
-
reason?: string;
|
|
49
|
-
matchedPattern?: string;
|
|
50
|
-
command?: string;
|
|
51
|
-
target?: string;
|
|
52
|
-
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
53
|
-
/** Which source contributed the winning rule. */
|
|
54
|
-
origin: RuleOrigin;
|
|
55
|
-
/**
|
|
56
|
-
* Execution context of the offending nested command, when the winning bash
|
|
57
|
-
* unit came from a substitution or subshell. Absent for current-shell
|
|
58
|
-
* (top-level) commands.
|
|
59
|
-
*/
|
|
60
|
-
commandContext?: BashCommandContext;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
3
|
/** Emitted at `session_start`, after the service is published. */
|
|
64
4
|
declare const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
65
5
|
/** Emitted when a permission request is committed to the active UI prompt path. */
|
|
@@ -135,6 +75,175 @@ interface PermissionDecisionEvent {
|
|
|
135
75
|
matchedPattern: string | null;
|
|
136
76
|
}
|
|
137
77
|
|
|
78
|
+
declare const permissionStateSchema: z.ZodUnion<readonly [z.ZodLiteral<"allow">, z.ZodLiteral<"deny">, z.ZodLiteral<"ask">]>;
|
|
79
|
+
/** A permission decision. */
|
|
80
|
+
type PermissionState = z.infer<typeof permissionStateSchema>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Provenance of a rule — which source contributed it.
|
|
84
|
+
*
|
|
85
|
+
* Config scopes: "global", "project", "agent", "project-agent".
|
|
86
|
+
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
87
|
+
* "baseline" (conditional MCP metadata auto-allow).
|
|
88
|
+
* Runtime: "session" (session approvals).
|
|
89
|
+
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode).
|
|
90
|
+
*/
|
|
91
|
+
type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session" | "yolo";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Execution context of a bash command nested inside a substitution or subshell.
|
|
95
|
+
* Absent for current-shell (top-level) commands.
|
|
96
|
+
*/
|
|
97
|
+
type BashCommandContext = "command_substitution" | "process_substitution" | "subshell";
|
|
98
|
+
interface PermissionCheckResult {
|
|
99
|
+
toolName: string;
|
|
100
|
+
state: PermissionState;
|
|
101
|
+
/** Custom denial reason from a deny-with-reason pattern, when present. */
|
|
102
|
+
reason?: string;
|
|
103
|
+
matchedPattern?: string;
|
|
104
|
+
command?: string;
|
|
105
|
+
target?: string;
|
|
106
|
+
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
107
|
+
/** Which source contributed the winning rule. */
|
|
108
|
+
origin: RuleOrigin;
|
|
109
|
+
/**
|
|
110
|
+
* Execution context of the offending nested command, when the winning bash
|
|
111
|
+
* unit came from a substitution or subshell. Absent for current-shell
|
|
112
|
+
* (top-level) commands.
|
|
113
|
+
*/
|
|
114
|
+
commandContext?: BashCommandContext;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The child's session-approval suggestion, relayed to the serving node so a
|
|
119
|
+
* human who grants "the whole session" records the same pattern the child
|
|
120
|
+
* would have recorded locally.
|
|
121
|
+
*
|
|
122
|
+
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
|
123
|
+
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
|
124
|
+
* from it via `SessionApproval.multiple`.
|
|
125
|
+
*/
|
|
126
|
+
interface ForwardedSessionApproval {
|
|
127
|
+
surface: string;
|
|
128
|
+
patterns: readonly string[];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The child-fixed facts a gate emits: the surface it evaluated and the match
|
|
132
|
+
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
|
|
133
|
+
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
|
|
134
|
+
*
|
|
135
|
+
* Strings only — an `AccessPath` never crosses onto the wire
|
|
136
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
137
|
+
*/
|
|
138
|
+
interface ForwardedAccessFacts {
|
|
139
|
+
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
|
|
140
|
+
surface: string;
|
|
141
|
+
/**
|
|
142
|
+
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
|
|
143
|
+
* (absolute ∪ cwd-relative ∪ canonical), computed at the child. Non-path
|
|
144
|
+
* surface: the already-portable single value as a one-element array.
|
|
145
|
+
*/
|
|
146
|
+
matchValues: string[];
|
|
147
|
+
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
148
|
+
boundaryValue: string | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
152
|
+
/**
|
|
153
|
+
* Provenance of a forwarded ask: who is really asking, one hop below.
|
|
154
|
+
*
|
|
155
|
+
* Present on {@link PromptPermissionDetails} only when the ask was forwarded
|
|
156
|
+
* from a subagent. Structurally identical to the event's `ForwardedPromptContext`
|
|
157
|
+
* so the details flow straight into `buildUiPrompt`, but declared here to keep
|
|
158
|
+
* the prompter layer free of an events-module import.
|
|
159
|
+
*/
|
|
160
|
+
interface ForwardedAskProvenance {
|
|
161
|
+
requesterAgentName: string | null;
|
|
162
|
+
requesterSessionId: string | null;
|
|
163
|
+
}
|
|
164
|
+
/** Details passed when prompting the user for a permission decision. */
|
|
165
|
+
interface PromptPermissionDetails {
|
|
166
|
+
requestId: string;
|
|
167
|
+
source: PermissionReviewSource;
|
|
168
|
+
agentName: string | null;
|
|
169
|
+
message: string;
|
|
170
|
+
toolCallId?: string;
|
|
171
|
+
toolName?: string;
|
|
172
|
+
skillName?: string;
|
|
173
|
+
path?: string;
|
|
174
|
+
command?: string;
|
|
175
|
+
target?: string;
|
|
176
|
+
toolInputPreview?: string;
|
|
177
|
+
/** Override label for the "for this session" dialog option. */
|
|
178
|
+
sessionLabel?: string;
|
|
179
|
+
/** Explicit display-surface override (a forwarded ask carries the child's original). */
|
|
180
|
+
surface?: string | null;
|
|
181
|
+
/** Explicit display-value override (a forwarded ask carries the child's original). */
|
|
182
|
+
value?: string | null;
|
|
183
|
+
/** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
|
|
184
|
+
forwarding?: ForwardedAskProvenance;
|
|
185
|
+
/**
|
|
186
|
+
* The session-approval suggestion for this ask. On the child's escalation it
|
|
187
|
+
* rides into the forwarded request; on the serving node it lets the dialog
|
|
188
|
+
* offer a whole-session grant scope. Absent when the gate computed no
|
|
189
|
+
* suggestion.
|
|
190
|
+
*/
|
|
191
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
192
|
+
/**
|
|
193
|
+
* The child-fixed access facts the raising gate computed (surface + match
|
|
194
|
+
* set). Rides through the runner to the escalation edge, which completes
|
|
195
|
+
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
|
196
|
+
* `principal`. Absent for a serving-node local prompt reconstructed from a
|
|
197
|
+
* forwarded request.
|
|
198
|
+
*/
|
|
199
|
+
accessIntent?: ForwardedAccessFacts;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
|
|
204
|
+
* pass the ask on to the next link (`defer`). A `deny` carries an optional
|
|
205
|
+
* teaching `reason` the invoking model sees, so it can self-correct.
|
|
206
|
+
*/
|
|
207
|
+
type AuthorizerVerdict = {
|
|
208
|
+
kind: "allow";
|
|
209
|
+
} | {
|
|
210
|
+
kind: "deny";
|
|
211
|
+
reason?: string;
|
|
212
|
+
} | {
|
|
213
|
+
kind: "defer";
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* A non-terminal link in the live-authority chain: reviews an `ask` and may
|
|
217
|
+
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
|
218
|
+
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
|
219
|
+
* queries the deterministic engine at gate parity rather than reaching for the
|
|
220
|
+
* cross-extension service via `Symbol.for()`.
|
|
221
|
+
*/
|
|
222
|
+
interface Authorizer {
|
|
223
|
+
authorize(details: PromptPermissionDetails, query: PermissionQuery): Promise<AuthorizerVerdict>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Registry for custom tool access-intent extractors.
|
|
228
|
+
*
|
|
229
|
+
* Lets sibling extensions declare the filesystem path a tool will access when
|
|
230
|
+
* the tool's input shape is not the default `input.path` convention, so the
|
|
231
|
+
* cross-cutting `path` and `external_directory` gates can see it.
|
|
232
|
+
* One extractor per tool name; duplicate registration throws.
|
|
233
|
+
*/
|
|
234
|
+
/** Returns the filesystem path this tool will access, or `undefined` to decline. */
|
|
235
|
+
type ToolAccessExtractor = (input: Record<string, unknown>) => string | undefined;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Registry for custom tool-input preview formatters.
|
|
239
|
+
*
|
|
240
|
+
* Allows extensions to register a formatter for a specific tool name so
|
|
241
|
+
* permission prompts can show a human-readable summary instead of raw JSON.
|
|
242
|
+
* One formatter per tool name; duplicate registration throws.
|
|
243
|
+
*/
|
|
244
|
+
/** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
|
|
245
|
+
type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined;
|
|
246
|
+
|
|
138
247
|
/**
|
|
139
248
|
* Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
|
|
140
249
|
*
|
|
@@ -149,13 +258,12 @@ interface PermissionDecisionEvent {
|
|
|
149
258
|
*/
|
|
150
259
|
|
|
151
260
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* rules internally.
|
|
261
|
+
* The narrow, read-only projection of {@link PermissionsService}: answer a
|
|
262
|
+
* policy query for a surface, and report a tool-level state. This is the
|
|
263
|
+
* capability an Authorizer chain link is handed (ISP) — it never sees the
|
|
264
|
+
* registration surface.
|
|
157
265
|
*/
|
|
158
|
-
interface
|
|
266
|
+
interface PermissionQuery {
|
|
159
267
|
/**
|
|
160
268
|
* Query the permission policy for a surface and value.
|
|
161
269
|
*
|
|
@@ -168,6 +276,27 @@ interface PermissionsService {
|
|
|
168
276
|
* @returns Full check result including state, matched pattern, and origin.
|
|
169
277
|
*/
|
|
170
278
|
checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
|
|
279
|
+
/**
|
|
280
|
+
* Query the tool-level permission state for pre-filtering tools before
|
|
281
|
+
* creating a child session.
|
|
282
|
+
*
|
|
283
|
+
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
284
|
+
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
285
|
+
* use `checkPermission` for runtime invocation gates.
|
|
286
|
+
*
|
|
287
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
288
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
289
|
+
*/
|
|
290
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Public interface exposed to other extensions via `getPermissionsService()`.
|
|
294
|
+
*
|
|
295
|
+
* `checkPermission` takes a surface + optional value + optional agent name,
|
|
296
|
+
* and delegates to `PermissionManager.checkPermission()` with current session
|
|
297
|
+
* rules internally.
|
|
298
|
+
*/
|
|
299
|
+
interface PermissionsService extends PermissionQuery {
|
|
171
300
|
/**
|
|
172
301
|
* Register a custom preview formatter for a specific tool name.
|
|
173
302
|
*
|
|
@@ -203,17 +332,25 @@ interface PermissionsService {
|
|
|
203
332
|
*/
|
|
204
333
|
registerToolAccessExtractor(toolName: string, extractor: ToolAccessExtractor): () => void;
|
|
205
334
|
/**
|
|
206
|
-
*
|
|
207
|
-
* creating a child session.
|
|
335
|
+
* Register a named live-authority chain link (ADR 0007 §4).
|
|
208
336
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
337
|
+
* A link reviews an `ask` and returns `allow` / `deny` (with an optional
|
|
338
|
+
* teaching `reason`) / `defer`. It is handed a narrow, session-scoped
|
|
339
|
+
* {@link PermissionQuery} at `authorize` time so it can query the
|
|
340
|
+
* deterministic engine at gate parity. Register from a `permissions:ready`
|
|
341
|
+
* handler so registration is robust to load order and survives `/reload`.
|
|
212
342
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
343
|
+
* Registration alone grants **no authority**: the link decides nothing until
|
|
344
|
+
* the operator names it in the `authorizerChain` config (opt-in activation),
|
|
345
|
+
* and the chain owner caps every verdict with the bounded-delegation
|
|
346
|
+
* checkpoint (an `allow` on an excluded surface downgrades to `defer`). Only
|
|
347
|
+
* one link may be registered per name — a second call for the same name
|
|
348
|
+
* throws. The returned disposer unregisters the link.
|
|
349
|
+
*
|
|
350
|
+
* @param name - Operator-facing link name referenced from `authorizerChain`.
|
|
351
|
+
* @param authorize - The link's decision callback (`(details, query) => verdict`).
|
|
215
352
|
*/
|
|
216
|
-
|
|
353
|
+
registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
|
|
217
354
|
}
|
|
218
355
|
/**
|
|
219
356
|
* Store a `PermissionsService` on `globalThis` so other extensions can
|
|
@@ -246,4 +383,4 @@ declare function getPermissionsService(): PermissionsService | undefined;
|
|
|
246
383
|
declare function unpublishPermissionsService(service: PermissionsService): void;
|
|
247
384
|
|
|
248
385
|
export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, publishPermissionsService, unpublishPermissionsService };
|
|
249
|
-
export type { ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, ToolInputFormatter };
|
|
386
|
+
export type { Authorizer, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptPermissionDetails, ToolInputFormatter };
|
package/docs/configuration.md
CHANGED
|
@@ -52,6 +52,9 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConf
|
|
|
52
52
|
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
|
53
53
|
},
|
|
54
54
|
|
|
55
|
+
// Ordered names of registered live-authority chain links (empty = none)
|
|
56
|
+
"authorizerChain": [],
|
|
57
|
+
|
|
55
58
|
// Flat permission policy
|
|
56
59
|
"permission": {
|
|
57
60
|
"*": "ask", // universal fallback
|
|
@@ -81,15 +84,16 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConf
|
|
|
81
84
|
|
|
82
85
|
## Runtime Knobs
|
|
83
86
|
|
|
84
|
-
| Key | Default | Description
|
|
85
|
-
| --------------------------- | ------- |
|
|
86
|
-
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl`
|
|
87
|
-
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`
|
|
88
|
-
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled
|
|
89
|
-
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press.
|
|
90
|
-
| `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation.
|
|
91
|
-
| `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default.
|
|
92
|
-
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`).
|
|
87
|
+
| Key | Default | Description |
|
|
88
|
+
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
90
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
|
|
91
|
+
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
92
|
+
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
93
|
+
| `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
|
|
94
|
+
| `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
|
|
95
|
+
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
|
|
96
|
+
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
|
|
93
97
|
|
|
94
98
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
95
99
|
No debug output is printed to the terminal.
|
|
@@ -159,6 +163,39 @@ To change a specific tool's mapping, set that tool's key at the project scope (t
|
|
|
159
163
|
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
|
|
160
164
|
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
|
|
161
165
|
|
|
166
|
+
### Authorizer chain — case-by-case decision links
|
|
167
|
+
|
|
168
|
+
The deterministic policy above decides `allow` / `deny` / `ask` for every request.
|
|
169
|
+
When a request lands on `ask`, the **authorizer chain** decides who answers it.
|
|
170
|
+
By default that is you (an interactive prompt), the subagent-forwarding path, or a headless deny.
|
|
171
|
+
A downstream extension can register a **link** — a reviewer that sees the `ask` and returns `allow`, `deny` (with an optional teaching reason), or `defer` to the next link — and the chain ends at the default terminal that always decides.
|
|
172
|
+
The canonical use case is a light model judge that reviews asks case by case (e.g. auto-denying an errant typo-path with a corrective reason).
|
|
173
|
+
|
|
174
|
+
`authorizerChain` is the ordered list of link names to consult, ahead of the terminal:
|
|
175
|
+
|
|
176
|
+
```jsonc
|
|
177
|
+
{
|
|
178
|
+
"authorizerChain": ["model-judge"]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Three invariants govern the chain:
|
|
183
|
+
|
|
184
|
+
1. **Config order wins, never registration order.**
|
|
185
|
+
The order in `authorizerChain` — not the order extensions happen to register in — fixes the security-relevant chain order.
|
|
186
|
+
2. **A missing link is skipped fail-safe.**
|
|
187
|
+
A name with no registered link is skipped with a logged warning; the `ask` still reaches the terminal.
|
|
188
|
+
Absence of a judge means *more* prompting, never less.
|
|
189
|
+
3. **Registration alone grants no authority.**
|
|
190
|
+
Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
|
|
191
|
+
|
|
192
|
+
The chain owner caps every link with a **bounded-delegation checkpoint**: a link's `allow` on an excluded surface (`external_directory` or the `path` surface) is downgraded to `defer`, so a buggy or over-eager judge can never approve access outside your policy.
|
|
193
|
+
Deny and defer are never capped.
|
|
194
|
+
|
|
195
|
+
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
|
196
|
+
Registration returns a disposer, and only one link may hold a given name.
|
|
197
|
+
For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
|
|
198
|
+
|
|
162
199
|
---
|
|
163
200
|
|
|
164
201
|
## Policy Reference
|
package/package.json
CHANGED
|
@@ -55,6 +55,16 @@
|
|
|
55
55
|
"minLength": 1
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
|
+
"authorizerChain": {
|
|
59
|
+
"description": "Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
|
|
60
|
+
"markdownDescription": "Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here — not the order extensions register in — fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).",
|
|
61
|
+
"default": [],
|
|
62
|
+
"type": "array",
|
|
63
|
+
"items": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"minLength": 1
|
|
66
|
+
}
|
|
67
|
+
},
|
|
58
68
|
"permission": {
|
|
59
69
|
"type": "object",
|
|
60
70
|
"propertyNames": {
|
|
@@ -35,7 +35,7 @@ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
|
35
35
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
36
36
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
37
37
|
import { toRecord } from "#src/value-guards";
|
|
38
|
-
import type {
|
|
38
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
39
39
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
40
40
|
|
|
41
41
|
// ── Module-private helpers ────────────────────────────────────────────────
|
|
@@ -100,7 +100,7 @@ export interface ParentAuthorizerDeps {
|
|
|
100
100
|
* (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
|
|
101
101
|
* arms, both dead once every caller routes through `selectAuthorizer`).
|
|
102
102
|
*/
|
|
103
|
-
export class ParentAuthorizer implements
|
|
103
|
+
export class ParentAuthorizer implements TerminalAuthorizer {
|
|
104
104
|
private readonly forwardingDir: string;
|
|
105
105
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
106
106
|
private readonly logger: DebugReviewLogger;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PermissionQuery } from "#src/service";
|
|
2
|
+
import type {
|
|
3
|
+
Authorizer,
|
|
4
|
+
AuthorizerVerdict,
|
|
5
|
+
TerminalAuthorizer,
|
|
6
|
+
} from "./authorizer";
|
|
7
|
+
import { createDeniedPermissionDecision } from "./permission-dialog";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compose the live-authority chain (ADR 0007): try each non-terminal `link`
|
|
11
|
+
* in order, and on `defer` fall through to the next link, ending at the
|
|
12
|
+
* context-selected `terminal` that always decides.
|
|
13
|
+
*
|
|
14
|
+
* The signature is the type-level terminal-cannot-defer invariant: `links` are
|
|
15
|
+
* deferring {@link Authorizer}s while `terminal` is a {@link TerminalAuthorizer}
|
|
16
|
+
* (returns a full decision), so a deferring link cannot occupy the terminal
|
|
17
|
+
* slot.
|
|
18
|
+
*
|
|
19
|
+
* Each link is handed the session-scoped `query` at `authorize` time (ADR 0007
|
|
20
|
+
* §3) so it queries the deterministic engine at gate parity; the terminal never
|
|
21
|
+
* queries. With zero links the composed chain **is** the terminal instance
|
|
22
|
+
* (identity), so behavior is byte-identical to the pre-chain spine — the
|
|
23
|
+
* empty-links case that ships until a link registers.
|
|
24
|
+
*/
|
|
25
|
+
export function composeAuthorizerChain(
|
|
26
|
+
links: readonly Authorizer[],
|
|
27
|
+
terminal: TerminalAuthorizer,
|
|
28
|
+
query: PermissionQuery,
|
|
29
|
+
): TerminalAuthorizer {
|
|
30
|
+
if (links.length === 0) {
|
|
31
|
+
return terminal;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
async authorize(details) {
|
|
35
|
+
for (const link of links) {
|
|
36
|
+
const verdict = await link.authorize(details, query);
|
|
37
|
+
const decision = decideFromVerdict(verdict);
|
|
38
|
+
if (decision) {
|
|
39
|
+
return decision;
|
|
40
|
+
}
|
|
41
|
+
// `defer` \u2014 try the next link.
|
|
42
|
+
}
|
|
43
|
+
return terminal.authorize(details);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Map a link's decisive verdict to a decision; `defer` yields `null`. */
|
|
49
|
+
function decideFromVerdict(verdict: AuthorizerVerdict) {
|
|
50
|
+
switch (verdict.kind) {
|
|
51
|
+
case "allow":
|
|
52
|
+
// A link grant is non-persistent (state `approved`, never
|
|
53
|
+
// `approved_for_session`), per ADR 0007's off-by-default envelope.
|
|
54
|
+
return { approved: true, state: "approved" } as const;
|
|
55
|
+
case "deny":
|
|
56
|
+
return createDeniedPermissionDecision(verdict.reason);
|
|
57
|
+
case "defer":
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for named live-authority chain links (ADR 0007 §4).
|
|
3
|
+
*
|
|
4
|
+
* A downstream extension offers a named `Authorizer` link via
|
|
5
|
+
* `PermissionsService.registerAuthorizer`; this registry stores the link's
|
|
6
|
+
* `authorize` callback so composition can bind names to capabilities. One link
|
|
7
|
+
* per name; duplicate registration throws.
|
|
8
|
+
*
|
|
9
|
+
* Registration alone grants no authority — a link decides nothing until the
|
|
10
|
+
* operator names it in the `authorizerChain` config (the opt-in activation
|
|
11
|
+
* model). `AuthorizerSelection` owns that config-order resolution; this registry
|
|
12
|
+
* is storage only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Authorizer } from "./authorizer";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read-only lookup used by chain composition (ISP — exposes only the read side,
|
|
19
|
+
* not the registration surface).
|
|
20
|
+
*/
|
|
21
|
+
export interface AuthorizerLookup {
|
|
22
|
+
get(name: string): Authorizer["authorize"] | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Registration side of the registry (ISP — exposes only the write surface,
|
|
27
|
+
* mirroring the read-only {@link AuthorizerLookup}).
|
|
28
|
+
*/
|
|
29
|
+
export interface AuthorizerRegistrar {
|
|
30
|
+
register(name: string, authorize: Authorizer["authorize"]): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Persistent registry mapping link names to their `authorize` callbacks.
|
|
35
|
+
*
|
|
36
|
+
* Owned by the extension factory (`index.ts`) so it survives across session
|
|
37
|
+
* activations. Exposed to sibling extensions via
|
|
38
|
+
* `PermissionsService.registerAuthorizer` and consulted by
|
|
39
|
+
* `AuthorizerSelection` during chain resolution.
|
|
40
|
+
*/
|
|
41
|
+
export class AuthorizerRegistry
|
|
42
|
+
implements AuthorizerLookup, AuthorizerRegistrar
|
|
43
|
+
{
|
|
44
|
+
private readonly links = new Map<string, Authorizer["authorize"]>();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register a link under `name`.
|
|
48
|
+
*
|
|
49
|
+
* Throws if a link is already registered for that name — keeps resolution
|
|
50
|
+
* deterministic (a pi-permission-system package priority). Returns a disposer
|
|
51
|
+
* that removes the link; the disposer is identity-guarded so a stale call
|
|
52
|
+
* cannot evict a later registration.
|
|
53
|
+
*/
|
|
54
|
+
register(name: string, authorize: Authorizer["authorize"]): () => void {
|
|
55
|
+
if (this.links.has(name)) {
|
|
56
|
+
throw new Error(`An authorizer is already registered for '${name}'.`);
|
|
57
|
+
}
|
|
58
|
+
this.links.set(name, authorize);
|
|
59
|
+
return () => {
|
|
60
|
+
if (this.links.get(name) === authorize) {
|
|
61
|
+
this.links.delete(name);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(name: string): Authorizer["authorize"] | undefined {
|
|
67
|
+
return this.links.get(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
|
+
import type { PermissionQuery } from "#src/service";
|
|
3
4
|
import {
|
|
4
5
|
type Authorizer,
|
|
5
6
|
type AuthorizerSelectionDeps,
|
|
6
7
|
selectAuthorizer,
|
|
8
|
+
type TerminalAuthorizer,
|
|
7
9
|
} from "./authorizer";
|
|
10
|
+
import { composeAuthorizerChain } from "./authorizer-chain";
|
|
11
|
+
import type { AuthorizerLookup } from "./authorizer-registry";
|
|
12
|
+
import { encloseInDelegationEnvelope } from "./delegation-envelope";
|
|
8
13
|
import type {
|
|
9
14
|
PermissionPrompterApi,
|
|
10
15
|
PromptPermissionDetails,
|
|
@@ -49,38 +54,79 @@ export interface AskEscalator {
|
|
|
49
54
|
export class AuthorizerSelection
|
|
50
55
|
implements AskEscalator, AuthorizerSelectionLifecycle
|
|
51
56
|
{
|
|
52
|
-
private
|
|
57
|
+
private terminal: TerminalAuthorizer | null = null;
|
|
53
58
|
|
|
54
59
|
constructor(
|
|
55
60
|
private readonly deps: AuthorizerSelectionDeps & {
|
|
56
61
|
prompter: PermissionPrompterApi;
|
|
62
|
+
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
|
63
|
+
getPermissionQuery: () => PermissionQuery;
|
|
64
|
+
/** Read-only lookup of registered links by name. */
|
|
65
|
+
authorizerRegistry: AuthorizerLookup;
|
|
66
|
+
/** The operator's configured link names, read live per ask. */
|
|
67
|
+
getAuthorizerChain: () => string[];
|
|
57
68
|
},
|
|
58
69
|
) {}
|
|
59
70
|
|
|
60
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Select the terminal Authorizer for `ctx` and store it. The non-terminal
|
|
73
|
+
* chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
|
|
74
|
+
* link register in a `permissions:ready` handler that may fire after
|
|
75
|
+
* activation, so link resolution is deferred to the session's first ask.
|
|
76
|
+
*/
|
|
61
77
|
activate(ctx: ExtensionContext): void {
|
|
62
|
-
this.
|
|
78
|
+
this.terminal = selectAuthorizer(ctx, this.deps);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the operator's `authorizerChain` names to registered links, in
|
|
83
|
+
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a
|
|
84
|
+
* warning (invariant 2 — more prompting, never less); each resolved link is
|
|
85
|
+
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
|
|
86
|
+
* surface cannot exceed the operator's policy.
|
|
87
|
+
*/
|
|
88
|
+
private resolveConfiguredLinks(): Authorizer[] {
|
|
89
|
+
const links: Authorizer[] = [];
|
|
90
|
+
for (const name of this.deps.getAuthorizerChain()) {
|
|
91
|
+
const authorize = this.deps.authorizerRegistry.get(name);
|
|
92
|
+
if (authorize === undefined) {
|
|
93
|
+
this.deps.logger.review("authorizer_chain_unregistered_link", { name });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
links.push({ authorize: encloseInDelegationEnvelope(authorize) });
|
|
97
|
+
}
|
|
98
|
+
return links;
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/** Clear the stored selection. */
|
|
66
102
|
deactivate(): void {
|
|
67
|
-
this.
|
|
103
|
+
this.terminal = null;
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
/**
|
|
71
|
-
* Escalate an ask
|
|
107
|
+
* Escalate an ask through the composed chain and return its decision.
|
|
108
|
+
*
|
|
109
|
+
* Resolves the configured links freshly (so a link registered any time before
|
|
110
|
+
* this first ask is honored) and composes them ahead of the selected
|
|
111
|
+
* terminal. With zero links the composed value **is** the terminal instance,
|
|
112
|
+
* so behavior is identical to a bare terminal escalation.
|
|
72
113
|
*
|
|
73
|
-
* Rejects if no
|
|
114
|
+
* Rejects if no terminal has been selected — i.e. before the session was
|
|
74
115
|
* activated. Implements {@link AskEscalator}.
|
|
75
116
|
*/
|
|
76
117
|
escalate(
|
|
77
118
|
details: PromptPermissionDetails,
|
|
78
119
|
): Promise<PermissionPromptDecision> {
|
|
79
|
-
if (this.
|
|
120
|
+
if (this.terminal === null) {
|
|
80
121
|
return Promise.reject(
|
|
81
122
|
new Error("escalate called before the session was activated"),
|
|
82
123
|
);
|
|
83
124
|
}
|
|
84
|
-
|
|
125
|
+
const chain = composeAuthorizerChain(
|
|
126
|
+
this.resolveConfiguredLinks(),
|
|
127
|
+
this.terminal,
|
|
128
|
+
this.deps.getPermissionQuery(),
|
|
129
|
+
);
|
|
130
|
+
return this.deps.prompter.prompt(chain, details);
|
|
85
131
|
}
|
|
86
132
|
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "#src/authority/permission-prompt-component";
|
|
7
7
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
8
8
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
9
|
+
import type { PermissionQuery } from "#src/service";
|
|
9
10
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
10
11
|
import { ParentAuthorizer } from "./approval-escalator";
|
|
11
12
|
import { DenyingAuthorizer } from "./denying-authorizer";
|
|
@@ -14,15 +15,41 @@ import type { PromptPermissionDetails } from "./permission-prompter";
|
|
|
14
15
|
import type { SubagentDetector } from "./subagent-detection";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
|
|
19
|
+
* pass the ask on to the next link (`defer`). A `deny` carries an optional
|
|
20
|
+
* teaching `reason` the invoking model sees, so it can self-correct.
|
|
21
|
+
*/
|
|
22
|
+
export type AuthorizerVerdict =
|
|
23
|
+
| { kind: "allow" }
|
|
24
|
+
| { kind: "deny"; reason?: string }
|
|
25
|
+
| { kind: "defer" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A non-terminal link in the live-authority chain: reviews an `ask` and may
|
|
29
|
+
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
|
30
|
+
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
|
31
|
+
* queries the deterministic engine at gate parity rather than reaching for the
|
|
32
|
+
* cross-extension service via `Symbol.for()`.
|
|
33
|
+
*/
|
|
34
|
+
export interface Authorizer {
|
|
35
|
+
authorize(
|
|
36
|
+
details: PromptPermissionDetails,
|
|
37
|
+
query: PermissionQuery,
|
|
38
|
+
): Promise<AuthorizerVerdict>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The terminal link: on `ask`, rules on a single request and is told the
|
|
43
|
+
* decision. Structurally cannot defer — it always returns a full
|
|
44
|
+
* {@link PermissionPromptDecision}, which is the type-level enforcement of
|
|
45
|
+
* ADR 0007's terminal-cannot-defer invariant.
|
|
19
46
|
*
|
|
20
47
|
* One method, one responsibility. `DenyingAuthorizer` ignores `details`;
|
|
21
48
|
* `LocalUserAuthorizer` reads `message`/`sessionLabel` and derives the UI
|
|
22
49
|
* event from it; `ParentAuthorizer` reads `message` and derives the
|
|
23
50
|
* forwarded display from it.
|
|
24
51
|
*/
|
|
25
|
-
export interface
|
|
52
|
+
export interface TerminalAuthorizer {
|
|
26
53
|
authorize(
|
|
27
54
|
details: PromptPermissionDetails,
|
|
28
55
|
): Promise<PermissionPromptDecision>;
|
|
@@ -56,7 +83,7 @@ export interface AuthorizerSelectionDeps {
|
|
|
56
83
|
export function selectAuthorizer(
|
|
57
84
|
ctx: ExtensionContext,
|
|
58
85
|
deps: AuthorizerSelectionDeps,
|
|
59
|
-
):
|
|
86
|
+
): TerminalAuthorizer {
|
|
60
87
|
if (ctx.hasUI) {
|
|
61
88
|
return new LocalUserAuthorizer({
|
|
62
89
|
ui: ctx.ui,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
|
|
3
|
+
*
|
|
4
|
+
* The chain owner caps every registered link's verdict so a buggy or over-eager
|
|
5
|
+
* external judge can never exceed the operator's policy: a link's `allow` on an
|
|
6
|
+
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
|
|
7
|
+
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
|
|
8
|
+
* verdict — it never turns a `defer`/`deny` into an `allow`.
|
|
9
|
+
*
|
|
10
|
+
* The excluded set is the whole `path` surface plus `external_directory`. A
|
|
11
|
+
* finer secret-shaped-`path` exclusion (letting a link allow a non-secret path)
|
|
12
|
+
* is deferred to the allow-capable slice that needs it (#620); until then the
|
|
13
|
+
* conservative whole-surface exclusion ships. The checkpoint is dormant while
|
|
14
|
+
* the only registered links are deny-first (they never `allow`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Authorizer } from "./authorizer";
|
|
18
|
+
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
19
|
+
|
|
20
|
+
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
|
|
21
|
+
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
|
|
22
|
+
"external_directory",
|
|
23
|
+
"path",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
|
|
28
|
+
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
|
|
29
|
+
* through unchanged. `details` and the injected `query` are forwarded as-is.
|
|
30
|
+
*/
|
|
31
|
+
export function encloseInDelegationEnvelope(
|
|
32
|
+
authorize: Authorizer["authorize"],
|
|
33
|
+
): Authorizer["authorize"] {
|
|
34
|
+
return async (details, query) => {
|
|
35
|
+
const verdict = await authorize(details, query);
|
|
36
|
+
if (verdict.kind === "allow" && isExcludedSurface(details)) {
|
|
37
|
+
return { kind: "defer" };
|
|
38
|
+
}
|
|
39
|
+
return verdict;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether the ask's surface is excluded from link grants. Reads the
|
|
45
|
+
* gate-authoritative `accessIntent.surface`, falling back to the display
|
|
46
|
+
* `surface`. Fail-safe: an ask whose surface cannot be determined is treated as
|
|
47
|
+
* excluded (more prompting, never less — ADR 0007 invariant 2).
|
|
48
|
+
*/
|
|
49
|
+
function isExcludedSurface(details: PromptPermissionDetails): boolean {
|
|
50
|
+
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
|
|
51
|
+
return surface === undefined || DELEGATION_EXCLUDED_SURFACES.has(surface);
|
|
52
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
|
-
import type {
|
|
2
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Least-privilege Authorizer: no authority is reachable for this session
|
|
@@ -9,7 +9,7 @@ import type { Authorizer } from "./authorizer";
|
|
|
9
9
|
* distinguish "nobody could answer" from an interactive user denial when it
|
|
10
10
|
* derives the review-entry and decision-event resolution.
|
|
11
11
|
*/
|
|
12
|
-
export class DenyingAuthorizer implements
|
|
12
|
+
export class DenyingAuthorizer implements TerminalAuthorizer {
|
|
13
13
|
authorize(): Promise<PermissionPromptDecision> {
|
|
14
14
|
return Promise.resolve({
|
|
15
15
|
approved: false,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type PermissionEventBus,
|
|
15
15
|
} from "#src/permission-events";
|
|
16
16
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
17
|
-
import type {
|
|
17
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
18
18
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
19
19
|
|
|
20
20
|
/** Dependencies required by {@link LocalUserAuthorizer}. */
|
|
@@ -41,7 +41,7 @@ export interface LocalUserAuthorizerDeps {
|
|
|
41
41
|
* class renders (populated `forwarding` context + "(Subagent)" title) so the
|
|
42
42
|
* broadcast stays non-degraded (#292) without a second emission path.
|
|
43
43
|
*/
|
|
44
|
-
export class LocalUserAuthorizer implements
|
|
44
|
+
export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
45
45
|
constructor(private readonly deps: LocalUserAuthorizerDeps) {}
|
|
46
46
|
|
|
47
47
|
authorize(
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
ForwardedSessionApproval,
|
|
5
5
|
} from "#src/authority/permission-forwarding";
|
|
6
6
|
import type { ReviewLogger } from "#src/session-logger";
|
|
7
|
-
import type {
|
|
7
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
8
8
|
|
|
9
9
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
10
10
|
|
|
@@ -69,7 +69,7 @@ export interface PromptPermissionDetails {
|
|
|
69
69
|
*/
|
|
70
70
|
export interface PermissionPrompterApi {
|
|
71
71
|
prompt(
|
|
72
|
-
authorizer:
|
|
72
|
+
authorizer: TerminalAuthorizer,
|
|
73
73
|
details: PromptPermissionDetails,
|
|
74
74
|
): Promise<PermissionPromptDecision>;
|
|
75
75
|
}
|
|
@@ -82,7 +82,7 @@ export interface PermissionPrompterDeps {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Brackets the ask-path flow with review-log entries and delegates the
|
|
85
|
-
* live decision to the selected {@link
|
|
85
|
+
* live decision to the selected {@link TerminalAuthorizer}:
|
|
86
86
|
* 1. Review-log "waiting" entry.
|
|
87
87
|
* 2. `authorizer.authorize(details)`.
|
|
88
88
|
* 3. Review-log "approved" / "denied" entry.
|
|
@@ -100,7 +100,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
100
100
|
constructor(private readonly deps: PermissionPrompterDeps) {}
|
|
101
101
|
|
|
102
102
|
async prompt(
|
|
103
|
-
authorizer:
|
|
103
|
+
authorizer: TerminalAuthorizer,
|
|
104
104
|
details: PromptPermissionDetails,
|
|
105
105
|
): Promise<PermissionPromptDecision> {
|
|
106
106
|
this.writeReviewEntry("permission_request.waiting", details);
|
package/src/config-loader.ts
CHANGED
|
@@ -231,10 +231,11 @@ export function mergeUnifiedConfigs(
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// Array fields: override replaces base when defined
|
|
234
|
-
const piInfrastructureReadPaths
|
|
235
|
-
override
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) {
|
|
235
|
+
const value = override[key] ?? base[key];
|
|
236
|
+
if (value !== undefined) {
|
|
237
|
+
merged[key] = value;
|
|
238
|
+
}
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
// shellTools: shallow-merge by tool name so a project entry overrides a
|
package/src/config-schema.ts
CHANGED
|
@@ -206,6 +206,13 @@ export const unifiedConfigSchema = z
|
|
|
206
206
|
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
|
|
207
207
|
default: [],
|
|
208
208
|
}),
|
|
209
|
+
authorizerChain: z.array(z.string().min(1)).optional().meta({
|
|
210
|
+
description:
|
|
211
|
+
"Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
|
|
212
|
+
markdownDescription:
|
|
213
|
+
"Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here \u2014 not the order extensions register in \u2014 fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).",
|
|
214
|
+
default: [],
|
|
215
|
+
}),
|
|
209
216
|
permission: permissionSchema.optional(),
|
|
210
217
|
shellTools: shellToolsSchema.optional(),
|
|
211
218
|
})
|
package/src/extension-config.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
23
23
|
toolTextSummaryMaxLength?: number;
|
|
24
24
|
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
|
25
25
|
shellTools?: ShellToolsConfig;
|
|
26
|
+
/** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
|
|
27
|
+
authorizerChain?: string[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
|
@@ -75,6 +77,9 @@ export function normalizePermissionSystemConfig(
|
|
|
75
77
|
if (raw.shellTools !== undefined) {
|
|
76
78
|
result.shellTools = raw.shellTools;
|
|
77
79
|
}
|
|
80
|
+
if (raw.authorizerChain !== undefined) {
|
|
81
|
+
result.authorizerChain = raw.authorizerChain;
|
|
82
|
+
}
|
|
78
83
|
return result;
|
|
79
84
|
}
|
|
80
85
|
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { warmBashParser } from "./access-intent/bash/parser";
|
|
4
4
|
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
|
|
5
|
+
import { AuthorizerRegistry } from "./authority/authorizer-registry";
|
|
5
6
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
6
7
|
import {
|
|
7
8
|
ForwardedRequestServer,
|
|
@@ -64,6 +65,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
64
65
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
65
66
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
66
67
|
const accessExtractorRegistry = new ToolAccessExtractorRegistry();
|
|
68
|
+
// One registry instance backs both the registerAuthorizer service surface and
|
|
69
|
+
// AuthorizerSelection's chain resolution, so a registration is visible to
|
|
70
|
+
// composition.
|
|
71
|
+
const authorizerRegistry = new AuthorizerRegistry();
|
|
67
72
|
|
|
68
73
|
// Both `configStore` and `session` are forward-declared so the logger's
|
|
69
74
|
// lazy thunks can close over them without a cast or null-init holder.
|
|
@@ -108,6 +113,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
108
113
|
registry: subagentRegistry,
|
|
109
114
|
logger,
|
|
110
115
|
prompter,
|
|
116
|
+
// The published service is the narrow, session-scoped PermissionQuery a
|
|
117
|
+
// chain link is handed (it routes bash/path at gate parity against the live
|
|
118
|
+
// session cwd). A thunk because `permissionsService` is constructed below;
|
|
119
|
+
// it resolves at session_start (activate), well after assignment.
|
|
120
|
+
getPermissionQuery: () => permissionsService,
|
|
121
|
+
// Same registry instance the registerAuthorizer service surface writes to,
|
|
122
|
+
// resolved in config order at activation.
|
|
123
|
+
authorizerRegistry,
|
|
124
|
+
getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
|
|
111
125
|
});
|
|
112
126
|
|
|
113
127
|
// Resolver composes the manager + session ruleset and owns the
|
|
@@ -174,6 +188,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
174
188
|
session,
|
|
175
189
|
formatterRegistry,
|
|
176
190
|
accessExtractorRegistry,
|
|
191
|
+
authorizerRegistry,
|
|
177
192
|
);
|
|
178
193
|
|
|
179
194
|
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
2
|
import { buildAccessIntentForSurface } from "./access-intent/input-normalizer";
|
|
3
|
+
import type { Authorizer } from "./authority/authorizer";
|
|
4
|
+
import type { AuthorizerRegistrar } from "./authority/authorizer-registry";
|
|
3
5
|
import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
|
|
4
6
|
import type { PathNormalizer } from "./path-normalizer";
|
|
5
7
|
import type { PermissionsService } from "./service";
|
|
@@ -44,6 +46,7 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
44
46
|
private readonly session: PathNormalizerProvider,
|
|
45
47
|
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
|
46
48
|
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
|
49
|
+
private readonly authorizerRegistry: AuthorizerRegistrar,
|
|
47
50
|
) {}
|
|
48
51
|
|
|
49
52
|
checkPermission(
|
|
@@ -87,4 +90,11 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
87
90
|
): ReturnType<PermissionsService["registerToolAccessExtractor"]> {
|
|
88
91
|
return this.accessExtractorRegistry.register(toolName, extractor);
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
registerAuthorizer(
|
|
95
|
+
name: string,
|
|
96
|
+
authorize: Authorizer["authorize"],
|
|
97
|
+
): ReturnType<PermissionsService["registerAuthorizer"]> {
|
|
98
|
+
return this.authorizerRegistry.register(name, authorize);
|
|
99
|
+
}
|
|
90
100
|
}
|
package/src/service.ts
CHANGED
|
@@ -11,10 +11,16 @@
|
|
|
11
11
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { Authorizer } from "./authority/authorizer";
|
|
14
15
|
import type { ToolAccessExtractor } from "./tool-access-extractor-registry";
|
|
15
16
|
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
16
17
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
17
18
|
|
|
19
|
+
export type {
|
|
20
|
+
Authorizer,
|
|
21
|
+
AuthorizerVerdict,
|
|
22
|
+
} from "./authority/authorizer";
|
|
23
|
+
export type { PromptPermissionDetails } from "./authority/permission-prompter";
|
|
18
24
|
export type {
|
|
19
25
|
ForwardedPromptContext,
|
|
20
26
|
PermissionDecisionEvent,
|
|
@@ -33,13 +39,12 @@ export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
|
33
39
|
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* rules internally.
|
|
42
|
+
* The narrow, read-only projection of {@link PermissionsService}: answer a
|
|
43
|
+
* policy query for a surface, and report a tool-level state. This is the
|
|
44
|
+
* capability an Authorizer chain link is handed (ISP) — it never sees the
|
|
45
|
+
* registration surface.
|
|
41
46
|
*/
|
|
42
|
-
export interface
|
|
47
|
+
export interface PermissionQuery {
|
|
43
48
|
/**
|
|
44
49
|
* Query the permission policy for a surface and value.
|
|
45
50
|
*
|
|
@@ -57,6 +62,28 @@ export interface PermissionsService {
|
|
|
57
62
|
agentName?: string,
|
|
58
63
|
): PermissionCheckResult;
|
|
59
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Query the tool-level permission state for pre-filtering tools before
|
|
67
|
+
* creating a child session.
|
|
68
|
+
*
|
|
69
|
+
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
70
|
+
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
71
|
+
* use `checkPermission` for runtime invocation gates.
|
|
72
|
+
*
|
|
73
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
74
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
75
|
+
*/
|
|
76
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Public interface exposed to other extensions via `getPermissionsService()`.
|
|
81
|
+
*
|
|
82
|
+
* `checkPermission` takes a surface + optional value + optional agent name,
|
|
83
|
+
* and delegates to `PermissionManager.checkPermission()` with current session
|
|
84
|
+
* rules internally.
|
|
85
|
+
*/
|
|
86
|
+
export interface PermissionsService extends PermissionQuery {
|
|
60
87
|
/**
|
|
61
88
|
* Register a custom preview formatter for a specific tool name.
|
|
62
89
|
*
|
|
@@ -100,17 +127,28 @@ export interface PermissionsService {
|
|
|
100
127
|
): () => void;
|
|
101
128
|
|
|
102
129
|
/**
|
|
103
|
-
*
|
|
104
|
-
* creating a child session.
|
|
130
|
+
* Register a named live-authority chain link (ADR 0007 §4).
|
|
105
131
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
132
|
+
* A link reviews an `ask` and returns `allow` / `deny` (with an optional
|
|
133
|
+
* teaching `reason`) / `defer`. It is handed a narrow, session-scoped
|
|
134
|
+
* {@link PermissionQuery} at `authorize` time so it can query the
|
|
135
|
+
* deterministic engine at gate parity. Register from a `permissions:ready`
|
|
136
|
+
* handler so registration is robust to load order and survives `/reload`.
|
|
109
137
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
138
|
+
* Registration alone grants **no authority**: the link decides nothing until
|
|
139
|
+
* the operator names it in the `authorizerChain` config (opt-in activation),
|
|
140
|
+
* and the chain owner caps every verdict with the bounded-delegation
|
|
141
|
+
* checkpoint (an `allow` on an excluded surface downgrades to `defer`). Only
|
|
142
|
+
* one link may be registered per name — a second call for the same name
|
|
143
|
+
* throws. The returned disposer unregisters the link.
|
|
144
|
+
*
|
|
145
|
+
* @param name - Operator-facing link name referenced from `authorizerChain`.
|
|
146
|
+
* @param authorize - The link's decision callback (`(details, query) => verdict`).
|
|
112
147
|
*/
|
|
113
|
-
|
|
148
|
+
registerAuthorizer(
|
|
149
|
+
name: string,
|
|
150
|
+
authorize: Authorizer["authorize"],
|
|
151
|
+
): () => void;
|
|
114
152
|
}
|
|
115
153
|
|
|
116
154
|
/**
|