@gotgenes/pi-permission-system 20.0.0 → 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,19 @@ 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
+
8
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)
9
22
 
10
23
 
@@ -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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.0.0",
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,
@@ -0,0 +1,87 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { GatePrompter } from "#src/gate-prompter";
3
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
4
+ import {
5
+ type Authorizer,
6
+ type AuthorizerSelectionDeps,
7
+ selectAuthorizer,
8
+ } from "./authorizer";
9
+ import type {
10
+ PermissionPrompterApi,
11
+ PromptPermissionDetails,
12
+ } from "./permission-prompter";
13
+
14
+ /**
15
+ * The lifecycle slice of the selection owner that PermissionSession drives.
16
+ *
17
+ * PermissionSession calls activate/deactivate to keep the selection's stored
18
+ * context in sync with its own — the same pattern the former
19
+ * PromptingGatewayLifecycle used.
20
+ */
21
+ export interface AuthorizerSelectionLifecycle {
22
+ activate(ctx: ExtensionContext): void;
23
+ deactivate(): void;
24
+ }
25
+
26
+ /**
27
+ * Context-owning selection root for the Authorizer spine.
28
+ *
29
+ * The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
30
+ * `selectAuthorizer` once per activation, and implements `GatePrompter` by
31
+ * delegating to the selected `Authorizer` via `PermissionPrompter`.
32
+ *
33
+ * `canConfirm()` survives this step (dissolved in #556): it is recomputed
34
+ * transitionally alongside `selectAuthorizer`'s own branch, rather than
35
+ * derived from the selected authorizer, to keep the ask path byte-identical
36
+ * until #556 derives confirmability from a `DenyingAuthorizer` marker.
37
+ */
38
+ export class AuthorizerSelection
39
+ implements GatePrompter, AuthorizerSelectionLifecycle
40
+ {
41
+ private selected: Authorizer | null = null;
42
+ private confirmable = false;
43
+
44
+ constructor(
45
+ private readonly deps: AuthorizerSelectionDeps & {
46
+ prompter: PermissionPrompterApi;
47
+ },
48
+ ) {}
49
+
50
+ /** Select the Authorizer for `ctx` and store both it and the confirmable predicate. */
51
+ activate(ctx: ExtensionContext): void {
52
+ this.selected = selectAuthorizer(ctx, this.deps);
53
+ this.confirmable = ctx.hasUI || this.deps.detection.isSubagent(ctx);
54
+ }
55
+
56
+ /** Clear the stored selection. */
57
+ deactivate(): void {
58
+ this.selected = null;
59
+ this.confirmable = false;
60
+ }
61
+
62
+ /**
63
+ * Whether an interactive permission prompt can be shown.
64
+ *
65
+ * Returns false when no authorizer has been selected. Otherwise true when
66
+ * the context had UI or was a forwarding subagent at selection time — the
67
+ * two Authorizer-selection predicates, evaluated once at `activate`.
68
+ */
69
+ canConfirm(): boolean {
70
+ return this.selected !== null && this.confirmable;
71
+ }
72
+
73
+ /**
74
+ * Prompt for a permission decision using the selected authorizer.
75
+ *
76
+ * Rejects if no authorizer has been selected — `canConfirm()` guards this
77
+ * in normal use. Implements {@link GatePrompter}.
78
+ */
79
+ prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
80
+ if (this.selected === null) {
81
+ return Promise.reject(
82
+ new Error("prompt called before the session was activated"),
83
+ );
84
+ }
85
+ return this.deps.prompter.prompt(this.selected, details);
86
+ }
87
+ }
@@ -0,0 +1,72 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ PermissionPromptDecision,
4
+ requestPermissionDecisionFromUi,
5
+ } from "#src/permission-dialog";
6
+ import type { PermissionEventBus } from "#src/permission-events";
7
+ import type { DebugReviewLogger } from "#src/session-logger";
8
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
9
+ import { ParentAuthorizer } from "./approval-escalator";
10
+ import { DenyingAuthorizer } from "./denying-authorizer";
11
+ import { LocalUserAuthorizer } from "./local-user-authorizer";
12
+ import type { PromptPermissionDetails } from "./permission-prompter";
13
+ import type { SubagentDetector } from "./subagent-detection";
14
+
15
+ /**
16
+ * The live-authority role: on `ask`, an `Authorizer` rules on a single
17
+ * request and is told the decision.
18
+ *
19
+ * One method, one responsibility. `DenyingAuthorizer` ignores `details`;
20
+ * `LocalUserAuthorizer` reads `message`/`sessionLabel` and derives the UI
21
+ * event from it; `ParentAuthorizer` reads `message` and derives the
22
+ * forwarded display from it.
23
+ */
24
+ export interface Authorizer {
25
+ authorize(
26
+ details: PromptPermissionDetails,
27
+ ): Promise<PermissionPromptDecision>;
28
+ }
29
+
30
+ /** Construction inputs for {@link selectAuthorizer}. */
31
+ export interface AuthorizerSelectionDeps {
32
+ /** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
33
+ detection: SubagentDetector;
34
+ /** Event bus used by `LocalUserAuthorizer` for the `permissions:ui_prompt` broadcast. */
35
+ events: PermissionEventBus;
36
+ /** Injected for testability; production callers pass the real function. */
37
+ requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
38
+ /** Forwarding directory `ParentAuthorizer` reads/writes request and response files under. */
39
+ forwardingDir: string;
40
+ /** In-process subagent session registry for forwarding target resolution. */
41
+ registry?: SubagentSessionRegistry;
42
+ logger: DebugReviewLogger;
43
+ }
44
+
45
+ /**
46
+ * Select the `Authorizer` for the current context: the single owner of the
47
+ * three-way `hasUI` / `isSubagent` / deny dispatch.
48
+ *
49
+ * Evaluated once per session activation (`AuthorizerSelection.activate`),
50
+ * replacing the re-derivation of the same predicates across
51
+ * `PromptingGateway`, `PermissionPrompter`, and `ApprovalEscalator`.
52
+ */
53
+ export function selectAuthorizer(
54
+ ctx: ExtensionContext,
55
+ deps: AuthorizerSelectionDeps,
56
+ ): Authorizer {
57
+ if (ctx.hasUI) {
58
+ return new LocalUserAuthorizer({
59
+ ui: ctx.ui,
60
+ events: deps.events,
61
+ requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
62
+ });
63
+ }
64
+ if (deps.detection.isSubagent(ctx)) {
65
+ return new ParentAuthorizer(ctx, {
66
+ forwardingDir: deps.forwardingDir,
67
+ registry: deps.registry,
68
+ logger: deps.logger,
69
+ });
70
+ }
71
+ return new DenyingAuthorizer();
72
+ }
@@ -0,0 +1,12 @@
1
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
2
+ import type { Authorizer } from "./authorizer";
3
+
4
+ /**
5
+ * Least-privilege Authorizer: no authority is reachable for this session
6
+ * (no UI, not a subagent), so every ask is denied.
7
+ */
8
+ export class DenyingAuthorizer implements Authorizer {
9
+ authorize(): Promise<PermissionPromptDecision> {
10
+ return Promise.resolve({ approved: false, state: "denied" });
11
+ }
12
+ }
@@ -0,0 +1,46 @@
1
+ import type {
2
+ PermissionDecisionUi,
3
+ PermissionPromptDecision,
4
+ requestPermissionDecisionFromUi,
5
+ } from "#src/permission-dialog";
6
+ import {
7
+ emitUiPromptEvent,
8
+ type PermissionEventBus,
9
+ } from "#src/permission-events";
10
+ import { buildDirectUiPrompt } from "#src/permission-ui-prompt";
11
+ import type { Authorizer } from "./authorizer";
12
+ import type { PromptPermissionDetails } from "./permission-prompter";
13
+
14
+ /** Dependencies required by {@link LocalUserAuthorizer}. */
15
+ export interface LocalUserAuthorizerDeps {
16
+ /** The active session's UI surface. */
17
+ ui: PermissionDecisionUi;
18
+ /** Event bus used for the `permissions:ui_prompt` broadcast. */
19
+ events: PermissionEventBus;
20
+ /** Injected for testability; production callers pass the real function. */
21
+ requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
22
+ }
23
+
24
+ /**
25
+ * Authorizer for a session with an active UI: prompt the human here.
26
+ *
27
+ * Emits the `permissions:ui_prompt` broadcast (moved here from
28
+ * `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
29
+ * observers know a decision is imminent.
30
+ */
31
+ export class LocalUserAuthorizer implements Authorizer {
32
+ constructor(private readonly deps: LocalUserAuthorizerDeps) {}
33
+
34
+ authorize(
35
+ details: PromptPermissionDetails,
36
+ ): Promise<PermissionPromptDecision> {
37
+ const uiPrompt = buildDirectUiPrompt(details);
38
+ emitUiPromptEvent(this.deps.events, uiPrompt);
39
+ return this.deps.requestPermissionDecisionFromUi(
40
+ this.deps.ui,
41
+ "Permission Required",
42
+ details.message,
43
+ details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
44
+ );
45
+ }
46
+ }
@@ -1,12 +1,6 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { ApprovalRequester } from "./authority/approval-escalator";
3
- import type { PermissionPromptDecision } from "./permission-dialog";
4
- import {
5
- emitUiPromptEvent,
6
- type PermissionEventBus,
7
- } from "./permission-events";
8
- import { buildDirectUiPrompt } from "./permission-ui-prompt";
9
- import type { ReviewLogger } from "./session-logger";
1
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
2
+ import type { ReviewLogger } from "#src/session-logger";
3
+ import type { Authorizer } from "./authorizer";
10
4
 
