@erichll/pi-auto-review 0.1.1 → 0.3.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/README.md +35 -21
- package/config/config.example.json +2 -1
- package/package.json +2 -2
- package/src/broker/types.ts +1 -1
- package/src/config.json +2 -1
- package/src/index.ts +71 -7
- package/src/integrations/{landstrip.ts → sandbox.ts} +15 -17
- package/src/policy.ts +1 -1
- package/src/ui-auto-confirm.ts +323 -0
package/README.md
CHANGED
|
@@ -20,14 +20,24 @@ The broker applies the following order:
|
|
|
20
20
|
|
|
21
21
|
1. Deterministic hard denies
|
|
22
22
|
2. Model review
|
|
23
|
-
3.
|
|
23
|
+
3. Local terminal when the authorizer chain requires one
|
|
24
24
|
4. Exact, expiring, one-use grants for external sandbox adapters
|
|
25
25
|
|
|
26
26
|
Model approval cannot override `pi-permission-system`'s bounded-delegation
|
|
27
27
|
checkpoint. In version 24, an authorizer's `allow` on `path` or
|
|
28
|
-
`external_directory` is still downgraded to `defer
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
`external_directory` is still downgraded to `defer`. In an interactive v24 TUI,
|
|
29
|
+
`autoConfirmBoundedAllows` can bind that exact model allow to the immediately
|
|
30
|
+
following local permission dialog and complete it as `auto_approved`. The
|
|
31
|
+
default enables this bridge only for `external_directory`; `path`, non-TUI
|
|
32
|
+
modes, UI mismatches, and reviewer defers still reach the human terminal.
|
|
33
|
+
Sandbox adapters should use concrete surfaces such as `filesystem-read`,
|
|
34
|
+
`filesystem-write`, and `network`.
|
|
35
|
+
|
|
36
|
+
The TUI bridge is request-ID-bound, expires after ten seconds, is consumed
|
|
37
|
+
once, and recognizes the v24 inline permission component before completing it.
|
|
38
|
+
Any event ordering, request, mode, component, or shared-UI wrapper mismatch
|
|
39
|
+
fails closed to the original dialog. This is a compatibility bridge around the
|
|
40
|
+
v24 delegation envelope, not permission-system authority-chain API.
|
|
31
41
|
|
|
32
42
|
The broker stops automatic review for a turn after three consecutive denials
|
|
33
43
|
or ten denials in the rolling last fifty reviews. An explicit denial is
|
|
@@ -67,13 +77,17 @@ Edit `src/config.json`, using `config/config.example.json` as the reference:
|
|
|
67
77
|
"maxToolTranscriptTokens": 1200,
|
|
68
78
|
"maxRelevantResultTokens": 800,
|
|
69
79
|
"failureMode": "deny",
|
|
70
|
-
"grantTtlMs": 60000
|
|
80
|
+
"grantTtlMs": 60000,
|
|
81
|
+
"autoConfirmBoundedAllows": ["external_directory"]
|
|
71
82
|
}
|
|
72
83
|
```
|
|
73
84
|
|
|
74
85
|
The configured provider must be registered in Pi and must support the
|
|
75
86
|
configured model. `failureMode: "deny"` is the fail-closed default;
|
|
76
87
|
`failureMode: "defer"` falls through to the normal human terminal.
|
|
88
|
+
Set `autoConfirmBoundedAllows` to `[]` to keep every bounded allow manual, or
|
|
89
|
+
include `"path"` as well to opt into the same TUI bridge for the cross-cutting
|
|
90
|
+
path surface.
|
|
77
91
|
|
|
78
92
|
Add the link to the permission-system configuration:
|
|
79
93
|
|
|
@@ -100,8 +114,8 @@ import {
|
|
|
100
114
|
} from "@erichll/pi-auto-review";
|
|
101
115
|
|
|
102
116
|
const request: BoundaryRequest = {
|
|
103
|
-
id: "
|
|
104
|
-
source: "
|
|
117
|
+
id: "sandbox-runtime-query-id",
|
|
118
|
+
source: "sandbox-runtime",
|
|
105
119
|
surface: "network",
|
|
106
120
|
operation: "connect",
|
|
107
121
|
cwd: "/workspace/project",
|
|
@@ -144,7 +158,7 @@ may additionally receive only these bounded result classes:
|
|
|
144
158
|
a pending deletion target.
|
|
145
159
|
- `git remote`, branch, status, revision, and remote configuration results for
|
|
146
160
|
a pending Git push.
|
|
147
|
-
- The current structured
|
|
161
|
+
- The current structured Sandbox Runtime trap.
|
|
148
162
|
|
|
149
163
|
Unrelated tool results and assistant prose remain excluded. Selected results
|
|
150
164
|
have a separate `maxRelevantResultTokens` budget, common secret/token forms are
|
|
@@ -152,19 +166,18 @@ redacted, and markup characters are escaped before the evidence is sent to the
|
|
|
152
166
|
reviewer. Results remain explicitly labeled as untrusted and possibly
|
|
153
167
|
incomplete.
|
|
154
168
|
|
|
155
|
-
##
|
|
169
|
+
## Sandbox adapter status
|
|
156
170
|
|
|
157
171
|
The broker-facing contract is implemented, but this package does not intercept
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
OS sandbox events by itself. Adapters translate a concrete boundary into a
|
|
173
|
+
`BoundaryRequest` and must consume the exact grant before allowing the
|
|
174
|
+
operation.
|
|
161
175
|
|
|
162
|
-
This monorepo
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
x64/arm64 hosts. Do not load `pi-sandbox` and `pi-landstrip` together.
|
|
176
|
+
This monorepo's `pi-sandbox` uses Anthropic Sandbox Runtime. Filesystem policy
|
|
177
|
+
is static and fails closed; unmatched public network destinations use the
|
|
178
|
+
broker for per-connection approval. Each Bash command or built-in subagent
|
|
179
|
+
session owns an independent Sandbox Runtime broker process. Adapters should use
|
|
180
|
+
the `./sandbox` export.
|
|
168
181
|
|
|
169
182
|
Do not implement the adapter by writing broad permanent rules to
|
|
170
183
|
`.pi/sandbox.json`.
|
|
@@ -189,9 +202,10 @@ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
|
|
|
189
202
|
The trusted package configuration is copied and frozen at `session_start`.
|
|
190
203
|
An optional workspace file at `.pi/pi-auto-review.json` may only lower
|
|
191
204
|
timeouts, token/evidence limits, retries, and grant TTL, or set `failureMode`
|
|
192
|
-
to `deny`. It
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
to `deny`. It may remove entries from `autoConfirmBoundedAllows`, but cannot
|
|
206
|
+
add a surface not enabled by the trusted package configuration. It cannot
|
|
207
|
+
select a model, weaken fail-closed behavior, or raise a trusted limit. Invalid
|
|
208
|
+
project configuration disables the reviewer for that session.
|
|
195
209
|
|
|
196
210
|
Writes to the installed reviewer package, project security configuration,
|
|
197
211
|
global Pi security configuration, and the global audit directory are
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Model-backed boundary approval broker for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"exports": {
|
|
36
36
|
".": "./src/index.ts",
|
|
37
37
|
"./broker": "./src/broker/index.ts",
|
|
38
|
-
"./
|
|
38
|
+
"./sandbox": "./src/integrations/sandbox.ts"
|
|
39
39
|
},
|
|
40
40
|
"pi": {
|
|
41
41
|
"extensions": [
|
package/src/broker/types.ts
CHANGED
package/src/config.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -33,16 +33,17 @@ import {
|
|
|
33
33
|
type BoundaryReview,
|
|
34
34
|
type BoundaryReviewerContext,
|
|
35
35
|
} from "./broker/index.ts";
|
|
36
|
+
import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
|
|
36
37
|
|
|
37
38
|
export { parseDecision } from "./policy.ts";
|
|
38
39
|
export * from "./broker/index.ts";
|
|
39
40
|
export {
|
|
40
|
-
|
|
41
|
-
type
|
|
42
|
-
type
|
|
43
|
-
type
|
|
44
|
-
type
|
|
45
|
-
} from "./integrations/
|
|
41
|
+
sandboxTrapToBoundaryRequest,
|
|
42
|
+
type SandboxBoundaryTrap,
|
|
43
|
+
type SandboxFilesystemTrap,
|
|
44
|
+
type SandboxNetworkTrap,
|
|
45
|
+
type SandboxRequestContext,
|
|
46
|
+
} from "./integrations/sandbox.ts";
|
|
46
47
|
|
|
47
48
|
type ReasoningLevel =
|
|
48
49
|
| "off"
|
|
@@ -53,6 +54,8 @@ type ReasoningLevel =
|
|
|
53
54
|
| "xhigh"
|
|
54
55
|
| "max";
|
|
55
56
|
|
|
57
|
+
type BoundedSurface = "external_directory" | "path";
|
|
58
|
+
|
|
56
59
|
export type Config = {
|
|
57
60
|
model: string;
|
|
58
61
|
reasoning: ReasoningLevel;
|
|
@@ -64,6 +67,7 @@ export type Config = {
|
|
|
64
67
|
maxRelevantResultTokens: number;
|
|
65
68
|
failureMode: "deny" | "defer";
|
|
66
69
|
grantTtlMs: number;
|
|
70
|
+
autoConfirmBoundedAllows: readonly BoundedSurface[];
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
type CompletionMessage = {
|
|
@@ -109,6 +113,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
109
113
|
maxRelevantResultTokens: 800,
|
|
110
114
|
failureMode: "deny",
|
|
111
115
|
grantTtlMs: 60_000,
|
|
116
|
+
autoConfirmBoundedAllows: Object.freeze(["external_directory"]),
|
|
112
117
|
};
|
|
113
118
|
|
|
114
119
|
const REVIEWER_SYSTEM_PROMPT = `You are a fail-closed permission reviewer.
|
|
@@ -163,6 +168,7 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
163
168
|
"maxRelevantResultTokens",
|
|
164
169
|
"failureMode",
|
|
165
170
|
"grantTtlMs",
|
|
171
|
+
"autoConfirmBoundedAllows",
|
|
166
172
|
]);
|
|
167
173
|
const unknownKeys = Object.keys(raw).filter((key) => !allowedKeys.has(key));
|
|
168
174
|
if (unknownKeys.length > 0) {
|
|
@@ -212,6 +218,18 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
212
218
|
) {
|
|
213
219
|
throw new Error(`${EXTENSION_NAME}: grantTtlMs must be 1000..300000`);
|
|
214
220
|
}
|
|
221
|
+
if (
|
|
222
|
+
!Array.isArray(config.autoConfirmBoundedAllows) ||
|
|
223
|
+
config.autoConfirmBoundedAllows.some(
|
|
224
|
+
(surface) => !BOUNDED_SURFACES.has(surface),
|
|
225
|
+
) ||
|
|
226
|
+
new Set(config.autoConfirmBoundedAllows).size !==
|
|
227
|
+
config.autoConfirmBoundedAllows.length
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`${EXTENSION_NAME}: autoConfirmBoundedAllows must contain unique external_directory/path entries`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
215
233
|
for (const [name, entry] of [
|
|
216
234
|
["maxUserTranscriptTokens", config.maxUserTranscriptTokens],
|
|
217
235
|
["maxToolTranscriptTokens", config.maxToolTranscriptTokens],
|
|
@@ -221,7 +239,12 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
221
239
|
throw new Error(`${EXTENSION_NAME}: ${name} must be 32..8000`);
|
|
222
240
|
}
|
|
223
241
|
}
|
|
224
|
-
return
|
|
242
|
+
return {
|
|
243
|
+
...config,
|
|
244
|
+
autoConfirmBoundedAllows: Object.freeze([
|
|
245
|
+
...config.autoConfirmBoundedAllows,
|
|
246
|
+
]),
|
|
247
|
+
};
|
|
225
248
|
}
|
|
226
249
|
|
|
227
250
|
export function loadConfig(): Config {
|
|
@@ -260,6 +283,7 @@ export function applyProjectConfig(
|
|
|
260
283
|
const allowed = new Set<string>([
|
|
261
284
|
...TIGHTENABLE_NUMBER_KEYS,
|
|
262
285
|
"failureMode",
|
|
286
|
+
"autoConfirmBoundedAllows",
|
|
263
287
|
]);
|
|
264
288
|
const forbidden = Object.keys(raw).filter((key) => !allowed.has(key));
|
|
265
289
|
if (forbidden.length > 0) {
|
|
@@ -289,6 +313,25 @@ export function applyProjectConfig(
|
|
|
289
313
|
}
|
|
290
314
|
merged.failureMode = "deny";
|
|
291
315
|
}
|
|
316
|
+
if (raw.autoConfirmBoundedAllows !== undefined) {
|
|
317
|
+
if (
|
|
318
|
+
!Array.isArray(raw.autoConfirmBoundedAllows) ||
|
|
319
|
+
raw.autoConfirmBoundedAllows.some(
|
|
320
|
+
(surface) =>
|
|
321
|
+
typeof surface !== "string" ||
|
|
322
|
+
!trusted.autoConfirmBoundedAllows.includes(
|
|
323
|
+
surface as BoundedSurface,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`${EXTENSION_NAME}: project autoConfirmBoundedAllows may only remove trusted surfaces`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
merged.autoConfirmBoundedAllows = [
|
|
332
|
+
...new Set(raw.autoConfirmBoundedAllows as BoundedSurface[]),
|
|
333
|
+
];
|
|
334
|
+
}
|
|
292
335
|
return Object.freeze(validateConfig(merged, "effective project config"));
|
|
293
336
|
}
|
|
294
337
|
|
|
@@ -376,6 +419,12 @@ function sessionConfig(
|
|
|
376
419
|
}
|
|
377
420
|
|
|
378
421
|
function surfaceOf(details: PromptPermissionDetails): string {
|
|
422
|
+
if (
|
|
423
|
+
typeof details.accessIntent?.surface === "string" &&
|
|
424
|
+
details.accessIntent.surface
|
|
425
|
+
) {
|
|
426
|
+
return details.accessIntent.surface;
|
|
427
|
+
}
|
|
379
428
|
if (typeof details.surface === "string" && details.surface) {
|
|
380
429
|
return details.surface;
|
|
381
430
|
}
|
|
@@ -721,6 +770,9 @@ export function createPiAutoReviewExtension(
|
|
|
721
770
|
let disposeBrokerService: (() => void) | undefined;
|
|
722
771
|
const reviewResults = new Map<string, ReviewResult>();
|
|
723
772
|
let broker: BoundaryApprovalBroker | undefined;
|
|
773
|
+
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
774
|
+
() => config.autoConfirmBoundedAllows,
|
|
775
|
+
);
|
|
724
776
|
|
|
725
777
|
const createBroker = (): BoundaryApprovalBroker =>
|
|
726
778
|
new BoundaryApprovalBroker({
|
|
@@ -831,6 +883,7 @@ export function createPiAutoReviewExtension(
|
|
|
831
883
|
disposeBrokerService?.();
|
|
832
884
|
broker?.clear();
|
|
833
885
|
reviewResults.clear();
|
|
886
|
+
uiAutoConfirmer.clear();
|
|
834
887
|
try {
|
|
835
888
|
config = sessionConfig(
|
|
836
889
|
ctx.cwd,
|
|
@@ -852,6 +905,10 @@ export function createPiAutoReviewExtension(
|
|
|
852
905
|
}
|
|
853
906
|
});
|
|
854
907
|
|
|
908
|
+
pi.events.on("permissions:ui_prompt", (event) => {
|
|
909
|
+
if (context) uiAutoConfirmer.handlePrompt(event, context);
|
|
910
|
+
});
|
|
911
|
+
|
|
855
912
|
pi.events.on("permissions:ready", () => {
|
|
856
913
|
try {
|
|
857
914
|
const service = permissionsService();
|
|
@@ -880,6 +937,11 @@ export function createPiAutoReviewExtension(
|
|
|
880
937
|
const result = reviewResults.get(request.id);
|
|
881
938
|
reviewResults.delete(request.id);
|
|
882
939
|
const allowCapped = decision.kind === "allow" && boundedRequest(details);
|
|
940
|
+
const autoConfirmQueued =
|
|
941
|
+
allowCapped &&
|
|
942
|
+
context.mode === "tui" &&
|
|
943
|
+
context.hasUI &&
|
|
944
|
+
uiAutoConfirmer.stage(request.id, surface);
|
|
883
945
|
log.review("pi_auto_review_decision", {
|
|
884
946
|
requestId: request.id,
|
|
885
947
|
surface,
|
|
@@ -890,6 +952,7 @@ export function createPiAutoReviewExtension(
|
|
|
890
952
|
userAuthorization: decision.review.userAuthorization,
|
|
891
953
|
rationale: decision.review.rationale,
|
|
892
954
|
allowCapped,
|
|
955
|
+
autoConfirmQueued,
|
|
893
956
|
circuitBreakerTripped:
|
|
894
957
|
decision.kind === "deny"
|
|
895
958
|
? decision.circuitBreakerTripped
|
|
@@ -931,6 +994,7 @@ export function createPiAutoReviewExtension(
|
|
|
931
994
|
broker?.clear();
|
|
932
995
|
broker = undefined;
|
|
933
996
|
reviewResults.clear();
|
|
997
|
+
uiAutoConfirmer.clear();
|
|
934
998
|
context = undefined;
|
|
935
999
|
});
|
|
936
1000
|
};
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { BoundaryRequest } from "../broker/types.ts";
|
|
2
2
|
|
|
3
|
-
export type
|
|
3
|
+
export type SandboxFilesystemTrap = {
|
|
4
4
|
kind: "filesystem";
|
|
5
|
-
code: "FILESYSTEM_DENIED";
|
|
6
|
-
query_id?: string;
|
|
7
5
|
operation: "read" | "write";
|
|
8
6
|
path: string;
|
|
9
7
|
requested_path?: string;
|
|
10
8
|
reason?: string;
|
|
9
|
+
query_id?: string;
|
|
11
10
|
process?: {
|
|
12
11
|
pid?: number;
|
|
13
12
|
exe?: string | null;
|
|
@@ -15,12 +14,11 @@ export type LandstripFilesystemTrap = {
|
|
|
15
14
|
};
|
|
16
15
|
};
|
|
17
16
|
|
|
18
|
-
export type
|
|
17
|
+
export type SandboxNetworkTrap = {
|
|
19
18
|
kind: "network";
|
|
20
|
-
code: "NETWORK_DENIED";
|
|
21
|
-
query_id?: string;
|
|
22
19
|
operation: "connect" | "bind";
|
|
23
20
|
target: string;
|
|
21
|
+
query_id?: string;
|
|
24
22
|
process?: {
|
|
25
23
|
pid?: number;
|
|
26
24
|
exe?: string | null;
|
|
@@ -28,29 +26,29 @@ export type LandstripNetworkTrap = {
|
|
|
28
26
|
};
|
|
29
27
|
};
|
|
30
28
|
|
|
31
|
-
export type
|
|
32
|
-
|
|
|
33
|
-
|
|
|
29
|
+
export type SandboxBoundaryTrap =
|
|
30
|
+
| SandboxFilesystemTrap
|
|
31
|
+
| SandboxNetworkTrap;
|
|
34
32
|
|
|
35
|
-
export type
|
|
33
|
+
export type SandboxRequestContext = {
|
|
36
34
|
command?: string;
|
|
37
35
|
cwd: string;
|
|
38
36
|
agentName?: string;
|
|
39
37
|
};
|
|
40
38
|
|
|
41
|
-
export function
|
|
42
|
-
trap:
|
|
43
|
-
context:
|
|
39
|
+
export function sandboxTrapToBoundaryRequest(
|
|
40
|
+
trap: SandboxBoundaryTrap,
|
|
41
|
+
context: SandboxRequestContext,
|
|
44
42
|
): BoundaryRequest {
|
|
45
43
|
const processSuffix =
|
|
46
44
|
trap.process?.pid === undefined ? "" : `:${trap.process.pid}`;
|
|
47
45
|
const id = trap.query_id
|
|
48
|
-
? `
|
|
49
|
-
: `
|
|
46
|
+
? `sandbox-runtime:${trap.query_id}`
|
|
47
|
+
: `sandbox-runtime:${trap.kind}:${trap.operation}${processSuffix}`;
|
|
50
48
|
if (trap.kind === "filesystem") {
|
|
51
49
|
return {
|
|
52
50
|
id,
|
|
53
|
-
source: "
|
|
51
|
+
source: "sandbox-runtime",
|
|
54
52
|
surface:
|
|
55
53
|
trap.operation === "read"
|
|
56
54
|
? "filesystem-read"
|
|
@@ -70,7 +68,7 @@ export function landstripTrapToBoundaryRequest(
|
|
|
70
68
|
}
|
|
71
69
|
return {
|
|
72
70
|
id,
|
|
73
|
-
source: "
|
|
71
|
+
source: "sandbox-runtime",
|
|
74
72
|
surface: "network",
|
|
75
73
|
operation: trap.operation,
|
|
76
74
|
cwd: trap.process?.cwd || context.cwd,
|
package/src/policy.ts
CHANGED
|
@@ -582,7 +582,7 @@ function relevantResultEvidence(
|
|
|
582
582
|
function sandboxTrapEvidence(
|
|
583
583
|
request: RelevantBoundaryRequest,
|
|
584
584
|
): string | undefined {
|
|
585
|
-
if (request.source !== "
|
|
585
|
+
if (request.source !== "sandbox-runtime") return;
|
|
586
586
|
return `<sandbox-trap>
|
|
587
587
|
${boundedString({
|
|
588
588
|
surface: request.surface,
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ExtensionUIContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
type UiCustom = ExtensionUIContext["custom"];
|
|
7
|
+
|
|
8
|
+
type PermissionUiPromptEvent = {
|
|
9
|
+
requestId: string;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type PendingApproval = {
|
|
14
|
+
surface: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PromptAttempt = {
|
|
19
|
+
controller: AbortController;
|
|
20
|
+
settled: boolean;
|
|
21
|
+
recognized: boolean;
|
|
22
|
+
autoApprove?: () => void;
|
|
23
|
+
denyForUiConflict?: () => void;
|
|
24
|
+
restore?: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const AUTO_APPROVED_DECISION = {
|
|
28
|
+
approved: true,
|
|
29
|
+
state: "approved",
|
|
30
|
+
autoApproved: true,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const UI_CONFLICT_DENIED_DECISION = {
|
|
34
|
+
approved: false,
|
|
35
|
+
state: "denied",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const DEFAULT_PENDING_TTL_MS = 10_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bridges a model allow that the permission-system delegation envelope capped
|
|
42
|
+
* to defer into the immediately following local TUI terminal.
|
|
43
|
+
*
|
|
44
|
+
* The bridge is deliberately request-bound and one-shot. Any timing, event, UI,
|
|
45
|
+
* component, or wrapper mismatch leaves the normal human dialog in control.
|
|
46
|
+
*/
|
|
47
|
+
export class PermissionUiAutoConfirmer {
|
|
48
|
+
private readonly pending = new Map<string, PendingApproval>();
|
|
49
|
+
private activeAttempt: PromptAttempt | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly enabledSurfaces: () => readonly string[],
|
|
53
|
+
private readonly pendingTtlMs = DEFAULT_PENDING_TTL_MS,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
stage(requestId: string, surface: string): boolean {
|
|
57
|
+
this.pruneExpired();
|
|
58
|
+
if (!this.enabledSurfaces().includes(surface)) return false;
|
|
59
|
+
this.pending.set(requestId, {
|
|
60
|
+
surface,
|
|
61
|
+
expiresAt: Date.now() + this.pendingTtlMs,
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handlePrompt(raw: unknown, ctx: ExtensionContext): void {
|
|
67
|
+
const event = parsePermissionUiPromptEvent(raw);
|
|
68
|
+
if (!event || ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
69
|
+
|
|
70
|
+
this.pruneExpired();
|
|
71
|
+
const pending = this.pending.get(event.requestId);
|
|
72
|
+
if (!pending) {
|
|
73
|
+
// A staged allow is valid only for the immediately following prompt.
|
|
74
|
+
// Never leave it latent after a different request reaches the terminal.
|
|
75
|
+
this.pending.clear();
|
|
76
|
+
this.settleActiveForConflict();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.pending.delete(event.requestId);
|
|
80
|
+
|
|
81
|
+
if (!this.enabledSurfaces().includes(pending.surface)) return;
|
|
82
|
+
this.settleActiveForConflict();
|
|
83
|
+
|
|
84
|
+
const attempt: PromptAttempt = {
|
|
85
|
+
controller: new AbortController(),
|
|
86
|
+
settled: false,
|
|
87
|
+
recognized: false,
|
|
88
|
+
};
|
|
89
|
+
this.activeAttempt = attempt;
|
|
90
|
+
if (!this.installOneShotInterceptor(ctx.ui, event, attempt)) {
|
|
91
|
+
attempt.controller.abort();
|
|
92
|
+
attempt.settled = true;
|
|
93
|
+
this.activeAttempt = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
clear(): void {
|
|
98
|
+
this.pending.clear();
|
|
99
|
+
const attempt = this.activeAttempt;
|
|
100
|
+
this.activeAttempt = undefined;
|
|
101
|
+
if (!attempt) return;
|
|
102
|
+
attempt.restore?.();
|
|
103
|
+
attempt.controller.abort();
|
|
104
|
+
attempt.settled = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private installOneShotInterceptor(
|
|
108
|
+
ui: ExtensionUIContext,
|
|
109
|
+
event: PermissionUiPromptEvent,
|
|
110
|
+
attempt: PromptAttempt,
|
|
111
|
+
): boolean {
|
|
112
|
+
const previousCustom = ui.custom;
|
|
113
|
+
let invoked = false;
|
|
114
|
+
let wrapper: UiCustom;
|
|
115
|
+
|
|
116
|
+
const release = () => {
|
|
117
|
+
if (this.activeAttempt === attempt) this.activeAttempt = undefined;
|
|
118
|
+
};
|
|
119
|
+
const restore = () => {
|
|
120
|
+
try {
|
|
121
|
+
if (ui.custom === wrapper) ui.custom = previousCustom;
|
|
122
|
+
} catch {
|
|
123
|
+
// A shared-UI wrapper conflict degrades to the normal human dialog.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
wrapper = ((factory: (...args: any[]) => any, options?: any) => {
|
|
128
|
+
if (invoked) {
|
|
129
|
+
return invokeUiCustom(previousCustom, ui, factory, options);
|
|
130
|
+
}
|
|
131
|
+
invoked = true;
|
|
132
|
+
restore();
|
|
133
|
+
|
|
134
|
+
if (options?.overlay !== false) {
|
|
135
|
+
this.releaseUnrecognized(attempt, release);
|
|
136
|
+
return invokeUiCustom(previousCustom, ui, factory, options);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const wrappedFactory = (
|
|
140
|
+
tui: unknown,
|
|
141
|
+
theme: unknown,
|
|
142
|
+
keybindings: unknown,
|
|
143
|
+
done: (result: unknown) => void,
|
|
144
|
+
) => {
|
|
145
|
+
let uiFinished = false;
|
|
146
|
+
const finish = (result: unknown) => {
|
|
147
|
+
if (uiFinished) return;
|
|
148
|
+
uiFinished = true;
|
|
149
|
+
if (!attempt.settled) {
|
|
150
|
+
attempt.settled = true;
|
|
151
|
+
attempt.controller.abort();
|
|
152
|
+
release();
|
|
153
|
+
}
|
|
154
|
+
done(result);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
attempt.autoApprove = () => finish(AUTO_APPROVED_DECISION);
|
|
158
|
+
attempt.denyForUiConflict = () => finish(UI_CONFLICT_DENIED_DECISION);
|
|
159
|
+
|
|
160
|
+
let produced: unknown;
|
|
161
|
+
try {
|
|
162
|
+
produced = factory(tui, theme, keybindings, finish);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.releaseUnrecognized(attempt, release);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isPromiseLike(produced)) {
|
|
169
|
+
return Promise.resolve(produced).then(
|
|
170
|
+
(component) =>
|
|
171
|
+
this.recognizeComponent(component, event, attempt, release),
|
|
172
|
+
(error) => {
|
|
173
|
+
this.releaseUnrecognized(attempt, release);
|
|
174
|
+
throw error;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return this.recognizeComponent(produced, event, attempt, release);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return invokeUiCustom(previousCustom, ui, wrappedFactory, options);
|
|
182
|
+
}) as UiCustom;
|
|
183
|
+
|
|
184
|
+
attempt.restore = restore;
|
|
185
|
+
try {
|
|
186
|
+
ui.custom = wrapper;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// v24 opens the permission component synchronously after the event. Never
|
|
192
|
+
// leave a wrapper installed long enough to catch an unrelated custom UI.
|
|
193
|
+
queueMicrotask(() => {
|
|
194
|
+
if (invoked) return;
|
|
195
|
+
restore();
|
|
196
|
+
this.releaseUnrecognized(attempt, release);
|
|
197
|
+
});
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private recognizeComponent(
|
|
202
|
+
component: unknown,
|
|
203
|
+
event: PermissionUiPromptEvent,
|
|
204
|
+
attempt: PromptAttempt,
|
|
205
|
+
release: () => void,
|
|
206
|
+
): unknown {
|
|
207
|
+
if (!isPermissionPromptComponent(component, event)) {
|
|
208
|
+
this.releaseUnrecognized(attempt, release);
|
|
209
|
+
return component;
|
|
210
|
+
}
|
|
211
|
+
attempt.recognized = true;
|
|
212
|
+
queueMicrotask(() => {
|
|
213
|
+
try {
|
|
214
|
+
if (!attempt.settled && attempt.recognized) {
|
|
215
|
+
attempt.autoApprove?.();
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
attempt.controller.abort();
|
|
219
|
+
attempt.settled = true;
|
|
220
|
+
release();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return component;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private releaseUnrecognized(
|
|
227
|
+
attempt: PromptAttempt,
|
|
228
|
+
release: () => void,
|
|
229
|
+
): void {
|
|
230
|
+
if (attempt.recognized || attempt.settled) return;
|
|
231
|
+
attempt.restore?.();
|
|
232
|
+
attempt.controller.abort();
|
|
233
|
+
attempt.settled = true;
|
|
234
|
+
release();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private settleActiveForConflict(): void {
|
|
238
|
+
const attempt = this.activeAttempt;
|
|
239
|
+
if (!attempt || attempt.settled) return;
|
|
240
|
+
try {
|
|
241
|
+
attempt.denyForUiConflict?.();
|
|
242
|
+
} catch {
|
|
243
|
+
attempt.controller.abort();
|
|
244
|
+
attempt.settled = true;
|
|
245
|
+
this.activeAttempt = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private pruneExpired(): void {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
for (const [requestId, pending] of this.pending) {
|
|
252
|
+
if (pending.expiresAt < now) this.pending.delete(requestId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parsePermissionUiPromptEvent(
|
|
258
|
+
raw: unknown,
|
|
259
|
+
): PermissionUiPromptEvent | undefined {
|
|
260
|
+
if (!isRecord(raw)) return;
|
|
261
|
+
if (
|
|
262
|
+
typeof raw.requestId !== "string" ||
|
|
263
|
+
typeof raw.message !== "string"
|
|
264
|
+
) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
return { requestId: raw.requestId, message: raw.message };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isPermissionPromptComponent(
|
|
271
|
+
value: unknown,
|
|
272
|
+
event: PermissionUiPromptEvent,
|
|
273
|
+
): boolean {
|
|
274
|
+
if (!isRecord(value) || typeof value.render !== "function") return false;
|
|
275
|
+
const constructorName =
|
|
276
|
+
typeof value.constructor === "function" ? value.constructor.name : "";
|
|
277
|
+
if (constructorName !== "PermissionPromptComponent") return false;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const rendered = value.render(2_000);
|
|
281
|
+
if (!Array.isArray(rendered)) return false;
|
|
282
|
+
const plain = normalizeWhitespace(stripAnsi(rendered.join("\n")));
|
|
283
|
+
const messagePrefix = normalizeWhitespace(event.message).slice(0, 120);
|
|
284
|
+
return (
|
|
285
|
+
plain.includes("Permission Required") &&
|
|
286
|
+
messagePrefix.length > 0 &&
|
|
287
|
+
plain.includes(messagePrefix)
|
|
288
|
+
);
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function invokeUiCustom(
|
|
295
|
+
method: UiCustom,
|
|
296
|
+
ui: ExtensionUIContext,
|
|
297
|
+
factory: (...args: any[]) => any,
|
|
298
|
+
options: any,
|
|
299
|
+
): Promise<any> {
|
|
300
|
+
return Reflect.apply(method as (...args: any[]) => Promise<any>, ui, [
|
|
301
|
+
factory,
|
|
302
|
+
options,
|
|
303
|
+
]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
307
|
+
return isRecord(value) && typeof value.then === "function";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
311
|
+
return typeof value === "object" && value !== null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function stripAnsi(value: string): string {
|
|
315
|
+
return value.replace(
|
|
316
|
+
/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g,
|
|
317
|
+
"",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeWhitespace(value: string): string {
|
|
322
|
+
return value.replace(/\s+/g, " ").trim();
|
|
323
|
+
}
|