@gotgenes/pi-permission-system 20.0.0 → 20.2.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,31 @@ 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.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.1.0...pi-permission-system-v20.2.0) (2026-07-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** serve forwarded permissions by resolution and Authorizer escalation ([#557](https://github.com/gotgenes/pi-packages/issues/557)) ([c5d3bcb](https://github.com/gotgenes/pi-packages/commit/c5d3bcbf4e69a568f750d6a7c0619d4b88d5b377))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** mark Phase 9 Step 3 complete ([a5a348f](https://github.com/gotgenes/pi-packages/commit/a5a348f3d3dc1835e64ecd7b6b0c1b6164085a97))
19
+
20
+ ## [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)
21
+
22
+
23
+ ### Features
24
+
25
+ * **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))
26
+ * **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))
27
+
28
+
29
+ ### Documentation
30
+
31
+ * **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))
32
+
8
33
  ## [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
34
 
10
35
 
@@ -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 |
@@ -307,7 +307,8 @@ This event is for integrations such as notification extensions that should alert
307
307
  It is not a generic "permission request entered waiting state" event, and it does not imply the prompt will be approved.
308
308
  Policy decisions that resolve without an active UI prompt, such as `policy_allow`, `policy_deny`, `session_approved`, `infrastructure_auto_allowed`, or `auto_approved`, do not emit this event.
309
309
  Non-UI child sessions also do not emit this event when they create a forwarded permission request; the parent UI session emits it immediately before showing the forwarded permission dialog.
310
- Forwarded prompts are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
310
+ A forwarded request the parent's own recorded policy decides (a matching `allow` or `deny`) is answered without a prompt and emits no event; the event fires only when the parent is actually about to ask the human.
311
+ Forwarded prompts that do reach the human are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
311
312
 
312
313
  The payload is lean by design — `surface`/`value` are the normalized display projection a notification consumer reads, not a mirror of the internal review log.
313
314
  Read defensively rather than version-gating: broadcast payloads carry no `protocolVersion`.
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.2.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 { buildUiPrompt } 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 = buildUiPrompt(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,86 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
3
+ import {
4
+ type Authorizer,
5
+ type AuthorizerSelectionDeps,
6
+ selectAuthorizer,
7
+ } from "./authorizer";
8
+ import type {
9
+ PermissionPrompterApi,
10
+ PromptPermissionDetails,
11
+ } from "./permission-prompter";
12
+
13
+ /**
14
+ * The lifecycle slice of the selection owner that PermissionSession drives.
15
+ *
16
+ * PermissionSession calls activate/deactivate to keep the selection's stored
17
+ * context in sync with its own — the same pattern the former
18
+ * PromptingGatewayLifecycle used.
19
+ */
20
+ export interface AuthorizerSelectionLifecycle {
21
+ activate(ctx: ExtensionContext): void;
22
+ deactivate(): void;
23
+ }
24
+
25
+ /**
26
+ * The ask-escalation seam `GateRunner` depends on: escalate a single ask to
27
+ * the session's selected `Authorizer` and return its decision.
28
+ *
29
+ * Replaces the two-method `GatePrompter` role (#556). There is no
30
+ * "can anyone answer" pre-check: absent authority is the `DenyingAuthorizer`,
31
+ * which answers by denying with a `confirmationUnavailable` marker.
32
+ */
33
+ export interface AskEscalator {
34
+ escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
35
+ }
36
+
37
+ /**
38
+ * Context-owning selection root for the Authorizer spine.
39
+ *
40
+ * The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
41
+ * `selectAuthorizer` once per activation, and implements `AskEscalator` by
42
+ * delegating to the selected `Authorizer` via `PermissionPrompter`.
43
+ *
44
+ * `selectAuthorizer` encodes the liveness decision in *which* `Authorizer` it
45
+ * returns (`LocalUserAuthorizer` / `ParentAuthorizer` when authority is
46
+ * reachable, `DenyingAuthorizer` otherwise), so no separate confirmability
47
+ * predicate survives (#556 dissolved `canConfirm()`).
48
+ */
49
+ export class AuthorizerSelection
50
+ implements AskEscalator, AuthorizerSelectionLifecycle
51
+ {
52
+ private selected: Authorizer | null = null;
53
+
54
+ constructor(
55
+ private readonly deps: AuthorizerSelectionDeps & {
56
+ prompter: PermissionPrompterApi;
57
+ },
58
+ ) {}
59
+
60
+ /** Select the Authorizer for `ctx` and store it. */
61
+ activate(ctx: ExtensionContext): void {
62
+ this.selected = selectAuthorizer(ctx, this.deps);
63
+ }
64
+
65
+ /** Clear the stored selection. */
66
+ deactivate(): void {
67
+ this.selected = null;
68
+ }
69
+
70
+ /**
71
+ * Escalate an ask to the selected authorizer and return its decision.
72
+ *
73
+ * Rejects if no authorizer has been selected — i.e. before the session was
74
+ * activated. Implements {@link AskEscalator}.
75
+ */
76
+ escalate(
77
+ details: PromptPermissionDetails,
78
+ ): Promise<PermissionPromptDecision> {
79
+ if (this.selected === null) {
80
+ return Promise.reject(
81
+ new Error("escalate called before the session was activated"),
82
+ );
83
+ }
84
+ return this.deps.prompter.prompt(this.selected, details);
85
+ }
86
+ }
@@ -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,20 @@
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
+ * The denial carries the `confirmationUnavailable` marker so the ask path can
9
+ * distinguish "nobody could answer" from an interactive user denial when it
10
+ * derives the review-entry and decision-event resolution.
11
+ */
12
+ export class DenyingAuthorizer implements Authorizer {
13
+ authorize(): Promise<PermissionPromptDecision> {
14
+ return Promise.resolve({
15
+ approved: false,
16
+ state: "denied",
17
+ confirmationUnavailable: true,
18
+ });
19
+ }
20
+ }