@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 +25 -0
- package/docs/configuration.md +3 -0
- package/docs/cross-extension-api.md +2 -1
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +6 -1
- package/src/access-intent/bash/token-classification.ts +30 -1
- package/src/authority/approval-escalator.ts +37 -89
- package/src/authority/authorizer-selection.ts +86 -0
- package/src/authority/authorizer.ts +72 -0
- package/src/authority/denying-authorizer.ts +20 -0
- package/src/authority/forwarded-request-server.ts +163 -89
- package/src/authority/local-user-authorizer.ts +51 -0
- package/src/{permission-prompter.ts → authority/permission-prompter.ts} +45 -49
- package/src/authority/subagent-detection.ts +1 -1
- package/src/config-loader.ts +1 -1
- package/src/handlers/gates/descriptor.ts +1 -1
- package/src/handlers/gates/helpers.ts +4 -3
- package/src/handlers/gates/runner.ts +8 -7
- package/src/index.ts +46 -34
- package/src/normalize.ts +1 -1
- package/src/path-normalizer.ts +12 -0
- package/src/permission-dialog.ts +8 -0
- package/src/permission-forwarding.ts +2 -1
- package/src/permission-gate.ts +15 -21
- package/src/permission-manager.ts +1 -1
- package/src/permission-session.ts +6 -7
- package/src/permission-ui-prompt.ts +39 -54
- package/src/session-logger.ts +1 -1
- package/src/types.ts +20 -0
- package/src/value-guards.ts +0 -22
- package/src/gate-prompter.ts +0 -12
- package/src/prompting-gateway.ts +0 -89
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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(
|
|
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 {
|
|
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
|
-
// ──
|
|
61
|
+
// ── ParentAuthorizer ────────────────────────────────────────────────────
|
|
86
62
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
72
|
+
* Authorizer for a subagent session: escalate the ask up the tree to the
|
|
73
|
+
* parent's authority.
|
|
109
74
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* for
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
+
}
|