@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 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
@@ -11,6 +11,8 @@
11
11
 
12
12
  "piInfrastructureReadPaths": [],
13
13
 
14
+ "authorizerChain": [],
15
+
14
16
  "shellTools": {
15
17
  "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
16
18
  },
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
- * Public interface exposed to other extensions via `getPermissionsService()`.
153
- *
154
- * `checkPermission` takes a surface + optional value + optional agent name,
155
- * and delegates to `PermissionManager.checkPermission()` with current session
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 PermissionsService {
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
- * Query the tool-level permission state for pre-filtering tools before
207
- * creating a child session.
335
+ * Register a named live-authority chain link (ADR 0007 §4).
208
336
  *
209
- * Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
210
- * Does not consider command-level rules (e.g. per-bash-command patterns)
211
- * use `checkPermission` for runtime invocation gates.
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
- * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
214
- * @param agentName - Optional agent name for per-agent policy resolution.
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
- getToolPermission(toolName: string, agentName?: string): PermissionState;
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 };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.8.0",
3
+ "version": "20.9.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { Authorizer } from "./authorizer";
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 Authorizer {
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 selected: Authorizer | null = null;
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
- /** Select the Authorizer for `ctx` and store it. */
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.selected = selectAuthorizer(ctx, this.deps);
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.selected = null;
103
+ this.terminal = null;
68
104
  }
69
105
 
70
106
  /**
71
- * Escalate an ask to the selected authorizer and return its decision.
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 authorizer has been selected — i.e. before the session was
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.selected === null) {
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
- return this.deps.prompter.prompt(this.selected, details);
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
- * The live-authority role: on `ask`, an `Authorizer` rules on a single
18
- * request and is told the decision.
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 Authorizer {
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
- ): Authorizer {
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 { Authorizer } from "./authorizer";
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 Authorizer {
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 { Authorizer } from "./authorizer";
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 Authorizer {
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 { Authorizer } from "./authorizer";
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: 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 Authorizer}:
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: Authorizer,
103
+ authorizer: TerminalAuthorizer,
104
104
  details: PromptPermissionDetails,
105
105
  ): Promise<PermissionPromptDecision> {
106
106
  this.writeReviewEntry("permission_request.waiting", details);
@@ -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.piInfrastructureReadPaths ?? base.piInfrastructureReadPaths;
236
- if (piInfrastructureReadPaths !== undefined) {
237
- merged.piInfrastructureReadPaths = piInfrastructureReadPaths;
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
@@ -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
  })
@@ -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
- * Public interface exposed to other extensions via `getPermissionsService()`.
37
- *
38
- * `checkPermission` takes a surface + optional value + optional agent name,
39
- * and delegates to `PermissionManager.checkPermission()` with current session
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 PermissionsService {
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
- * Query the tool-level permission state for pre-filtering tools before
104
- * creating a child session.
130
+ * Register a named live-authority chain link (ADR 0007 §4).
105
131
  *
106
- * Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
107
- * Does not consider command-level rules (e.g. per-bash-command patterns)
108
- * use `checkPermission` for runtime invocation gates.
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
- * @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
111
- * @param agentName - Optional agent name for per-agent policy resolution.
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
- getToolPermission(toolName: string, agentName?: string): PermissionState;
148
+ registerAuthorizer(
149
+ name: string,
150
+ authorize: Authorizer["authorize"],
151
+ ): () => void;
114
152
  }
115
153
 
116
154
  /**