11
5
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
12
6
 
@@ -27,72 +21,53 @@ export interface PromptPermissionDetails {
27
21
  sessionLabel?: string;
28
22
  }
29
23
 
30
- /** Mockable contract for permission prompting. */
24
+ /**
25
+ * Narrow seam onto {@link PermissionPrompter}.
26
+ *
27
+ * Kept separate from the concrete class so consumers (e.g. `AuthorizerSelection`)
28
+ * can inject a plain `{ prompt: vi.fn() }` mock in tests — a private field on
29
+ * the concrete class would create a nominal brand that a structural mock
30
+ * cannot satisfy without a cast.
31
+ */
31
32
  export interface PermissionPrompterApi {
32
33
  prompt(
33
- ctx: ExtensionContext,
34
+ authorizer: Authorizer,
34
35
  details: PromptPermissionDetails,
35
36
  ): Promise<PermissionPromptDecision>;
36
37
  }
37
38
 
38
- /**
39
- * Dependencies required by PermissionPrompter.
40
- *
41
- * Keeps the prompter's external surface narrow: callers provide a review
42
- * logger, the UI-prompt event bus, and the forwarder that owns the
43
- * UI/subagent-forwarding branching logic.
44
- */
39
+ /** Dependencies required by {@link PermissionPrompter}. */
45
40
  export interface PermissionPrompterDeps {
46
41
  /** Write structured entries to the permission review log. */
47
42
  logger: ReviewLogger;
48
- /** Event bus used for UI prompt broadcasts. */
49
- events: PermissionEventBus;
50
- /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
51
- forwarder: ApprovalRequester;
52
43
  }
