@gotgenes/pi-permission-system 19.0.1 → 20.1.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 CHANGED
@@ -5,6 +5,35 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.0.0...pi-permission-system-v20.1.0) (2026-07-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** gate win32 backslash-relative bash args via path rules ([#520](https://github.com/gotgenes/pi-packages/issues/520)) ([ad90fe5](https://github.com/gotgenes/pi-packages/commit/ad90fe568403a292afec52eaf75e9cb42e4db62c))
14
+ * **pi-permission-system:** recognize win32 backslash-relative path tokens ([#520](https://github.com/gotgenes/pi-packages/issues/520)) ([343f331](https://github.com/gotgenes/pi-packages/commit/343f3318257df5358f6db7e447fd1de8f16a81b8))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** document win32 backslash-relative path recognition ([#520](https://github.com/gotgenes/pi-packages/issues/520)) ([b71d0b8](https://github.com/gotgenes/pi-packages/commit/b71d0b88fa8ee28eddfb4fc9796b7e15172d670a))
20
+
21
+ ## [20.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v19.0.1...pi-permission-system-v20.0.0) (2026-07-07)
22
+
23
+
24
+ ### ⚠ BREAKING CHANGES
25
+
26
+ * **pi-permission-system:** The permissions:rpc:check and permissions:rpc:prompt event-bus channels are removed, along with their request/reply types (PermissionsCheckRequest, PermissionsCheckReplyData, PermissionsPromptRequest, PermissionsPromptReplyData), the PermissionsRpcReply envelope, PERMISSIONS_PROTOCOL_VERSION, and the rpc_prompt member of PermissionUiPromptSource. permissions:rpc:check consumers migrate to getPermissionsService().checkPermission(surface, value?, agentName?). permissions:rpc:prompt is removed with no public replacement; prompt forwarding is an internal subagent-to-parent mechanism.
27
+
28
+ ### Features
29
+
30
+ * **pi-permission-system:** remove deprecated event-bus RPC channel ([557ea91](https://github.com/gotgenes/pi-packages/commit/557ea913c54f21ef64b0812169bbc7d5224072e2))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * **pi-permission-system:** repoint cross-extension docs off the removed RPC channel ([b3d06f7](https://github.com/gotgenes/pi-packages/commit/b3d06f77aea43758a4e820e66d1111608b86879b)), closes [#531](https://github.com/gotgenes/pi-packages/issues/531)
36
+
8
37
  ## [19.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v19.0.0...pi-permission-system-v19.0.1) (2026-07-07)
9
38
 
10
39
 
@@ -363,6 +363,9 @@ When the current working directory is known, relative bash tokens are matched wi
363
363
  A bare filename with no path shape at all (e.g. `id_rsa` in `cat id_rsa`) is also gated when it matches an active, specific (non-`*`) `path` deny/ask rule — so `"id_rsa": "deny"` or `"*.pem": "deny"` blocks the file whether it is referenced by a bare name, a relative path, or the `read` tool.
364
364
  A bare token that matches no specific `path` rule (e.g. `status` in `git status`) is left alone, and this promotion never fires against a `"*"` catch-all — only a config that already declares a specific `path` rule is affected.
365
365
 
366
+ On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. `dir\file` in `cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool.
367
+ On other platforms a backslash is a legal filename character, so such a token is not treated as a path.
368
+
366
369
  Four orthogonal layers compose with most-restrictive-wins:
367
370
 
368
371
  | Layer | Question | Applies to |
@@ -3,14 +3,14 @@
3
3
  The extension provides two cross-extension integration surfaces:
4
4
 
5
5
  1. **Service accessor** (preferred) — a `Symbol.for()`-backed synchronous API on `globalThis` for direct policy queries.
6
- 2. **Event bus** — broadcasts and RPC on `pi.events` for observation and prompt forwarding.
6
+ 2. **Event bus** — broadcasts on `pi.events` for observation.
7
7
 
8
8
  ---
9
9
 
10
10
  ## Service Accessor
11
11
 
12
12
  The preferred way for other extensions to query the permission policy is the `Symbol.for()`-backed service accessor.
13
- It provides direct, synchronous, type-safe function calls — no async RPC envelope needed.
13
+ It provides direct, synchronous, type-safe function calls.
14
14
 
15
15
  ### Quick Start
16
16
 
@@ -286,22 +286,17 @@ The extension also emits events on Pi's `pi.events` bus so other extensions can
286
286
 
287
287
  Fields may be added to any payload, but existing fields will not be removed or renamed without a semver-major version bump.
288
288
  The broadcast contract is defined by the published TypeScript types plus package semver — broadcast payloads (`permissions:ready`, `permissions:ui_prompt`, `permissions:decision`) carry no `protocolVersion`.
289
- The `PERMISSIONS_PROTOCOL_VERSION` constant is exported from `src/permission-events.ts` and embedded only in the RPC reply envelope, where per-call request/reply negotiation is load-bearing.
290
289
  Consumers should read broadcast payloads defensively (field-presence checks) rather than version-gating — that is robust to any shape skew between independently-versioned sibling extensions.
291
290
 
292
291
  All three broadcasts are best-effort: a throwing listener cannot block permission handling, session startup, or gate resolution.
293
292
 
294
293
  ## Channel Reference
295
294
 
296
- | Channel | Direction | When | Payload type |
297
- | ------------------------------------------ | --------- | --------------------------------- | ------------------------------------------------- |
298
- | `permissions:ready` | Broadcast | At `session_start`, after publish | `PermissionsReadyEvent` |
299
- | `permissions:ui_prompt` | Broadcast | Before active UI prompt | `PermissionUiPromptEvent` |
300
- | `permissions:decision` | Broadcast | After every gate resolution | `PermissionDecisionEvent` |
301
- | `permissions:rpc:check` | Request | On-demand | `PermissionsCheckRequest` |
302
- | `permissions:rpc:check:reply:<requestId>` | Reply | After each check request | `PermissionsRpcReply<PermissionsCheckReplyData>` |
303
- | `permissions:rpc:prompt` | Request | On-demand | `PermissionsPromptRequest` |
304
- | `permissions:rpc:prompt:reply:<requestId>` | Reply | After prompt is resolved | `PermissionsRpcReply<PermissionsPromptReplyData>` |
295
+ | Channel | Direction | When | Payload type |
296
+ | ----------------------- | --------- | --------------------------------- | ------------------------- |
297
+ | `permissions:ready` | Broadcast | At `session_start`, after publish | `PermissionsReadyEvent` |
298
+ | `permissions:ui_prompt` | Broadcast | Before active UI prompt | `PermissionUiPromptEvent` |
299
+ | `permissions:decision` | Broadcast | After every gate resolution | `PermissionDecisionEvent` |
305
300
 
306
301
  ---
307
302
 
@@ -333,15 +328,15 @@ pi.events.on("permissions:ui_prompt", (raw) => {
333
328
 
334
329
  ### Payload Fields
335
330
 
336
- | Field | Type | Description |
337
- | ------------ | -------------------------------- | -------------------------------------------------------------------------------- |
338
- | `requestId` | `string` | Unique ID for the permission request being prompted |
339
- | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, `"skill_read"`, or `"rpc_prompt"` |
340
- | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
341
- | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
342
- | `agentName` | `string \| null` | Active/requesting agent name, when known |
343
- | `message` | `string` | Message displayed in the permission prompt |
344
- | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
331
+ | Field | Type | Description |
332
+ | ------------ | -------------------------------- | ---------------------------------------------------------------------- |
333
+ | `requestId` | `string` | Unique ID for the permission request being prompted |
334
+ | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"` |
335
+ | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
336
+ | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
337
+ | `agentName` | `string \| null` | Active/requesting agent name, when known |
338
+ | `message` | `string` | Message displayed in the permission prompt |
339
+ | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
345
340
 
346
341
  Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its original `source` and is identified by a non-null `forwarding` field, not by a dedicated source value.
347
342
 
@@ -400,117 +395,13 @@ pi.events.on("permissions:decision", (raw) => {
400
395
 
401
396
  ---
402
397
 
403
- ## Policy Query RPC (deprecated)
404
-
405
- > **Deprecated**: prefer the [Service Accessor](#service-accessor) above.
406
- > The event-bus RPC remains available as a zero-dependency fallback.
407
-
408
- Other extensions can evaluate the current permission policy without importing this package.
409
- The call is synchronous-style: emit a request, listen on a scoped reply channel.
410
-
411
- ```typescript
412
- const requestId = crypto.randomUUID();
413
-
414
- // Listen for the reply first
415
- const unsub = pi.events.on(
416
- `permissions:rpc:check:reply:${requestId}`,
417
- (raw) => {
418
- unsub();
419
- const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
420
- import("@gotgenes/pi-permission-system").PermissionsCheckReplyData
421
- >;
422
- if (reply.success) {
423
- console.log(reply.data?.result); // "allow" | "deny" | "ask"
424
- }
425
- },
426
- );
427
-
428
- // Then emit the request
429
- pi.events.emit("permissions:rpc:check", {
430
- requestId,
431
- surface: "bash",
432
- value: "git push",
433
- agentName: "Worker", // optional
434
- });
435
- ```
436
-
437
- If the extension is not loaded, no reply arrives.
438
- Callers should implement a timeout and treat no-reply as `deny` (graceful degradation).
439
-
440
- ### Request Fields
441
-
442
- | Field | Required | Description |
443
- | ----------- | -------- | ---------------------------------------------------------- |
444
- | `requestId` | Yes | Unique string; scopes the reply channel |
445
- | `surface` | Yes | Permission surface to evaluate |
446
- | `value` | No | Value to evaluate (command, name, path); defaults to `"*"` |
447
- | `agentName` | No | Agent name for per-agent policy resolution |
448
-
449
- As with the `checkPermission` service method, a path-shaped surface (`path`, `external_directory`, or a path-bearing tool) matches the `value` against both the path as given and its canonical (symlink-resolved) form.
450
-
451
- ### Reply Data Fields (`PermissionsCheckReplyData`)
452
-
453
- | Field | Type | Description |
454
- | ---------------- | ---------------------------- | ------------------------------------------------ |
455
- | `result` | `"allow" \| "deny" \| "ask"` | Policy decision (including active session rules) |
456
- | `matchedPattern` | `string \| null` | Matched rule pattern |
457
- | `origin` | `string \| null` | Config scope of the winning rule |
458
-
459
- ---
460
-
461
- ## Prompt Forwarding RPC
462
-
463
- In-process child sessions (e.g. tintinweb/pi-subagents running via `createAgentSession()`) cannot use file-based permission forwarding because no child process is spawned.
464
- They can instead forward permission prompts to the parent session's UI via this RPC.
465
-
466
- ```typescript
467
- const requestId = crypto.randomUUID();
468
-
469
- const unsub = pi.events.on(
470
- `permissions:rpc:prompt:reply:${requestId}`,
471
- (raw) => {
472
- unsub();
473
- const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
474
- import("@gotgenes/pi-permission-system").PermissionsPromptReplyData
475
- >;
476
- if (reply.success && reply.data?.approved) {
477
- // proceed
478
- } else {
479
- // deny — either user denied or no UI was available (error: "no_ui")
480
- }
481
- },
482
- );
483
-
484
- pi.events.emit("permissions:rpc:prompt", {
485
- requestId,
486
- surface: "bash",
487
- value: "rm -rf /tmp/build",
488
- message: "Allow rm -rf /tmp/build?",
489
- agentName: "Explore", // optional
490
- sessionLabel: "Allow rm *", // optional — label for the "for this session" option
491
- });
492
- ```
493
-
494
- The handler replies with `{ success: false, error: "no_ui" }` when no interactive session is available.
495
-
496
- ### Successful Reply Fields
497
-
498
- | Field | Type | Description |
499
- | -------------- | ------------------- | ----------------------------------------------------------------------------- |
500
- | `approved` | `boolean` | Whether the user approved |
501
- | `state` | `string` | `"approved"`, `"approved_for_session"`, `"denied"`, or `"denied_with_reason"` |
502
- | `denialReason` | `string` (optional) | User-provided denial reason |
503
-
504
- ---
505
-
506
398
  ## Ready Event
507
399
 
508
400
  The extension emits `permissions:ready` at `session_start`, right after the service is published — so a consumer reacting to it can immediately resolve `getPermissionsService()`.
509
401
  It fires once per `session_start` (including `/reload`).
510
- Consumers that start after the extension can check via a ping-style RPC check — the `permissions:rpc:check` handler is active as long as the extension is loaded.
511
402
 
512
403
  The payload is intentionally empty (`Record<string, never>`): the channel is a pure readiness signal.
513
- It carries no `protocolVersion` — version negotiation lives in the RPC reply envelope, and the broadcast contract is defined by the published types plus package semver.
404
+ It carries no `protocolVersion` — the broadcast contract is defined by the published types plus package semver.
514
405
 
515
406
  ```typescript
516
407
  pi.events.on("permissions:ready", () => {
@@ -127,61 +127,29 @@ The permission system handles all evaluation, prompt dialogs, and policy enforce
127
127
 
128
128
  ## Runtime Integration (Optional)
129
129
 
130
- If your extension runs subagents in-process (e.g. via `createAgentSession()`), you can optionally query the permission system's policy at runtime via the event bus API — no package import needed.
130
+ If your extension runs subagents in-process (e.g. via `createAgentSession()`), you can optionally query the permission system's policy at runtime via the `Symbol.for()`-backed service accessor — no required peer dependency, just a dynamic `import()`.
131
131
 
132
132
  ### Querying policy
133
133
 
134
134
  ```typescript
135
- const requestId = crypto.randomUUID();
136
-
137
- pi.events.on(
138
- `permissions:rpc:check:reply:${requestId}`,
139
- (raw) => {
140
- const reply = raw as { success: boolean; data?: { result: string } };
141
- if (reply.success) {
142
- console.log(reply.data?.result); // "allow" | "deny" | "ask"
143
- }
144
- },
145
- );
146
-
147
- pi.events.emit("permissions:rpc:check", {
148
- requestId,
149
- surface: "bash",
150
- value: "git push",
151
- agentName: "Worker",
152
- });
135
+ try {
136
+ const { getPermissionsService } = await import(
137
+ "@gotgenes/pi-permission-system"
138
+ );
139
+ const permissions = getPermissionsService();
140
+ if (permissions) {
141
+ const result = permissions.checkPermission("bash", "git push", "Worker");
142
+ console.log(result.state); // "allow" | "deny" | "ask"
143
+ }
144
+ } catch {
145
+ // Not installed — graceful degradation
146
+ }
153
147
  ```
154
148
 
155
- ### Forwarding permission prompts
149
+ If `pi-permission-system` is not installed, `import()` throws; if it has not published a service yet (or has been unloaded), `getPermissionsService()` returns `undefined`.
150
+ Guard both cases as shown above.
156
151
 
157
- When a child agent encounters an `ask` permission and has no UI, the prompt can be forwarded to the parent session:
158
-
159
- ```typescript
160
- const requestId = crypto.randomUUID();
161
-
162
- pi.events.on(
163
- `permissions:rpc:prompt:reply:${requestId}`,
164
- (raw) => {
165
- const reply = raw as { success: boolean; data?: { approved: boolean } };
166
- if (reply.success && reply.data?.approved) {
167
- // proceed
168
- } else {
169
- // deny
170
- }
171
- },
172
- );
173
-
174
- pi.events.emit("permissions:rpc:prompt", {
175
- requestId,
176
- surface: "bash",
177
- value: "rm -rf /tmp/build",
178
- message: "Allow rm -rf /tmp/build?",
179
- agentName: "Worker",
180
- });
181
- ```
182
-
183
- If `pi-permission-system` is not installed, no reply arrives.
184
- Implement a timeout and treat no-reply as `deny` for graceful degradation.
152
+ Prompt forwarding for headless child agents is an internal subagent-to-parent mechanism, not a public cross-extension operation there is no service-accessor equivalent to call directly.
185
153
 
186
154
  For full API documentation, see [Cross-extension API](../cross-extension-api.md).
187
155
 
@@ -191,11 +159,11 @@ For full API documentation, see [Cross-extension API](../cross-extension-api.md)
191
159
  2. **Unified config** — one `permission:` block per agent instead of separate restriction keys in multiple extensions.
192
160
  3. **Surface coverage** — policy covers bash patterns, MCP tools, skills, external directories, and special operations, not just tool names.
193
161
  4. **Forwarding** — permission prompts from headless child agents surface in the parent session's UI.
194
- 5. **Programmatic access** — the event bus API lets your extension query policy at runtime without importing the package.
162
+ 5. **Programmatic access** — the `Symbol.for()` service accessor lets your extension query policy at runtime with only a dynamic `import()`, no required peer dependency.
195
163
 
196
164
  ## Further Reading
197
165
 
198
166
  - [Subagent Integration](../subagent-integration.md) — full coexistence documentation and interaction rules
199
- - [Cross-extension API](../cross-extension-api.md) — service accessor, event bus reference (decision broadcasts, RPC check, RPC prompt)
167
+ - [Cross-extension API](../cross-extension-api.md) — service accessor, event bus reference (decision and UI-prompt broadcasts)
200
168
  - [Configuration](../configuration.md) — full policy reference including merge precedence
201
169
  - [Schema](../../schemas/permissions.schema.json) — canonical JSON Schema for the flat permission format
@@ -31,7 +31,7 @@ This is purely a documentation change — no code dependency on pi-permission-sy
31
31
  - **Three-state policy**: `allow`, `ask` (prompt the user), or `deny` — richer than binary allow/deny
32
32
  - **Multiple surfaces**: tools, bash commands (glob patterns), MCP operations, skills, external directories
33
33
  - **Prompt forwarding**: `ask` decisions in headless child agents surface in the parent session's UI
34
- - **Event bus API**: other extensions can query policy at runtime without importing the package
34
+ - **Service accessor API**: other extensions can query policy at runtime via a `Symbol.for()`-backed accessor, with only a dynamic `import()` and no required peer dependency
35
35
 
36
36
  ### Example
37
37
 
@@ -99,7 +99,7 @@ No code changes, no new dependency, no schema enforcement.
99
99
  | `{{your-key}}` | `disallowed_tools` |
100
100
  | `{{example-value}}` | `write_file,bash` |
101
101
 
102
- Additional note for tintinweb: since this extension runs subagents in-process via `createAgentSession()`, mention the [Event Bus RPC](https://github.com/gotgenes/pi-permission-system/blob/main/docs/cross-extension-api.md#policy-query-rpc-deprecated) as an optional runtime integration path for querying or forwarding permission prompts without spawning a subprocess.
102
+ Additional note for tintinweb: since this extension runs subagents in-process via `createAgentSession()`, mention the [service accessor](https://github.com/gotgenes/pi-permission-system/blob/main/docs/cross-extension-api.md#service-accessor) as an optional runtime integration path for querying policy without spawning a subprocess.
103
103
 
104
104
  ### HazAT/pi-interactive-subagents
105
105
 
@@ -29,7 +29,6 @@ The main interactive session polls for forwarded requests, shows the confirmatio
29
29
  This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
30
30
 
31
31
  For in-process child sessions, detection and forwarding use the event-driven registration described above.
32
- The [Prompt Forwarding RPC](cross-extension-api.md#prompt-forwarding-rpc) remains available as an alternative for extensions that cannot use the service accessor.
33
32
 
34
33
  ---
35
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "19.0.1",
3
+ "version": "20.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -426,6 +426,10 @@ export class BashPathResolver {
426
426
  * classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the
427
427
  * broad classifier rejects for shape — promoted only when the injected
428
428
  * `isPromotablePathToken` predicate matches an active, specific `path` rule.
429
+ * On win32 the broad classifier is told to treat a backslash as a path
430
+ * separator, so a backslash-relative token (`dir\file`) is recognized as a
431
+ * rule candidate the same as its forward-slash equivalent (#520); on POSIX
432
+ * `\` is a legal filename character, so the token stays bare there.
429
433
  * Pairs each qualifying token with its set of policy values (absolute +
430
434
  * project-relative + raw).
431
435
  * A token after a non-literal `cd` keeps only its literal value so no
@@ -436,10 +440,11 @@ export class BashPathResolver {
436
440
  ): BashPathRuleCandidate[] {
437
441
  const seen = new Set<string>();
438
442
  const result: BashPathRuleCandidate[] = [];
443
+ const windowsSeparators = this.normalizer.usesWindowsSeparators();
439
444
 
440
445
  for (const { token, base } of candidates) {
441
446
  const candidate =
442
- classifyTokenAsRuleCandidate(token) ??
447
+ classifyTokenAsRuleCandidate(token, { windowsSeparators }) ??
443
448
  classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
444
449
  if (!candidate) continue;
445
450
 
@@ -18,6 +18,14 @@
18
18
  * gated by the `path` surface; on Windows the `PathNormalizer` routes it through
19
19
  * the absolute-path branch. Shape recognition is platform-independent string
20
20
  * matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
21
+ *
22
+ * `classifyTokenAsRuleCandidate` also accepts a `RuleCandidateOptions.windowsSeparators`
23
+ * flag: when `true`, a backslash-relative token (`dir\file`, no leading `.`, no
24
+ * `/`, no `..`, not a drive-letter absolute) is accepted as path-shaped (#520).
25
+ * This is the one genuinely platform-sensitive shape rule the classifier owns —
26
+ * on POSIX `\` is a legal filename character, so the caller (`BashPathResolver`)
27
+ * derives the flag from `PathNormalizer.usesWindowsSeparators()` rather than the
28
+ * classifier reading `process.platform` itself.
21
29
  */
22
30
  import type { PathRuleTokenMatcher } from "#src/types";
23
31
 
@@ -45,6 +53,19 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
45
53
  return null;
46
54
  }
47
55
 
56
+ /**
57
+ * Platform-sensitive options for {@link classifyTokenAsRuleCandidate}.
58
+ */
59
+ export interface RuleCandidateOptions {
60
+ /**
61
+ * True when the host platform treats `\` as a path separator (win32), so a
62
+ * backslash-relative token (`dir\file`) is accepted as path-shaped (#520).
63
+ * On POSIX `\` is a legal filename character, so omit this (or pass
64
+ * `false`) to keep such a token bare.
65
+ */
66
+ readonly windowsSeparators?: boolean;
67
+ }
68
+
48
69
  /**
49
70
  * Broader token classifier for cross-cutting `path` permission rules.
50
71
  *
@@ -52,6 +73,10 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
52
73
  * - Dot-files and `./`-relative paths (starting with `.`)
53
74
  * - Any relative path containing `/` (e.g. `src/foo.ts`)
54
75
  * - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
76
+ * - A backslash-relative token (`dir\file`) when `options.windowsSeparators`
77
+ * is `true` (#520) — the caller (`BashPathResolver`) derives this from
78
+ * `PathNormalizer.usesWindowsSeparators()` so the platform bit has a single
79
+ * home; this classifier never reads `process.platform`.
55
80
  *
56
81
  * The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
57
82
  * The forward-slash drive form (`C:/…`) is also caught by `includes("/")`, but the
@@ -64,13 +89,17 @@ export function classifyTokenAsPathCandidate(token: string): string | null {
64
89
  *
65
90
  * Returns the raw token string if it qualifies, or `null` to skip.
66
91
  */
67
- export function classifyTokenAsRuleCandidate(token: string): string | null {
92
+ export function classifyTokenAsRuleCandidate(
93
+ token: string,
94
+ options?: RuleCandidateOptions,
95
+ ): string | null {
68
96
  if (rejectNonPathToken(token)) return null;
69
97
 
70
98
  if (token.startsWith(".")) return token;
71
99
  if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
72
100
  if (token.includes("..")) return token; // bare ".." (no slash)
73
101
  if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
102
+ if (options?.windowsSeparators && token.includes("\\")) return token;
74
103
 
75
104
  return null;
76
105
  }
@@ -18,12 +18,7 @@ import {
18
18
  sleep,
19
19
  writeJsonFileAtomic,
20
20
  } from "#src/authority/forwarding-io";
21
- import type { SubagentDetector } from "#src/authority/subagent-detection";
22
- import type {
23
- PermissionDecisionUi,
24
- PermissionPromptDecision,
25
- RequestPermissionOptions,
26
- } from "#src/permission-dialog";
21
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
27
22
  import {
28
23
  type ForwardedPermissionRequest,
29
24
  type ForwardedPromptDisplay,
@@ -33,31 +28,12 @@ import {
33
28
  resolvePermissionForwardingTargetSessionId,
34
29
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
35
30
  } from "#src/permission-forwarding";
31
+ import { buildDirectUiPrompt } from "#src/permission-ui-prompt";
36
32
  import type { DebugReviewLogger } from "#src/session-logger";
37
33
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
38
34
  import { toRecord } from "#src/value-guards";
39
-
40
- /**
41
- * Constructor config for `ApprovalEscalator`.
42
- *
43
- * Replaces the `PermissionForwardingDeps` interface that was previously
44
- * threaded into free functions in `polling.ts`. The escalator consumes it
45
- * once at construction and stores each member as a private readonly field.
46
- */
47
- export interface ApprovalEscalatorDeps {
48
- forwardingDir: string;
49
- /** Single owner of subagent detection; gates the forward-vs-deny decision. */
50
- detection: SubagentDetector;
51
- /** In-process subagent session registry for forwarding target resolution. */
52
- registry?: SubagentSessionRegistry;
53
- logger: DebugReviewLogger;
54
- requestPermissionDecisionFromUi: (
55
- ui: PermissionDecisionUi,
56
- title: string,
57
- message: string,
58
- options?: RequestPermissionOptions,
59
- ) => Promise<PermissionPromptDecision>;
60
- }
35
+ import type { Authorizer } from "./authorizer";
36
+ import type { PromptPermissionDetails } from "./permission-prompter";
61
37
 
62
38
  // ── Module-private helpers ────────────────────────────────────────────────
63
39
 
@@ -82,82 +58,51 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
82
58
  }
83
59
  }
84
60
 
85
- // ── Public seam interfaces ────────────────────────────────────────────────
61
+ // ── ParentAuthorizer ────────────────────────────────────────────────────
86
62
 
87
- /**
88
- * Narrow seam describing what `PermissionPrompter` needs from the escalator:
89
- * a single method that resolves a permission decision for the current context
90
- * (prompt directly when the session has UI, otherwise forward to the parent).
91
- *
92
- * Depending on the interface (not the concrete `ApprovalEscalator`) keeps
93
- * the prompter's unit tests free of casts — they inject a plain
94
- * `{ requestApproval: vi.fn() }` mock.
95
- */
96
- export interface ApprovalRequester {
97
- requestApproval(
98
- ctx: ForwarderContext,
99
- message: string,
100
- options?: RequestPermissionOptions,
101
- forwarded?: ForwardedPromptDisplay,
102
- ): Promise<PermissionPromptDecision>;
63
+ /** Constructor config for {@link ParentAuthorizer}. */
64
+ export interface ParentAuthorizerDeps {
65
+ forwardingDir: string;
66
+ /** In-process subagent session registry for forwarding target resolution. */
67
+ registry?: SubagentSessionRegistry;
68
+ logger: DebugReviewLogger;
103
69
  }
104
70
 
105
- // ── ApprovalEscalator ────────────────────────────────────────────────
106
-
107
71
  /**
108
- * Owner of the escalation-up role of the forwarded-permission behavior.
72
+ * Authorizer for a subagent session: escalate the ask up the tree to the
73
+ * parent's authority.
109
74
  *
110
- * Holds all forwarding state as private readonly fields and provides the
111
- * public `requestApproval` method: deciding whether to prompt directly or
112
- * forward to the parent, building and persisting request files, and polling
113
- * for responses.
75
+ * Owns the escalation-up role of the forwarded-permission behavior: builds
76
+ * and persists a request file, then polls for the parent session's
77
+ * response. `ctx` is bound once at construction `selectAuthorizer` only
78
+ * constructs a `ParentAuthorizer` for a context it has already confirmed has
79
+ * no UI and is a subagent, so `authorize` never re-derives that dispatch
80
+ * (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
81
+ * arms, both dead once every caller routes through `selectAuthorizer`).
114
82
  */
115
- export class ApprovalEscalator implements ApprovalRequester {
83
+ export class ParentAuthorizer implements Authorizer {
116
84
  private readonly forwardingDir: string;
117
- private readonly detection: SubagentDetector;
118
85
  private readonly registry: SubagentSessionRegistry | undefined;
119
86
  private readonly logger: DebugReviewLogger;
120
- private readonly requestPermissionDecisionFromUi: (
121
- ui: PermissionDecisionUi,
122
- title: string,
123
- message: string,
124
- options?: RequestPermissionOptions,
125
- ) => Promise<PermissionPromptDecision>;
126
87
 
127
- constructor(deps: ApprovalEscalatorDeps) {
88
+ constructor(
89
+ private readonly ctx: ForwarderContext,
90
+ deps: ParentAuthorizerDeps,
91
+ ) {
128
92
  this.forwardingDir = deps.forwardingDir;
129
- this.detection = deps.detection;
130
93
  this.registry = deps.registry;
131
94
  this.logger = deps.logger;
132
- this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
133
95
  }
134
96
 
135
- // ── Public seam methods ────────────────────────────────────────────────
136
-
137
- /**
138
- * Resolve a permission decision for the current context: prompt directly
139
- * when this session has UI, otherwise forward to the parent session.
140
- */
141
- requestApproval(
142
- ctx: ForwarderContext,
143
- message: string,
144
- options?: RequestPermissionOptions,
145
- forwarded?: ForwardedPromptDisplay,
97
+ authorize(
98
+ details: PromptPermissionDetails,
146
99
  ): Promise<PermissionPromptDecision> {
147
- if (ctx.hasUI) {
148
- return this.requestPermissionDecisionFromUi(
149
- ctx.ui,
150
- "Permission Required",
151
- message,
152
- options,
153
- );
154
- }
155
-
156
- if (!this.detection.isSubagent(ctx)) {
157
- return Promise.resolve({ approved: false, state: "denied" });
158
- }
159
-
160
- return this.waitForForwardedApproval(ctx, message, forwarded);
100
+ const uiPrompt = buildDirectUiPrompt(details);
101
+ return this.waitForForwardedApproval(this.ctx, details.message, {
102
+ source: uiPrompt.source,
103
+ surface: uiPrompt.surface,
104
+ value: uiPrompt.value,
105
+ });
161
106
  }
162
107
 
163
108
  // ── Private methods ────────────────────────────────────────────────────
@@ -170,7 +115,10 @@ export class ApprovalEscalator implements ApprovalRequester {
170
115
  const requesterSessionId = getSessionId(ctx);
171
116
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
172
117
  hasUI: ctx.hasUI,
173
- isSubagent: this.detection.isSubagent(ctx),
118
+ // Invariant: selectAuthorizer only selects ParentAuthorizer for a
119
+ // no-UI subagent context, so this is always true — no detection dep
120
+ // needed to re-derive it here.
121
+ isSubagent: true,
174
122
  currentSessionId: requesterSessionId,
175
123
  env: process.env,
176
124
  sessionId: requesterSessionId,