@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 +13 -0
- package/docs/configuration.md +3 -0
- 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 +87 -0
- package/src/authority/authorizer.ts +72 -0
- package/src/authority/denying-authorizer.ts +12 -0
- package/src/authority/local-user-authorizer.ts +46 -0
- package/src/{permission-prompter.ts → authority/permission-prompter.ts} +23 -48
- package/src/authority/subagent-detection.ts +1 -1
- package/src/config-loader.ts +1 -1
- package/src/gate-prompter.ts +1 -1
- package/src/handlers/gates/descriptor.ts +1 -1
- package/src/index.ts +16 -23
- package/src/normalize.ts +1 -1
- package/src/path-normalizer.ts +12 -0
- package/src/permission-manager.ts +1 -1
- package/src/permission-session.ts +6 -7
- package/src/session-logger.ts +1 -1
- package/src/types.ts +20 -0
- package/src/value-guards.ts +0 -22
- package/src/prompting-gateway.ts +0 -89
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
|
|
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 |
|
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 { 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
|
-
// ──
|
|
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 = 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
|
-
|
|
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 {
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
65
|
+
authorizer: Authorizer,
|
|
73
66
|
details: PromptPermissionDetails,
|
|
74
67
|
): Promise<PermissionPromptDecision> {
|
|
75
68
|
this.writeReviewEntry("permission_request.waiting", details);
|
|
76
69
|
|
|
77
|
-
|
|
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
|
-
* `
|
|
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
|
*/
|
package/src/config-loader.ts
CHANGED
|
@@ -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 "./
|
|
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
|
package/src/gate-prompter.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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 "./
|
|
3
|
+
import { isDenyWithReason, isPermissionState } from "./types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Convert a flat permission config into a Ruleset.
|
package/src/path-normalizer.ts
CHANGED
|
@@ -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 "./
|
|
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
|
-
* - `
|
|
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
|
|
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.
|
|
73
|
+
this.authorizerSelection.activate(ctx);
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
/** Clear the context, stop forwarding, and deactivate the
|
|
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.
|
|
80
|
+
this.authorizerSelection.deactivate();
|
|
82
81
|
}
|
|
83
82
|
|
|
84
83
|
/** Return the current runtime context, or null if not activated. */
|
package/src/session-logger.ts
CHANGED
|
@@ -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`, `
|
|
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
|
+
}
|
package/src/value-guards.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/prompting-gateway.ts
DELETED
|
@@ -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
|
-
}
|