53
44
 
54
45
  /**
55
- * Encapsulates the full permission-prompt flow:
46
+ * Brackets the ask-path flow with review-log entries and delegates the
47
+ * live decision to the selected {@link Authorizer}:
56
48
  * 1. Review-log "waiting" entry.
57
- * 2. UI-present vs. subagent-forwarding branching (via confirmPermission).
49
+ * 2. `authorizer.authorize(details)`.
58
50
  * 3. Review-log "approved" / "denied" entry.
59
51
  *
52
+ * The UI/forwarding branching this class previously owned now lives on the
53
+ * individual `Authorizer` implementations (`LocalUserAuthorizer`,
54
+ * `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
55
+ * `ExtensionContext` per call.
56
+ *
60
57
  * Yolo-mode auto-approval happens upstream, at the composition stage
61
58
  * (`PermissionManager.check`'s `rewriteAsksToYolo`) — an `ask` never reaches
62
59
  * this class under yolo, so this class has no yolo-mode knowledge.
63
- *
64
- * Injecting a single PermissionPrompter instance means adding a new prompt
65
- * parameter (e.g. a future sessionLabel variant) only requires changing
66
- * PromptPermissionDetails and this class — not the full threading chain.
67
60
  */
68
61
  export class PermissionPrompter implements PermissionPrompterApi {
69
62
  constructor(private readonly deps: PermissionPrompterDeps) {}
70
63
 
71
64
  async prompt(
72
- ctx: ExtensionContext,
65
+ authorizer: Authorizer,
73
66
  details: PromptPermissionDetails,
74
67
  ): Promise<PermissionPromptDecision> {
75
68
  this.writeReviewEntry("permission_request.waiting", details);
76
69
 
77
- // Build the event once. When this session has UI it broadcasts directly;
78
- // when it does not (a forwarding subagent), the display fields ride along
79
- // to the parent so the parent emits a non-degraded event from the
80
- // forwarded path instead of here.
81
- const uiPrompt = buildDirectUiPrompt(details);
82
- if (ctx.hasUI) {
83
- emitUiPromptEvent(this.deps.events, uiPrompt);
84
- }
85
-
86
- const decision = await this.deps.forwarder.requestApproval(
87
- ctx,
88
- details.message,
89
- details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
90
- {
91
- source: uiPrompt.source,
92
- surface: uiPrompt.surface,
93
- value: uiPrompt.value,
94
- },
95
- );
70
+ const decision = await authorizer.authorize(details);
96
71
 
97
72
  this.writeReviewEntry(
98
73
  decision.approved
@@ -8,7 +8,7 @@ import type { SubagentSessionRegistry } from "#src/subagent-registry";
8
8
  /**
9
9
  * Narrow seam for the ask-path consumers: "is the current session a subagent?"
10
10
  *
11
- * `PromptingGateway`, `ForwardingManager`, and `ApprovalEscalator` depend on
11
+ * `selectAuthorizer`/`AuthorizerSelection` and `ForwardingManager` depend on
12
12
  * this single-method view so their unit tests inject a one-field fake without
13
13
  * casts. It is the Authorizer-selection predicate the Phase 9 spine consumes.
14
14
  */
@@ -14,7 +14,7 @@ import {
14
14
  } from "./config-schema";
15
15
  import { mergeFlatPermissions } from "./permission-merge";
16
16
  import type { FlatPermissionConfig, PatternValue } from "./types";
17
- import { isDenyWithReason, isPermissionState } from "./value-guards";
17
+ import { isDenyWithReason, isPermissionState } from "./types";
18
18
 
19
19
  // The unified config shape is derived from the zod schema (config-schema.ts,
20
20
  // the single source of truth) and re-exported so existing importers keep their
@@ -1,5 +1,5 @@
1
+ import type { PromptPermissionDetails } from "./authority/permission-prompter";
1
2
  import type { PermissionPromptDecision } from "./permission-dialog";
2
- import type { PromptPermissionDetails } from "./permission-prompter";
3
3
 
4
4
  /**
5
5
  * The prompting role the gate runner needs: a yes/no on whether an
@@ -1,6 +1,6 @@
1
+ import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
1
2
  import type { DenialContext } from "#src/denial-messages";
2
3
  import type { PermissionDecisionEvent } from "#src/permission-events";
3
- import type { PromptPermissionDetails } from "#src/permission-prompter";
4
4
  import type { SessionApproval } from "#src/session-approval";
5
5
  import type { PermissionCheckResult, PermissionState } from "#src/types";
6
6
 
package/src/index.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
- import {
4
- ApprovalEscalator,
5
- type ApprovalEscalatorDeps,
6
- } from "./authority/approval-escalator";
3
+ import { AuthorizerSelection } from "./authority/authorizer-selection";
7
4
  import {
8
5
  ForwardedRequestServer,
9
6
  type ForwardedRequestServerDeps,
10
7
  } from "./authority/forwarded-request-server";
8
+ import { PermissionPrompter } from "./authority/permission-prompter";
11
9
  import { SubagentDetection } from "./authority/subagent-detection";
12
10
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
13
11
  import { registerPermissionSystemCommand } from "./config-modal";
@@ -29,11 +27,9 @@ import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
29
27
  import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
30
28
  import { requestPermissionDecisionFromUi } from "./permission-dialog";
31
29
  import { PermissionManager } from "./permission-manager";
32
- import { PermissionPrompter } from "./permission-prompter";
33
30
  import { PermissionResolver } from "./permission-resolver";
34
31
  import { PermissionSession } from "./permission-session";
35
32
  import { LocalPermissionsService } from "./permissions-service";
36
- import { PromptingGateway } from "./prompting-gateway";
37
33
  import { PermissionServiceLifecycle } from "./service-lifecycle";
38
34
  import { PermissionSessionLogger } from "./session-logger";
39
35
  import { SessionRules } from "./session-rules";
@@ -94,15 +90,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
94
90
  logger,
95
91
  });
96
92
 
97
- const escalatorDeps: ApprovalEscalatorDeps = {
98
- forwardingDir: paths.forwardingDir,
99
- detection: subagentDetection,
100
- registry: subagentRegistry,
101
- logger,
102
- requestPermissionDecisionFromUi,
103
- };
104
- const escalator = new ApprovalEscalator(escalatorDeps);
105
-
106
93
  const requestServerDeps: ForwardedRequestServerDeps = {
107
94
  forwardingDir: paths.forwardingDir,
108
95
  logger,
@@ -112,14 +99,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
112
99
  };
113
100
  const requestServer = new ForwardedRequestServer(requestServerDeps);
114
101
 
115
- const prompter = new PermissionPrompter({
116
- logger,
117
- events: pi.events,
118
- forwarder: escalator,
119
- });
102
+ const prompter = new PermissionPrompter({ logger });
120
103
 
121
- const gateway = new PromptingGateway({
104
+ const authorizerSelection = new AuthorizerSelection({
122
105
  detection: subagentDetection,
106
+ events: pi.events,
107
+ requestPermissionDecisionFromUi,
108
+ forwardingDir: paths.forwardingDir,
109
+ registry: subagentRegistry,
110
+ logger,
123
111
  prompter,
124
112
  });
125
113
 
@@ -129,7 +117,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
117
  permissionManager,
130
118
  sessionRules,
131
119
  configStore,
132
- gateway,
120
+ authorizerSelection,
133
121
  hostPlatform,
134
122
  );
135
123
 
@@ -196,7 +184,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
196
184
  const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
197
185
 
198
186
  const reporter = new GateDecisionReporter(logger, pi.events);
199
- const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter);
187
+ const gateRunner = new GateRunner(
188
+ resolver,
189
+ sessionRules,
190
+ authorizerSelection,
191
+ reporter,
192
+ );
200
193
  const toolCallGatePipeline = new ToolCallGatePipeline(
201
194
  resolver,
202
195
  session,
package/src/normalize.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Rule, Ruleset } from "./rule";
2
2
  import type { FlatPermissionConfig } from "./types";
3
- import { isDenyWithReason, isPermissionState } from "./value-guards";
3
+ import { isDenyWithReason, isPermissionState } from "./types";
4
4
 
5
5
  /**
6
6
  * Convert a flat permission config into a Ruleset.
@@ -112,6 +112,18 @@ export class PathNormalizer {
112
112
  return this.impl.isAbsolute(pathValue);
113
113
  }
114
114
 
115
+ /**
116
+ * True when the host platform treats a backslash as a path separator (win32).
117
+ *
118
+ * The bash rule-candidate classifier reads this to decide whether a
119
+ * backslash-relative token (`dir\file`) is path-shaped: on win32 `\` is a
120
+ * separator, but on POSIX it is a legal filename character (#520). Keeping the
121
+ * decision here means no bash-layer module re-reads `process.platform`.
122
+ */
123
+ usesWindowsSeparators(): boolean {
124
+ return this.platform === "win32";
125
+ }
126
+
115
127
  /**
116
128
  * Interpret a literal `cd` target's effect on the effective base.
117
129
  *
@@ -34,7 +34,7 @@ import type {
34
34
  PermissionCheckResult,
35
35
  PermissionState,
36
36
  } from "./types";
37
- import { isPermissionState } from "./value-guards";
37
+ import { isPermissionState } from "./types";
38
38
  import { wildcardMatch } from "./wildcard-matcher";
39
39
 
40
40
  const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
@@ -3,7 +3,7 @@ import {
3
3
  getActiveAgentName,
4
4
  getActiveAgentNameFromSystemPrompt,
5
5
  } from "./active-agent";
6
-
6
+ import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-selection";
7
7
  import type { SessionConfigStore } from "./config-store";
8
8
  import type { PermissionSystemExtensionConfig } from "./extension-config";
9
9
  import type { ExtensionPaths } from "./extension-paths";
@@ -11,7 +11,6 @@ import type { ForwardingController } from "./forwarding-manager";
11
11
  import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipeline";
12
12
  import { PathNormalizer } from "./path-normalizer";
13
13
  import type { ScopedPermissionManager } from "./permission-manager";
14
- import type { PromptingGatewayLifecycle } from "./prompting-gateway";
15
14
 
16
15
  import type { SessionRules } from "./session-rules";
17
16
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
@@ -33,7 +32,7 @@ import type { PathRuleTokenMatcher } from "./types";
33
32
  * - `ExtensionPaths` — immutable path constants
34
33
  * - `ForwardingController` — polling lifecycle
35
34
  * - `SessionConfigStore` — owns extension config; provides refresh, log, read
36
- * - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
35
+ * - `AuthorizerSelectionLifecycle` — authorizer-selection lifecycle forwarded via activate/deactivate
37
36
  */
38
37
  export class PermissionSession implements ToolCallGateInputs {
39
38
  private context: ExtensionContext | null = null;
@@ -47,7 +46,7 @@ export class PermissionSession implements ToolCallGateInputs {
47
46
  private readonly permissionManager: ScopedPermissionManager,
48
47
  private readonly sessionRules: SessionRules,
49
48
  private readonly configStore: SessionConfigStore,
50
- private readonly gateway: PromptingGatewayLifecycle,
49
+ private readonly authorizerSelection: AuthorizerSelectionLifecycle,
51
50
  private readonly platform: NodeJS.Platform,
52
51
  ) {
53
52
  // Placeholder until the first activate(ctx) binds the real cwd; every gate
@@ -71,14 +70,14 @@ export class PermissionSession implements ToolCallGateInputs {
71
70
  this.context = ctx;
72
71
  this.pathNormalizer = new PathNormalizer(this.platform, ctx.cwd);
73
72
  this.forwarding.start(ctx);
74
- this.gateway.activate(ctx);
73
+ this.authorizerSelection.activate(ctx);
75
74
  }
76
75
 
77
- /** Clear the context, stop forwarding, and deactivate the gateway. */
76
+ /** Clear the context, stop forwarding, and deactivate the authorizer selection. */
78
77
  deactivate(): void {
79
78
  this.context = null;
80
79
  this.forwarding.stop();
81
- this.gateway.deactivate();
80
+ this.authorizerSelection.deactivate();
82
81
  }
83
82
 
84
83
  /** Return the current runtime context, or null if not activated. */
@@ -19,7 +19,7 @@ export interface ReviewLogger {
19
19
 
20
20
  /**
21
21
  * Logging seam for consumers that write both debug and review entries.
22
- * Injected into `ConfigStore`, `ApprovalEscalator`, and `ForwardedRequestServer`.
22
+ * Injected into `ConfigStore`, `ParentAuthorizer`, and `ForwardedRequestServer`.
23
23
  */
24
24
  export interface DebugReviewLogger extends ReviewLogger {
25
25
  debug(event: string, details?: Record<string, unknown>): void;
package/src/types.ts CHANGED
@@ -63,3 +63,23 @@ export interface PermissionCheckResult {
63
63
  */
64
64
  commandContext?: BashCommandContext;
65
65
  }
66
+
67
+ export function isPermissionState(value: unknown): value is PermissionState {
68
+ return value === "allow" || value === "deny" || value === "ask";
69
+ }
70
+
71
+ /**
72
+ * Narrow type guard: a raw value representing a DenyWithReason object.
73
+ * Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
74
+ * Rejects a non-string `reason` to keep malformed config out of the rule set.
75
+ */
76
+ export function isDenyWithReason(value: unknown): value is DenyWithReason {
77
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
78
+ return false;
79
+ }
80
+ const record = value as Record<string, unknown>;
81
+ return (
82
+ record.action === "deny" &&
83
+ (record.reason === undefined || typeof record.reason === "string")
84
+ );
85
+ }
@@ -1,5 +1,3 @@
1
- import type { DenyWithReason, PermissionState } from "./types";
2
-
3
1
  export function toRecord(value: unknown): Record<string, unknown> {
4
2
  if (!value || typeof value !== "object" || Array.isArray(value)) {
5
3
  return {};
@@ -16,23 +14,3 @@ export function getNonEmptyString(value: unknown): string | null {
16
14
  const trimmed = value.trim();
17
15
  return trimmed.length > 0 ? trimmed : null;
18
16
  }
19
-
20
- export function isPermissionState(value: unknown): value is PermissionState {
21
- return value === "allow" || value === "deny" || value === "ask";
22
- }
23
-
24
- /**
25
- * Narrow type guard: a raw value representing a DenyWithReason object.
26
- * Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
27
- * Rejects a non-string `reason` to keep malformed config out of the rule set.
28
- */
29
- export function isDenyWithReason(value: unknown): value is DenyWithReason {
30
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
31
- return false;
32
- }
33
- const record = value as Record<string, unknown>;
34
- return (
35
- record.action === "deny" &&
36
- (record.reason === undefined || typeof record.reason === "string")
37
- );
38
- }
@@ -1,89 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { SubagentDetector } from "./authority/subagent-detection";
3
- import type { GatePrompter } from "./gate-prompter";
4
- import type { PermissionPromptDecision } from "./permission-dialog";
5
- import type {
6
- PermissionPrompterApi,
7
- PromptPermissionDetails,
8
- } from "./permission-prompter";
9
-
10
- /**
11
- * Dependencies required by PromptingGateway.
12
- *
13
- * All fields are actively consumed:
14
- * - `detection` drives `canConfirm()`.
15
- * - `prompter` is called by `prompt()`.
16
- */
17
- export interface PromptingGatewayDeps {
18
- /** Single owner of subagent detection; drives `canConfirm()`. */
19
- detection: SubagentDetector;
20
- /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
21
- prompter: PermissionPrompterApi;
22
- }
23
-
24
- /**
25
- * The lifecycle slice of the gateway that PermissionSession drives.
26
- *
27
- * PermissionSession calls activate/deactivate to keep the gateway's stored
28
- * context in sync with its own — the same pattern used for ForwardingController.
29
- */
30
- export interface PromptingGatewayLifecycle {
31
- activate(ctx: ExtensionContext): void;
32
- deactivate(): void;
33
- }
34
-
35
- /**
36
- * Context-owning implementation of the GatePrompter role.
37
- *
38
- * Owns the stored ExtensionContext and the "can we prompt?" policy
39
- * (UI / subagent), replacing the four twin methods that previously
40
- * lived on PermissionSession.
41
- *
42
- * Lifecycle: PermissionSession drives activate/deactivate so the stored
43
- * context mirrors the session context without independent call-site changes.
44
- */
45
- export class PromptingGateway
46
- implements GatePrompter, PromptingGatewayLifecycle
47
- {
48
- private context: ExtensionContext | null = null;
49
-
50
- constructor(private readonly deps: PromptingGatewayDeps) {}
51
-
52
- /** Store the current extension context. */
53
- activate(ctx: ExtensionContext): void {
54
- this.context = ctx;
55
- }
56
-
57
- /** Clear the stored context. */
58
- deactivate(): void {
59
- this.context = null;
60
- }
61
-
62
- /**
63
- * Whether an interactive permission prompt can be shown.
64
- *
65
- * Returns false when no context is active. Otherwise true when the
66
- * context has UI or is a forwarding subagent — the two Authorizer-
67
- * selection predicates the Phase 9 spine will consume. Yolo-mode never
68
- * reaches this policy: it is resolved upstream at the composition stage.
69
- */
70
- canConfirm(): boolean {
71
- if (this.context === null) return false;
72
- return this.context.hasUI || this.deps.detection.isSubagent(this.context);
73
- }
74
-
75
- /**
76
- * Prompt the user for a permission decision using the stored context.
77
- *
78
- * Rejects if no context is active — canConfirm() guards this in normal use.
79
- * Implements {@link GatePrompter}.
80
- */
81
- prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
82
- if (this.context === null) {
83
- return Promise.reject(
84
- new Error("prompt called before the session was activated"),
85
- );
86
- }
87
- return this.deps.prompter.prompt(this.context, details);
88
- }
89
- }