@gotgenes/pi-permission-system 20.7.3 → 20.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -0
  3. package/config/config.example.json +2 -0
  4. package/dist/public.d.ts +212 -75
  5. package/docs/configuration.md +45 -9
  6. package/package.json +1 -1
  7. package/schemas/permissions.schema.json +10 -0
  8. package/src/access-intent/input-normalizer.ts +34 -1
  9. package/src/authority/approval-escalator.ts +53 -23
  10. package/src/authority/authorizer-chain.ts +60 -0
  11. package/src/authority/authorizer-registry.ts +69 -0
  12. package/src/authority/authorizer-selection.ts +54 -8
  13. package/src/authority/authorizer.ts +31 -4
  14. package/src/authority/delegation-envelope.ts +52 -0
  15. package/src/authority/denying-authorizer.ts +2 -2
  16. package/src/authority/forwarded-request-server.ts +22 -32
  17. package/src/authority/forwarder-context.ts +7 -0
  18. package/src/authority/forwarding-io.ts +61 -0
  19. package/src/authority/local-user-authorizer.ts +2 -2
  20. package/src/authority/permission-forwarding.ts +46 -0
  21. package/src/authority/permission-prompter.ts +16 -5
  22. package/src/config-loader.ts +5 -4
  23. package/src/config-schema.ts +7 -0
  24. package/src/extension-config.ts +5 -0
  25. package/src/handlers/gates/bash-external-directory.ts +6 -0
  26. package/src/handlers/gates/bash-path.ts +2 -0
  27. package/src/handlers/gates/external-directory.ts +2 -0
  28. package/src/handlers/gates/helpers.ts +33 -0
  29. package/src/handlers/gates/path.ts +2 -0
  30. package/src/handlers/gates/skill-input.ts +2 -0
  31. package/src/handlers/gates/skill-read.ts +2 -0
  32. package/src/handlers/gates/tool.ts +19 -6
  33. package/src/index.ts +26 -13
  34. package/src/permission-resolver.ts +17 -2
  35. package/src/permissions-service.ts +10 -0
  36. package/src/service.ts +52 -14
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.8.0...pi-permission-system-v20.9.0) (2026-07-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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))
14
+ * **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))
15
+ * **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))
16
+ * **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))
17
+ * **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))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * **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))
23
+
24
+ ## [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)
25
+
26
+
27
+ ### Features
28
+
29
+ * **pi-permission-system:** accept pre-fixed path-values intents for forwarded serving ([ab60874](https://github.com/gotgenes/pi-packages/commit/ab6087464c7719c01bd7f2a15fd4c56a1a7e9f93))
30
+ * **pi-permission-system:** declare ForwardedAccessIntent wire schema with tolerant read ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([66ddbef](https://github.com/gotgenes/pi-packages/commit/66ddbef74e3fa6cd41d17358a0b817ae91d49ed1))
31
+ * **pi-permission-system:** emit access-intent facts from the bash path gates ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([1a0e6de](https://github.com/gotgenes/pi-packages/commit/1a0e6de3fdf411ab576d0c9aa5571ff23cb5f179))
32
+ * **pi-permission-system:** emit access-intent facts from the per-tool gate ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([2e17256](https://github.com/gotgenes/pi-packages/commit/2e1725695442b10073ea627ce9699ef363e4c77c))
33
+ * **pi-permission-system:** emit access-intent facts from the skill gates ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([5a20033](https://github.com/gotgenes/pi-packages/commit/5a20033fca9f33dee576c5d39c0c04e468d2d031))
34
+ * **pi-permission-system:** emit access-intent facts from the tool path gates ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([93a3398](https://github.com/gotgenes/pi-packages/commit/93a3398245b06fe6cb80f841d2a2ca5dc8172de5))
35
+ * **pi-permission-system:** serialize the child-fixed access intent onto the forwarded request ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([5234614](https://github.com/gotgenes/pi-packages/commit/52346148c41a097719558175582b57d12e7832d0))
36
+ * **pi-permission-system:** serving resolves the forwarded access intent at gate parity ([#597](https://github.com/gotgenes/pi-packages/issues/597)) ([a8fe815](https://github.com/gotgenes/pi-packages/commit/a8fe815bd70e3243553b7cac6841ec270e20bf23))
37
+
38
+
39
+ ### Documentation
40
+
41
+ * **pi-permission-system:** fix stale ServingPolicy doc comment ([#597](https://github.com/gotgenes/pi-packages/issues/597)) ([64b1b8e](https://github.com/gotgenes/pi-packages/commit/64b1b8e13a8d22ee4f088bef7d54024a81d9a437))
42
+
8
43
  ## [20.7.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.2...pi-permission-system-v20.7.3) (2026-07-15)
9
44
 
10
45
 
package/README.md CHANGED
@@ -112,6 +112,9 @@ 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
+
115
118
  For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
116
119
 
117
120
  ## 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,38 @@ 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
+
162
198
  ---
163
199
 
164
200
  ## Policy Reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.7.3",
3
+ "version": "20.9.0",
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": {
@@ -1,7 +1,7 @@
1
1
  import { stripBashCommentLines } from "#src/bash-arity";
2
2
  import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import { getNonEmptyString, toRecord } from "#src/value-guards";
4
- import type { AccessIntent } from "./access-intent";
4
+ import type { AccessIntent, ResolvedAccessIntent } from "./access-intent";
5
5
  import { createMcpPermissionTargets } from "./mcp-targets";
6
6
  import { PATH_SURFACES } from "./path-surfaces";
7
7
  import { classifyToolKind } from "./tool-kind";
@@ -41,6 +41,39 @@ export function buildAccessIntentForSurface(
41
41
  };
42
42
  }
43
43
 
44
+ /**
45
+ * Build a {@link ResolvedAccessIntent} directly from a forwarded request's
46
+ * child-fixed match values (ADR 0008 §2), for the forwarded-serving wire
47
+ * (#597).
48
+ *
49
+ * Unlike {@link buildAccessIntentForSurface}, this never touches a
50
+ * `PathNormalizer` and never rebuilds an `AccessPath` — a path-shaped surface
51
+ * gets a `path-values` intent carrying `matchValues` as-is (the values the
52
+ * child already fixed), and every other surface gets a `tool` intent built
53
+ * from its single portable value. `agentName` is always the requester's
54
+ * `principal.agentName` (ADR 0008 §3, agent-scoped serving).
55
+ */
56
+ export function buildResolvedIntentFromMatchValues(
57
+ surface: string,
58
+ matchValues: readonly string[],
59
+ agentName: string,
60
+ ): ResolvedAccessIntent {
61
+ if (PATH_SURFACES.has(surface)) {
62
+ return {
63
+ kind: "path-values",
64
+ surface,
65
+ values: [...matchValues],
66
+ agentName,
67
+ };
68
+ }
69
+ return {
70
+ kind: "tool",
71
+ surface,
72
+ input: buildInputForSurface(surface, matchValues[0]),
73
+ agentName,
74
+ };
75
+ }
76
+
44
77
  /**
45
78
  * Construct a surface-appropriate input object from a raw value string for the
46
79
  * `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path