@gotgenes/pi-permission-system 20.7.2 → 20.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/public.d.ts +249 -0
- package/docs/cross-extension-api.md +3 -0
- package/package.json +10 -2
- package/src/access-intent/input-normalizer.ts +34 -1
- package/src/authority/approval-escalator.ts +51 -21
- package/src/authority/forwarded-request-server.ts +22 -32
- package/src/authority/forwarder-context.ts +7 -0
- package/src/authority/forwarding-io.ts +61 -0
- package/src/authority/permission-forwarding.ts +46 -0
- package/src/authority/permission-prompter.ts +12 -1
- package/src/handlers/gates/bash-external-directory.ts +6 -0
- package/src/handlers/gates/bash-path.ts +2 -0
- package/src/handlers/gates/external-directory.ts +2 -0
- package/src/handlers/gates/helpers.ts +33 -0
- package/src/handlers/gates/path.ts +2 -0
- package/src/handlers/gates/skill-input.ts +2 -0
- package/src/handlers/gates/skill-read.ts +2 -0
- package/src/handlers/gates/tool.ts +19 -6
- package/src/index.ts +11 -13
- package/src/permission-resolver.ts +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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.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
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** accept pre-fixed path-values intents for forwarded serving ([ab60874](https://github.com/gotgenes/pi-packages/commit/ab6087464c7719c01bd7f2a15fd4c56a1a7e9f93))
|
|
14
|
+
* **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))
|
|
15
|
+
* **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))
|
|
16
|
+
* **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))
|
|
17
|
+
* **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))
|
|
18
|
+
* **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))
|
|
19
|
+
* **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))
|
|
20
|
+
* **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))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* **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))
|
|
26
|
+
|
|
27
|
+
## [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)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Bug Fixes
|
|
31
|
+
|
|
32
|
+
* **pi-permission-system:** ship consumable public type declarations ([#592](https://github.com/gotgenes/pi-packages/issues/592)) ([542e094](https://github.com/gotgenes/pi-packages/commit/542e094b9650e8f13bd9dad3864007f3ce2c0cc2))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
* **pi-permission-system:** document the bundled public type declaration ([070875d](https://github.com/gotgenes/pi-packages/commit/070875d654efde50e8867f8fd4aeba857a26c4fb))
|
|
38
|
+
|
|
8
39
|
## [20.7.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.1...pi-permission-system-v20.7.2) (2026-07-14)
|
|
9
40
|
|
|
10
41
|
|
package/dist/public.d.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
/** Emitted at `session_start`, after the service is published. */
|
|
64
|
+
declare const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
65
|
+
/** Emitted when a permission request is committed to the active UI prompt path. */
|
|
66
|
+
declare const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
|
|
67
|
+
/** Emitted after every permission gate resolution. */
|
|
68
|
+
declare const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
|
69
|
+
/**
|
|
70
|
+
* Payload emitted on `permissions:ready`.
|
|
71
|
+
*
|
|
72
|
+
* Intentionally empty: the channel is a readiness signal. There is no
|
|
73
|
+
* `protocolVersion` — the published types plus package semver define the
|
|
74
|
+
* broadcast contract.
|
|
75
|
+
*/
|
|
76
|
+
type PermissionsReadyEvent = Record<string, never>;
|
|
77
|
+
/**
|
|
78
|
+
* Origin of a UI prompt.
|
|
79
|
+
*
|
|
80
|
+
* Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its
|
|
81
|
+
* original source and is identified by a non-null `forwarding` field, not by a
|
|
82
|
+
* dedicated source value.
|
|
83
|
+
*/
|
|
84
|
+
type PermissionUiPromptSource = "tool_call" | "skill_input" | "skill_read";
|
|
85
|
+
/** Forwarding context, present only when a prompt was forwarded from a non-UI subagent. */
|
|
86
|
+
interface ForwardedPromptContext {
|
|
87
|
+
/** Requesting subagent's display name, when known. */
|
|
88
|
+
requesterAgentName: string | null;
|
|
89
|
+
/** Requesting subagent's session id, when known. */
|
|
90
|
+
requesterSessionId: string | null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Payload emitted on `permissions:ui_prompt`, immediately before the active
|
|
94
|
+
* user-facing permission UI is shown.
|
|
95
|
+
*
|
|
96
|
+
* Lean by design: `surface`/`value` are the normalized display projection a
|
|
97
|
+
* notification consumer reads; `source` is the origin; `forwarding` is non-null
|
|
98
|
+
* only for forwarded subagent prompts. There is no `protocolVersion` — the
|
|
99
|
+
* published types plus package semver define the broadcast contract, and
|
|
100
|
+
* consumers should read defensively.
|
|
101
|
+
*/
|
|
102
|
+
interface PermissionUiPromptEvent {
|
|
103
|
+
/** Unique ID for the permission request being prompted. */
|
|
104
|
+
requestId: string;
|
|
105
|
+
/** Prompt origin. */
|
|
106
|
+
source: PermissionUiPromptSource;
|
|
107
|
+
/** Normalized display surface (e.g. "bash", "skill"), when known. */
|
|
108
|
+
surface: string | null;
|
|
109
|
+
/** Normalized display value (command, path, skill name, etc.), when known. */
|
|
110
|
+
value: string | null;
|
|
111
|
+
/** Agent name (when known). */
|
|
112
|
+
agentName: string | null;
|
|
113
|
+
/** Message displayed to the user. */
|
|
114
|
+
message: string;
|
|
115
|
+
/** Forwarding context, or null for a direct prompt. */
|
|
116
|
+
forwarding: ForwardedPromptContext | null;
|
|
117
|
+
}
|
|
118
|
+
/** How a permission decision was reached. */
|
|
119
|
+
type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable";
|
|
120
|
+
/** Payload emitted on `permissions:decision`. */
|
|
121
|
+
interface PermissionDecisionEvent {
|
|
122
|
+
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
123
|
+
surface: string;
|
|
124
|
+
/** The value that was evaluated (command, tool name, skill name, path). */
|
|
125
|
+
value: string;
|
|
126
|
+
/** Final decision. */
|
|
127
|
+
result: "allow" | "deny";
|
|
128
|
+
/** How the decision was reached. */
|
|
129
|
+
resolution: PermissionDecisionResolution;
|
|
130
|
+
/** Which config scope contributed the winning rule (when available). */
|
|
131
|
+
origin: string | null;
|
|
132
|
+
/** Agent name (when known). */
|
|
133
|
+
agentName: string | null;
|
|
134
|
+
/** Matched pattern from the winning rule (when available). */
|
|
135
|
+
matchedPattern: string | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
|
|
140
|
+
*
|
|
141
|
+
* `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
|
|
142
|
+
* module isolation (`moduleCache: false`). A consumer doing
|
|
143
|
+
* `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
|
|
144
|
+
* `getPermissionsService()` reads from the same `globalThis` slot the provider
|
|
145
|
+
* wrote to — enabling direct, synchronous, type-safe function calls.
|
|
146
|
+
*
|
|
147
|
+
* Best practice: call `getPermissionsService()` per use rather than caching the
|
|
148
|
+
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
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.
|
|
157
|
+
*/
|
|
158
|
+
interface PermissionsService {
|
|
159
|
+
/**
|
|
160
|
+
* Query the permission policy for a surface and value.
|
|
161
|
+
*
|
|
162
|
+
* @param surface - Permission surface: "bash", "read", "mcp", "skill",
|
|
163
|
+
* "external_directory", etc.
|
|
164
|
+
* @param value - The value to evaluate: command string, tool name, skill
|
|
165
|
+
* name, or path. Omit or pass `undefined` for a
|
|
166
|
+
* surface-level query.
|
|
167
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
168
|
+
* @returns Full check result including state, matched pattern, and origin.
|
|
169
|
+
*/
|
|
170
|
+
checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
|
|
171
|
+
/**
|
|
172
|
+
* Register a custom preview formatter for a specific tool name.
|
|
173
|
+
*
|
|
174
|
+
* The formatter is consulted first inside `ToolPreviewFormatter.formatToolInputForPrompt`;
|
|
175
|
+
* returning `undefined` falls through to the built-in switch (and ultimately
|
|
176
|
+
* the JSON default).
|
|
177
|
+
*
|
|
178
|
+
* Only one formatter may be registered per tool name — a second call for the
|
|
179
|
+
* same name throws. The returned disposer unregisters the formatter.
|
|
180
|
+
*
|
|
181
|
+
* @param toolName - Exact tool name to register for (e.g. `"mcp"`, `"my-server:run"`).
|
|
182
|
+
* @param formatter - Receives the raw `input` record; return a string to use
|
|
183
|
+
* as the prompt preview, or `undefined` to decline.
|
|
184
|
+
*/
|
|
185
|
+
registerToolInputFormatter(toolName: string, formatter: ToolInputFormatter): () => void;
|
|
186
|
+
/**
|
|
187
|
+
* Register a custom access-intent extractor for a specific tool name.
|
|
188
|
+
*
|
|
189
|
+
* The extractor declares the filesystem path a tool will access so the
|
|
190
|
+
* cross-cutting `path` and `external_directory` gates can see it. Use it for
|
|
191
|
+
* tools whose path lives under a non-standard key — built-in file tools and
|
|
192
|
+
* any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
|
|
193
|
+
* already covered by convention without registration.
|
|
194
|
+
*
|
|
195
|
+
* The extractor receives the raw `input` record and returns the path string,
|
|
196
|
+
* or `undefined` to decline. Only one extractor may be registered per tool
|
|
197
|
+
* name — a second call for the same name throws. The returned disposer
|
|
198
|
+
* unregisters the extractor.
|
|
199
|
+
*
|
|
200
|
+
* @param toolName - Exact tool name to register for (e.g. `"ffgrep"`).
|
|
201
|
+
* @param extractor - Receives the raw `input` record; return the path string,
|
|
202
|
+
* or `undefined` to decline.
|
|
203
|
+
*/
|
|
204
|
+
registerToolAccessExtractor(toolName: string, extractor: ToolAccessExtractor): () => void;
|
|
205
|
+
/**
|
|
206
|
+
* Query the tool-level permission state for pre-filtering tools before
|
|
207
|
+
* creating a child session.
|
|
208
|
+
*
|
|
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.
|
|
212
|
+
*
|
|
213
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
214
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
215
|
+
*/
|
|
216
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Store a `PermissionsService` on `globalThis` so other extensions can
|
|
220
|
+
* retrieve it via `getPermissionsService()`.
|
|
221
|
+
*
|
|
222
|
+
* Called at `session_start` by the top-level (parent) instance only — an
|
|
223
|
+
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
224
|
+
* service. Overwrites any previously published service, which keeps `/reload`
|
|
225
|
+
* working: a reloaded parent re-publishes its fresh service.
|
|
226
|
+
*/
|
|
227
|
+
declare function publishPermissionsService(service: PermissionsService): void;
|
|
228
|
+
/**
|
|
229
|
+
* Retrieve the published `PermissionsService`, or `undefined` if the
|
|
230
|
+
* permission-system extension has not loaded (or has been unloaded).
|
|
231
|
+
*/
|
|
232
|
+
declare function getPermissionsService(): PermissionsService | undefined;
|
|
233
|
+
/**
|
|
234
|
+
* Remove `service` from `globalThis`, but only when the current slot still
|
|
235
|
+
* holds it (identity compare-and-delete).
|
|
236
|
+
*
|
|
237
|
+
* Called during `session_shutdown` to avoid stale references after the
|
|
238
|
+
* extension is torn down. Scoping the delete to the publishing instance keeps
|
|
239
|
+
* two cases correct:
|
|
240
|
+
*
|
|
241
|
+
* - An in-process subagent child never published the parent's service, so its
|
|
242
|
+
* shutdown is a no-op and the parent's slot survives.
|
|
243
|
+
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
244
|
+
* shutdown cannot wipe the new generation's freshly published service.
|
|
245
|
+
*/
|
|
246
|
+
declare function unpublishPermissionsService(service: PermissionsService): void;
|
|
247
|
+
|
|
248
|
+
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 };
|
|
@@ -39,6 +39,9 @@ Consumers call `getPermissionsService()` to retrieve it — even though their `i
|
|
|
39
39
|
An in-process subagent child does not publish its own service; inside a child, `getPermissionsService()` resolves the parent's service.
|
|
40
40
|
A consumer reacting to the `permissions:ready` broadcast (also emitted at `session_start`, after the publish) can resolve the service immediately.
|
|
41
41
|
|
|
42
|
+
All types below are directly importable and type-check with `tsc` out of the box.
|
|
43
|
+
`@gotgenes/pi-permission-system`'s published `exports` resolve `import type { … }` to a self-contained, bundled declaration file with no internal module references, so a downstream `tsconfig.json` needs no special path configuration.
|
|
44
|
+
|
|
42
45
|
### API
|
|
43
46
|
|
|
44
47
|
The `PermissionsService` interface:
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.8.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/public.d.ts",
|
|
9
|
+
"default": "./src/service.ts"
|
|
10
|
+
}
|
|
8
11
|
},
|
|
9
12
|
"imports": {
|
|
10
13
|
"#src/*": "./src/*",
|
|
@@ -12,6 +15,7 @@
|
|
|
12
15
|
},
|
|
13
16
|
"files": [
|
|
14
17
|
"src",
|
|
18
|
+
"dist",
|
|
15
19
|
"config/config.example.json",
|
|
16
20
|
"schemas/permissions.schema.json",
|
|
17
21
|
"docs/*.md",
|
|
@@ -67,6 +71,8 @@
|
|
|
67
71
|
"@earendil-works/pi-coding-agent": "0.79.1",
|
|
68
72
|
"@earendil-works/pi-tui": "0.79.1",
|
|
69
73
|
"@types/node": "^22.15.3",
|
|
74
|
+
"rollup": "^4.62.2",
|
|
75
|
+
"rollup-plugin-dts": "^6.4.1",
|
|
70
76
|
"rumdl": "^0.2.10",
|
|
71
77
|
"typescript": "^6.0.3",
|
|
72
78
|
"vitest": "^4.1.8"
|
|
@@ -78,9 +84,11 @@
|
|
|
78
84
|
},
|
|
79
85
|
"scripts": {
|
|
80
86
|
"check": "tsc --noEmit",
|
|
87
|
+
"build:types": "rollup -c rollup.dts.config.mjs",
|
|
81
88
|
"gen:schema": "node --experimental-strip-types scripts/generate-permissions-schema.ts && biome format --write schemas/permissions.schema.json",
|
|
82
89
|
"test": "vitest run",
|
|
83
90
|
"test:watch": "vitest",
|
|
91
|
+
"verify:public-types": "bash scripts/verify-public-types.sh",
|
|
84
92
|
"lint:md": "rumdl check *.md docs/**/*.md",
|
|
85
93
|
"lint": "biome check . && eslint . && pnpm run lint:md"
|
|
86
94
|
}
|
|
@@ -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
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "#src/active-agent";
|
|
7
7
|
import {
|
|
8
8
|
type ForwarderContext,
|
|
9
|
+
getCwd,
|
|
9
10
|
getSessionId,
|
|
10
11
|
} from "#src/authority/forwarder-context";
|
|
11
12
|
import {
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from "#src/authority/forwarding-io";
|
|
21
22
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
22
23
|
import {
|
|
24
|
+
type ForwardedAccessFacts,
|
|
23
25
|
type ForwardedPermissionRequest,
|
|
24
26
|
type ForwardedPromptDisplay,
|
|
25
27
|
type ForwardedSessionApproval,
|
|
@@ -61,6 +63,23 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
|
61
63
|
|
|
62
64
|
// ── ParentAuthorizer ────────────────────────────────────────────────────
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The facts a forwarded request relays unchanged from the child's ask: the
|
|
68
|
+
* prompt message, the optional display projection, and the optional
|
|
69
|
+
* session-approval suggestion.
|
|
70
|
+
*
|
|
71
|
+
* Bundled into one object so the two-hop private chain
|
|
72
|
+
* (`waitForForwardedApproval` → `buildForwardedRequest`) threads a single
|
|
73
|
+
* relayed value instead of three positional optionals.
|
|
74
|
+
*/
|
|
75
|
+
interface ForwardedRequestFacts {
|
|
76
|
+
message: string;
|
|
77
|
+
display?: ForwardedPromptDisplay;
|
|
78
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
79
|
+
/** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
|
|
80
|
+
accessIntent?: ForwardedAccessFacts;
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
/** Constructor config for {@link ParentAuthorizer}. */
|
|
65
84
|
export interface ParentAuthorizerDeps {
|
|
66
85
|
forwardingDir: string;
|
|
@@ -99,25 +118,23 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
99
118
|
details: PromptPermissionDetails,
|
|
100
119
|
): Promise<PermissionPromptDecision> {
|
|
101
120
|
const uiPrompt = buildUiPrompt(details);
|
|
102
|
-
return this.waitForForwardedApproval(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
121
|
+
return this.waitForForwardedApproval(this.ctx, {
|
|
122
|
+
message: details.message,
|
|
123
|
+
display: {
|
|
106
124
|
source: uiPrompt.source,
|
|
107
125
|
surface: uiPrompt.surface,
|
|
108
126
|
value: uiPrompt.value,
|
|
109
127
|
},
|
|
110
|
-
details.sessionApproval,
|
|
111
|
-
|
|
128
|
+
sessionApproval: details.sessionApproval,
|
|
129
|
+
accessIntent: details.accessIntent,
|
|
130
|
+
});
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
// ── Private methods ────────────────────────────────────────────────────
|
|
115
134
|
|
|
116
135
|
private async waitForForwardedApproval(
|
|
117
136
|
ctx: ForwarderContext,
|
|
118
|
-
|
|
119
|
-
forwarded?: ForwardedPromptDisplay,
|
|
120
|
-
sessionApproval?: ForwardedSessionApproval,
|
|
137
|
+
facts: ForwardedRequestFacts,
|
|
121
138
|
): Promise<PermissionPromptDecision> {
|
|
122
139
|
const requesterSessionId = getSessionId(ctx);
|
|
123
140
|
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
@@ -159,11 +176,9 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
159
176
|
|
|
160
177
|
const request = this.buildForwardedRequest(
|
|
161
178
|
ctx,
|
|
162
|
-
|
|
179
|
+
facts,
|
|
163
180
|
requesterSessionId,
|
|
164
181
|
targetSessionId,
|
|
165
|
-
forwarded,
|
|
166
|
-
sessionApproval,
|
|
167
182
|
);
|
|
168
183
|
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
|
169
184
|
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
@@ -198,32 +213,47 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
198
213
|
|
|
199
214
|
private buildForwardedRequest(
|
|
200
215
|
ctx: ForwarderContext,
|
|
201
|
-
|
|
216
|
+
facts: ForwardedRequestFacts,
|
|
202
217
|
requesterSessionId: string,
|
|
203
218
|
targetSessionId: string,
|
|
204
|
-
forwarded?: ForwardedPromptDisplay,
|
|
205
|
-
sessionApproval?: ForwardedSessionApproval,
|
|
206
219
|
): ForwardedPermissionRequest {
|
|
207
220
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
208
221
|
const requesterAgentName =
|
|
209
222
|
getActiveAgentName(ctx) ??
|
|
210
223
|
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
|
211
224
|
"unknown";
|
|
225
|
+
// Complete the child-fixed facts into a full ForwardedAccessIntent: the
|
|
226
|
+
// gate fixed the access facts; the edge stamps the requester identity it
|
|
227
|
+
// alone knows (cwd + principal). The parent resolves against this intent
|
|
228
|
+
// and never re-derives the match set (ADR 0008).
|
|
229
|
+
const accessIntent = facts.accessIntent
|
|
230
|
+
? {
|
|
231
|
+
...facts.accessIntent,
|
|
232
|
+
requesterCwd: getCwd(ctx),
|
|
233
|
+
principal: {
|
|
234
|
+
sessionId: requesterSessionId,
|
|
235
|
+
agentName: requesterAgentName,
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
: undefined;
|
|
212
239
|
return {
|
|
213
240
|
id: requestId,
|
|
214
241
|
createdAt: Date.now(),
|
|
215
242
|
requesterSessionId,
|
|
216
243
|
targetSessionId,
|
|
217
244
|
requesterAgentName,
|
|
218
|
-
message,
|
|
219
|
-
...(
|
|
245
|
+
message: facts.message,
|
|
246
|
+
...(facts.display
|
|
220
247
|
? {
|
|
221
|
-
source:
|
|
222
|
-
surface:
|
|
223
|
-
value:
|
|
248
|
+
source: facts.display.source,
|
|
249
|
+
surface: facts.display.surface,
|
|
250
|
+
value: facts.display.value,
|
|
224
251
|
}
|
|
225
252
|
: {}),
|
|
226
|
-
...(sessionApproval
|
|
253
|
+
...(facts.sessionApproval
|
|
254
|
+
? { sessionApproval: facts.sessionApproval }
|
|
255
|
+
: {}),
|
|
256
|
+
...(accessIntent ? { accessIntent } : {}),
|
|
227
257
|
};
|
|
228
258
|
}
|
|
229
259
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "#src/authority/forwarder-context";
|
|
6
6
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
7
7
|
import {
|
|
8
|
+
type ForwardedAccessIntent,
|
|
8
9
|
type ForwardedPermissionRequest,
|
|
9
10
|
type ForwardedPermissionResponse,
|
|
10
11
|
isForwardedPermissionRequestForSession,
|
|
@@ -43,23 +44,25 @@ export interface InboxProcessor {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Recorded-authority view the serving node resolves a forwarded request
|
|
46
|
-
* against: answer one
|
|
47
|
-
* composed
|
|
48
|
-
*
|
|
47
|
+
* against: answer one {@link ForwardedAccessIntent} query on the serving
|
|
48
|
+
* session's composed ruleset, agent-scoped to the requester
|
|
49
|
+
* (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
|
|
50
|
+
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
|
|
49
51
|
*
|
|
50
52
|
* Narrow by design (ISP): the server needs one decision, not the whole
|
|
51
|
-
* resolver. The composition root satisfies it with
|
|
52
|
-
* `resolver.resolve`, the same
|
|
53
|
+
* resolver. The composition root satisfies it with
|
|
54
|
+
* `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
|
|
55
|
+
* `resolve` entry point `LocalPermissionsService` composes.
|
|
53
56
|
*/
|
|
54
57
|
export interface ServingPolicy {
|
|
55
|
-
|
|
58
|
+
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** Constructor config for `ForwardedRequestServer`. */
|
|
59
62
|
export interface ForwardedRequestServerDeps {
|
|
60
63
|
forwardingDir: string;
|
|
61
64
|
logger: DebugReviewLogger;
|
|
62
|
-
/** Recorded-authority resolution for
|
|
65
|
+
/** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
|
|
63
66
|
policy: ServingPolicy;
|
|
64
67
|
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
|
65
68
|
escalator: AskEscalator;
|
|
@@ -87,22 +90,6 @@ function formatForwardedPermissionPrompt(
|
|
|
87
90
|
].join("\n");
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
/**
|
|
91
|
-
* A request is resolvable against the ruleset only when it carries a concrete
|
|
92
|
-
* `(surface, value)` display projection. A legacy/version-skew request without
|
|
93
|
-
* them floors to `ask` (escalate), never a silent grant.
|
|
94
|
-
*/
|
|
95
|
-
function hasDisplayFields(
|
|
96
|
-
request: ForwardedPermissionRequest,
|
|
97
|
-
): request is ForwardedPermissionRequest & { surface: string; value: string } {
|
|
98
|
-
return (
|
|
99
|
-
typeof request.surface === "string" &&
|
|
100
|
-
request.surface.length > 0 &&
|
|
101
|
-
typeof request.value === "string" &&
|
|
102
|
-
request.value.length > 0
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
93
|
/**
|
|
107
94
|
* Map a forwarded request onto the escalated ask's details, carrying the
|
|
108
95
|
* forwarded provenance (requester agent/session + the child's original display
|
|
@@ -135,9 +122,10 @@ function buildForwardedAskDetails(
|
|
|
135
122
|
/**
|
|
136
123
|
* Owner of the serving-down role of the forwarded-permission behavior:
|
|
137
124
|
* draining this session's forwarded-permission inbox and answering each
|
|
138
|
-
* request the same way the session resolves a local action —
|
|
139
|
-
* against
|
|
140
|
-
* selected `Authorizer` (`AskEscalator`) on `ask
|
|
125
|
+
* request the same way the session resolves a local action — resolving its
|
|
126
|
+
* `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
|
|
127
|
+
* escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
|
|
128
|
+
* 0008).
|
|
141
129
|
*/
|
|
142
130
|
export class ForwardedRequestServer implements InboxProcessor {
|
|
143
131
|
private readonly forwardingDir: string;
|
|
@@ -343,17 +331,19 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
343
331
|
|
|
344
332
|
/**
|
|
345
333
|
* Resolve the request the same way the session resolves a local action:
|
|
346
|
-
* recorded authority first (a request carrying
|
|
347
|
-
* against the serving node's
|
|
348
|
-
* yolo-rewritten, auto-approves;
|
|
349
|
-
*
|
|
334
|
+
* recorded authority first (a request carrying an `accessIntent` — the
|
|
335
|
+
* child-fixed facts, ADR 0008 §2 — resolves against the serving node's
|
|
336
|
+
* composed ruleset — `allow`, including yolo-rewritten, auto-approves;
|
|
337
|
+
* `deny` auto-denies), then escalate `ask` (or a request missing
|
|
338
|
+
* `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
|
|
339
|
+
* `Authorizer`.
|
|
350
340
|
*/
|
|
351
341
|
private async resolveDecision(
|
|
352
342
|
request: ForwardedPermissionRequest,
|
|
353
343
|
logDetails: Record<string, unknown>,
|
|
354
344
|
): Promise<PermissionPromptDecision> {
|
|
355
|
-
const state =
|
|
356
|
-
? this.policy.
|
|
345
|
+
const state = request.accessIntent
|
|
346
|
+
? this.policy.resolve(request.accessIntent).state
|
|
357
347
|
: "ask";
|
|
358
348
|
|
|
359
349
|
if (state === "allow") {
|
|
@@ -12,6 +12,8 @@ import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
|
|
|
12
12
|
export interface ForwarderContext {
|
|
13
13
|
hasUI: boolean;
|
|
14
14
|
ui: PermissionDecisionUi;
|
|
15
|
+
/** The session's working directory, stamped onto a forwarded request as the requester cwd. */
|
|
16
|
+
cwd: string;
|
|
15
17
|
sessionManager: {
|
|
16
18
|
getSessionId(): string;
|
|
17
19
|
getSessionDir(): string;
|
|
@@ -19,6 +21,11 @@ export interface ForwarderContext {
|
|
|
19
21
|
};
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/** Reads the current session cwd off `ctx`. */
|
|
25
|
+
export function getCwd(ctx: ForwarderContext): string {
|
|
26
|
+
return ctx.cwd;
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
|
|
23
30
|
export function getSessionId(ctx: ForwarderContext): string {
|
|
24
31
|
try {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
13
13
|
import {
|
|
14
14
|
createPermissionForwardingLocation,
|
|
15
|
+
type ForwardedAccessIntent,
|
|
15
16
|
type ForwardedPermissionRequest,
|
|
16
17
|
type ForwardedPermissionResponse,
|
|
17
18
|
type ForwardedSessionApproval,
|
|
@@ -67,6 +68,65 @@ function asForwardedSessionApproval(
|
|
|
67
68
|
return { surface: candidate.surface, patterns: [...candidate.patterns] };
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Narrow an unknown value to a `ForwardedAccessIntent`, or `undefined`.
|
|
73
|
+
*
|
|
74
|
+
* Tolerant read: the child-fixed access intent is optional (absent on an older
|
|
75
|
+
* child) and only accepted when fully well-formed — a string `surface`, an
|
|
76
|
+
* all-string `matchValues` array, a `string | null` `boundaryValue`, a string
|
|
77
|
+
* `requesterCwd`, and a `principal` with string `sessionId`/`agentName`. Any
|
|
78
|
+
* malformed shape → `undefined`, so the serving node floors to `ask` (Step 3)
|
|
79
|
+
* rather than resolving against corrupt facts.
|
|
80
|
+
*/
|
|
81
|
+
function asForwardedAccessIntent(
|
|
82
|
+
value: unknown,
|
|
83
|
+
): ForwardedAccessIntent | undefined {
|
|
84
|
+
if (typeof value !== "object" || value === null) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const candidate = value as {
|
|
88
|
+
surface?: unknown;
|
|
89
|
+
matchValues?: unknown;
|
|
90
|
+
boundaryValue?: unknown;
|
|
91
|
+
requesterCwd?: unknown;
|
|
92
|
+
principal?: unknown;
|
|
93
|
+
};
|
|
94
|
+
if (
|
|
95
|
+
typeof candidate.surface !== "string" ||
|
|
96
|
+
!Array.isArray(candidate.matchValues) ||
|
|
97
|
+
!candidate.matchValues.every((entry) => typeof entry === "string") ||
|
|
98
|
+
!(
|
|
99
|
+
candidate.boundaryValue === null ||
|
|
100
|
+
typeof candidate.boundaryValue === "string"
|
|
101
|
+
) ||
|
|
102
|
+
typeof candidate.requesterCwd !== "string" ||
|
|
103
|
+
typeof candidate.principal !== "object" ||
|
|
104
|
+
candidate.principal === null
|
|
105
|
+
) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const principal = candidate.principal as {
|
|
109
|
+
sessionId?: unknown;
|
|
110
|
+
agentName?: unknown;
|
|
111
|
+
};
|
|
112
|
+
if (
|
|
113
|
+
typeof principal.sessionId !== "string" ||
|
|
114
|
+
typeof principal.agentName !== "string"
|
|
115
|
+
) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
surface: candidate.surface,
|
|
120
|
+
matchValues: [...candidate.matchValues],
|
|
121
|
+
boundaryValue: candidate.boundaryValue,
|
|
122
|
+
requesterCwd: candidate.requesterCwd,
|
|
123
|
+
principal: {
|
|
124
|
+
sessionId: principal.sessionId,
|
|
125
|
+
agentName: principal.agentName,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
70
130
|
export function formatUnknownErrorMessage(error: unknown): string {
|
|
71
131
|
if (error instanceof Error && error.message) {
|
|
72
132
|
return error.message;
|
|
@@ -350,6 +410,7 @@ export function readForwardedPermissionRequest(
|
|
|
350
410
|
surface: asNullableDisplayString(parsed.surface),
|
|
351
411
|
value: asNullableDisplayString(parsed.value),
|
|
352
412
|
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
|
|
413
|
+
accessIntent: asForwardedAccessIntent(parsed.accessIntent),
|
|
353
414
|
};
|
|
354
415
|
} catch (error) {
|
|
355
416
|
logPermissionForwardingWarning(
|
|
@@ -66,6 +66,46 @@ export interface ForwardedSessionApproval {
|
|
|
66
66
|
patterns: readonly string[];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The child-fixed facts a gate emits: the surface it evaluated and the match
|
|
71
|
+
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
|
|
72
|
+
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
|
|
73
|
+
*
|
|
74
|
+
* Strings only — an `AccessPath` never crosses onto the wire
|
|
75
|
+
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
76
|
+
*/
|
|
77
|
+
export interface ForwardedAccessFacts {
|
|
78
|
+
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
|
|
79
|
+
surface: string;
|
|
80
|
+
/**
|
|
81
|
+
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
|
|
82
|
+
* (absolute ∪ cwd-relative ∪ canonical), computed at the child. Non-path
|
|
83
|
+
* surface: the already-portable single value as a one-element array.
|
|
84
|
+
*/
|
|
85
|
+
matchValues: string[];
|
|
86
|
+
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
87
|
+
boundaryValue: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
|
|
92
|
+
* plus the requester identity the escalation edge stamps.
|
|
93
|
+
*
|
|
94
|
+
* The serving node resolves against this intent directly (Step 3, [#597]),
|
|
95
|
+
* using `matchValues` as-is — it never re-derives a path through its own
|
|
96
|
+
* `PathNormalizer`/cwd. See
|
|
97
|
+
* `docs/decisions/0008-cross-session-access-intent.md`.
|
|
98
|
+
*/
|
|
99
|
+
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
|
|
100
|
+
/** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
|
|
101
|
+
requesterCwd: string;
|
|
102
|
+
/** Who is requesting. */
|
|
103
|
+
principal: {
|
|
104
|
+
sessionId: string;
|
|
105
|
+
agentName: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
export type ForwardedPermissionRequest = {
|
|
70
110
|
id: string;
|
|
71
111
|
createdAt: number;
|
|
@@ -89,6 +129,12 @@ export type ForwardedPermissionRequest = {
|
|
|
89
129
|
* omits it, and the serving dialog then offers no scope choice).
|
|
90
130
|
*/
|
|
91
131
|
sessionApproval?: ForwardedSessionApproval;
|
|
132
|
+
/**
|
|
133
|
+
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
|
|
134
|
+
* tolerance: an older child omits it, and the serving node floors to `ask`
|
|
135
|
+
* (Step 3). Present on a current child's request for every gate surface.
|
|
136
|
+
*/
|
|
137
|
+
accessIntent?: ForwardedAccessIntent;
|
|
92
138
|
};
|
|
93
139
|
|
|
94
140
|
export type ForwardedPermissionResponse = {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ForwardedAccessFacts,
|
|
4
|
+
ForwardedSessionApproval,
|
|
5
|
+
} from "#src/authority/permission-forwarding";
|
|
3
6
|
import type { ReviewLogger } from "#src/session-logger";
|
|
4
7
|
import type { Authorizer } from "./authorizer";
|
|
5
8
|
|
|
@@ -46,6 +49,14 @@ export interface PromptPermissionDetails {
|
|
|
46
49
|
* suggestion.
|
|
47
50
|
*/
|
|
48
51
|
sessionApproval?: ForwardedSessionApproval;
|
|
52
|
+
/**
|
|
53
|
+
* The child-fixed access facts the raising gate computed (surface + match
|
|
54
|
+
* set). Rides through the runner to the escalation edge, which completes
|
|
55
|
+
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
|
56
|
+
* `principal`. Absent for a serving-node local prompt reconstructed from a
|
|
57
|
+
* forwarded request.
|
|
58
|
+
*/
|
|
59
|
+
accessIntent?: ForwardedAccessFacts;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
/**
|
|
@@ -5,6 +5,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
|
|
|
5
5
|
import type { GateResult } from "./descriptor";
|
|
6
6
|
import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
7
7
|
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
8
|
+
import { accessFactsFromPath } from "./helpers";
|
|
8
9
|
import type { ToolCallContext } from "./types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -65,6 +66,10 @@ export function describeBashExternalDirectoryGate(
|
|
|
65
66
|
// defined; the fallback keeps TypeScript happy across the early return. A
|
|
66
67
|
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
|
|
67
68
|
const preCheck = worstCheck ?? uncoveredEntries[0].check;
|
|
69
|
+
// The AccessPath the decision was made against — its facts ride the wire.
|
|
70
|
+
const worstEntry =
|
|
71
|
+
uncoveredEntries.find(({ check }) => check === preCheck) ??
|
|
72
|
+
uncoveredEntries[0];
|
|
68
73
|
|
|
69
74
|
const disclosures = uncoveredEntries.map(({ path }) => ({
|
|
70
75
|
path: path.value(),
|
|
@@ -98,6 +103,7 @@ export function describeBashExternalDirectoryGate(
|
|
|
98
103
|
toolCallId: tcc.toolCallId,
|
|
99
104
|
toolName: tcc.toolName,
|
|
100
105
|
command,
|
|
106
|
+
accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
|
|
101
107
|
},
|
|
102
108
|
logContext: {
|
|
103
109
|
source: "tool_call",
|
|
@@ -6,6 +6,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
|
|
|
6
6
|
import type { PermissionCheckResult } from "#src/types";
|
|
7
7
|
import { pickMostRestrictive } from "./candidate-check";
|
|
8
8
|
import type { GateResult } from "./descriptor";
|
|
9
|
+
import { accessFactsFromPath } from "./helpers";
|
|
9
10
|
import { formatPathAskPrompt } from "./path";
|
|
10
11
|
import type { ToolCallContext } from "./types";
|
|
11
12
|
|
|
@@ -135,6 +136,7 @@ export function describeBashPathGate(
|
|
|
135
136
|
toolCallId: tcc.toolCallId,
|
|
136
137
|
toolName: tcc.toolName,
|
|
137
138
|
command,
|
|
139
|
+
accessIntent: accessFactsFromPath("path", worstEntry.path),
|
|
138
140
|
},
|
|
139
141
|
logContext: {
|
|
140
142
|
source: "tool_call",
|
|
@@ -7,6 +7,7 @@ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-regis
|
|
|
7
7
|
import type { GateResult } from "./descriptor";
|
|
8
8
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
9
9
|
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
10
|
+
import { accessFactsFromPath } from "./helpers";
|
|
10
11
|
import type { ToolCallContext } from "./types";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -104,6 +105,7 @@ export function describeExternalDirectoryGate(
|
|
|
104
105
|
toolCallId: tcc.toolCallId,
|
|
105
106
|
toolName: tcc.toolName,
|
|
106
107
|
path: externalDirectoryPath,
|
|
108
|
+
accessIntent: accessFactsFromPath("external_directory", accessPath),
|
|
107
109
|
},
|
|
108
110
|
logContext: {
|
|
109
111
|
source: "tool_call",
|
|
@@ -1,10 +1,43 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
3
|
+
import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
|
|
2
4
|
import type {
|
|
3
5
|
PermissionDecisionEvent,
|
|
4
6
|
PermissionDecisionResolution,
|
|
5
7
|
} from "#src/permission-events";
|
|
6
8
|
import type { PermissionCheckResult } from "#src/types";
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Build the child-fixed access facts for a path-shaped gate from its
|
|
12
|
+
* `AccessPath`.
|
|
13
|
+
*
|
|
14
|
+
* Converts the `AccessPath` to strings at the point of emission (ADR-0002: an
|
|
15
|
+
* `AccessPath` never crosses onto the wire), carrying the lexical ∪ canonical
|
|
16
|
+
* match set. An empty `boundaryValue()` (a literal-only path) becomes `null`,
|
|
17
|
+
* so the wire distinguishes "no canonical form" cleanly.
|
|
18
|
+
*/
|
|
19
|
+
export function accessFactsFromPath(
|
|
20
|
+
surface: string,
|
|
21
|
+
path: AccessPath,
|
|
22
|
+
): ForwardedAccessFacts {
|
|
23
|
+
return {
|
|
24
|
+
surface,
|
|
25
|
+
matchValues: path.matchValues(),
|
|
26
|
+
boundaryValue: path.boundaryValue() || null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the child-fixed access facts for a non-path gate (bash command, MCP
|
|
32
|
+
* target, skill name, plain tool) from its already-portable single value.
|
|
33
|
+
*/
|
|
34
|
+
export function accessFactsFromValue(
|
|
35
|
+
surface: string,
|
|
36
|
+
value: string,
|
|
37
|
+
): ForwardedAccessFacts {
|
|
38
|
+
return { surface, matchValues: [value], boundaryValue: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
/**
|
|
9
42
|
* Derive the human-readable value for a decision event from a check result.
|
|
10
43
|
* Bash → extracted command; MCP → qualified target;
|
|
@@ -5,6 +5,7 @@ import { SessionApproval } from "#src/session-approval";
|
|
|
5
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
7
7
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
8
|
+
import { accessFactsFromPath } from "./helpers";
|
|
8
9
|
import type { ToolCallContext } from "./types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -67,6 +68,7 @@ export function describePathGate(
|
|
|
67
68
|
toolCallId: tcc.toolCallId,
|
|
68
69
|
toolName: tcc.toolName,
|
|
69
70
|
path: filePath,
|
|
71
|
+
accessIntent: accessFactsFromPath("path", accessPath),
|
|
70
72
|
},
|
|
71
73
|
logContext: {
|
|
72
74
|
source: "tool_call",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { formatSkillAskPrompt } from "#src/permission-prompts";
|
|
2
2
|
import type { PermissionCheckResult } from "#src/types";
|
|
3
3
|
import type { GateDescriptor } from "./descriptor";
|
|
4
|
+
import { accessFactsFromValue } from "./helpers";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Build a pure descriptor for the skill-input permission gate.
|
|
@@ -29,6 +30,7 @@ export function describeSkillInputGate(
|
|
|
29
30
|
agentName,
|
|
30
31
|
message,
|
|
31
32
|
skillName,
|
|
33
|
+
accessIntent: accessFactsFromValue("skill", skillName),
|
|
32
34
|
},
|
|
33
35
|
logContext: {
|
|
34
36
|
source: "skill_input",
|
|
@@ -4,6 +4,7 @@ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
|
4
4
|
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
|
|
5
5
|
import { toRecord } from "#src/value-guards";
|
|
6
6
|
import type { GateDescriptor } from "./descriptor";
|
|
7
|
+
import { accessFactsFromValue } from "./helpers";
|
|
7
8
|
import type { ToolCallContext } from "./types";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -64,6 +65,7 @@ export function describeSkillReadGate(
|
|
|
64
65
|
toolName: tcc.toolName,
|
|
65
66
|
skillName: matchedSkill.name,
|
|
66
67
|
path,
|
|
68
|
+
accessIntent: accessFactsFromValue("skill", matchedSkill.name),
|
|
67
69
|
},
|
|
68
70
|
logContext: {
|
|
69
71
|
source: "skill_read",
|
|
@@ -11,7 +11,11 @@ import { SessionApproval } from "#src/session-approval";
|
|
|
11
11
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
12
12
|
import type { PermissionCheckResult } from "#src/types";
|
|
13
13
|
import type { GateDescriptor } from "./descriptor";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
accessFactsFromPath,
|
|
16
|
+
accessFactsFromValue,
|
|
17
|
+
deriveDecisionValue,
|
|
18
|
+
} from "./helpers";
|
|
15
19
|
import type { ToolCallContext } from "./types";
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -75,6 +79,18 @@ export function describeToolGate(
|
|
|
75
79
|
formatter,
|
|
76
80
|
);
|
|
77
81
|
|
|
82
|
+
const decisionValue = deriveDecisionValue(
|
|
83
|
+
gateSurface,
|
|
84
|
+
check,
|
|
85
|
+
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// A path-bearing tool carries the AccessPath's alias set; every other surface
|
|
89
|
+
// (bash command, MCP target, plain tool) carries its already-portable value.
|
|
90
|
+
const accessIntent = accessPath
|
|
91
|
+
? accessFactsFromPath(gateSurface, accessPath)
|
|
92
|
+
: accessFactsFromValue(gateSurface, decisionValue);
|
|
93
|
+
|
|
78
94
|
return {
|
|
79
95
|
surface: gateSurface,
|
|
80
96
|
input: tcc.input,
|
|
@@ -95,6 +111,7 @@ export function describeToolGate(
|
|
|
95
111
|
toolCallId: tcc.toolCallId,
|
|
96
112
|
toolName: tcc.toolName,
|
|
97
113
|
sessionLabel: suggestion.label,
|
|
114
|
+
accessIntent,
|
|
98
115
|
...permissionLogContext,
|
|
99
116
|
},
|
|
100
117
|
logContext: {
|
|
@@ -106,11 +123,7 @@ export function describeToolGate(
|
|
|
106
123
|
},
|
|
107
124
|
decision: {
|
|
108
125
|
surface: gateSurface,
|
|
109
|
-
value:
|
|
110
|
-
gateSurface,
|
|
111
|
-
check,
|
|
112
|
-
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
|
|
113
|
-
),
|
|
126
|
+
value: decisionValue,
|
|
114
127
|
},
|
|
115
128
|
};
|
|
116
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
import {
|
|
4
|
+
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
|
|
5
5
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
6
6
|
import {
|
|
7
7
|
ForwardedRequestServer,
|
|
@@ -116,20 +116,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
116
116
|
// service and gates below share this one instance.
|
|
117
117
|
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
|
118
118
|
|
|
119
|
-
// Serving a forwarded request is resolution:
|
|
120
|
-
// against the serving node's
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// deferred-binding precedent as the logger notify sink below.
|
|
119
|
+
// Serving a forwarded request is resolution: resolve the child-fixed
|
|
120
|
+
// ForwardedAccessIntent (ADR 0008) directly against the serving node's
|
|
121
|
+
// composed ruleset, agent-scoped to the requester (§3) — the match values
|
|
122
|
+
// are used as fixed by the child, never re-derived through this session's
|
|
123
|
+
// PathNormalizer/cwd (#597).
|
|
125
124
|
const servingPolicy: ServingPolicy = {
|
|
126
|
-
|
|
125
|
+
resolve: (intent) =>
|
|
127
126
|
resolver.resolve(
|
|
128
|
-
|
|
129
|
-
surface,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
undefined,
|
|
127
|
+
buildResolvedIntentFromMatchValues(
|
|
128
|
+
intent.surface,
|
|
129
|
+
intent.matchValues,
|
|
130
|
+
intent.principal.agentName,
|
|
133
131
|
),
|
|
134
132
|
),
|
|
135
133
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AccessIntent,
|
|
3
|
+
PathValuesAccessIntent,
|
|
3
4
|
ResolvedAccessIntent,
|
|
4
5
|
} from "./access-intent/access-intent";
|
|
5
6
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
@@ -31,8 +32,14 @@ export interface ScopedPermissionResolver {
|
|
|
31
32
|
* canonical alias set (#418) is derived. Keeping it here (not in the manager)
|
|
32
33
|
* is the deliberate boundary formalized in ADR-0002
|
|
33
34
|
* (`docs/decisions/0002-path-values-string-boundary.md`).
|
|
35
|
+
*
|
|
36
|
+
* Also accepts an already-resolved {@link PathValuesAccessIntent} (the
|
|
37
|
+
* forwarded-serving wire's producer, #597) as a pure passthrough — it is
|
|
38
|
+
* already a `ResolvedAccessIntent`, so there is nothing to unwrap.
|
|
34
39
|
*/
|
|
35
|
-
function toResolvedIntent(
|
|
40
|
+
function toResolvedIntent(
|
|
41
|
+
intent: AccessIntent | PathValuesAccessIntent,
|
|
42
|
+
): ResolvedAccessIntent {
|
|
36
43
|
if (intent.kind === "access-path") {
|
|
37
44
|
return {
|
|
38
45
|
kind: "path-values",
|
|
@@ -66,8 +73,16 @@ export class PermissionResolver
|
|
|
66
73
|
* Answer a gate-emitted access intent, composing the current session ruleset
|
|
67
74
|
* so callers never thread it by hand. Unwraps the `access-path` variant via
|
|
68
75
|
* `matchValues()` before handing a string-based intent to the manager.
|
|
76
|
+
*
|
|
77
|
+
* Also accepts a pre-fixed `path-values` intent (the forwarded-serving wire,
|
|
78
|
+
* #597) — a passthrough, since it is already a `ResolvedAccessIntent`. The
|
|
79
|
+
* gate-facing {@link ScopedPermissionResolver} interface stays narrow
|
|
80
|
+
* (`AccessIntent` only); this wider acceptance is available only through the
|
|
81
|
+
* concrete `PermissionResolver` instance the composition root holds.
|
|
69
82
|
*/
|
|
70
|
-
resolve(
|
|
83
|
+
resolve(
|
|
84
|
+
intent: AccessIntent | PathValuesAccessIntent,
|
|
85
|
+
): PermissionCheckResult {
|
|
71
86
|
return this.permissionManager.check(
|
|
72
87
|
toResolvedIntent(intent),
|
|
73
88
|
this.sessionRules.getRuleset(),
|