@erichll/pi-auto-review 0.1.1 → 0.1.2

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 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. Human terminal when the model returns `defer` or `failureMode` is `defer`
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` and reaches the human
29
- terminal. Sandbox adapters should use concrete surfaces such as
30
- `filesystem-read`, `filesystem-write`, and `network`.
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
 
@@ -189,9 +203,10 @@ PI_AUTO_REVIEW_ALLOW_UNTRUSTED_DEV=1 pi --approve
189
203
  The trusted package configuration is copied and frozen at `session_start`.
190
204
  An optional workspace file at `.pi/pi-auto-review.json` may only lower
191
205
  timeouts, token/evidence limits, retries, and grant TTL, or set `failureMode`
192
- to `deny`. It cannot select a model, weaken fail-closed behavior, or raise a
193
- trusted limit. Invalid project configuration disables the reviewer for that
194
- session.
206
+ to `deny`. It may remove entries from `autoConfirmBoundedAllows`, but cannot
207
+ add a surface not enabled by the trusted package configuration. It cannot
208
+ select a model, weaken fail-closed behavior, or raise a trusted limit. Invalid
209
+ project configuration disables the reviewer for that session.
195
210
 
196
211
  Writes to the installed reviewer package, project security configuration,
197
212
  global Pi security configuration, and the global audit directory are
@@ -8,5 +8,6 @@
8
8
  "maxToolTranscriptTokens": 1200,
9
9
  "maxRelevantResultTokens": 800,
10
10
  "failureMode": "deny",
11
- "grantTtlMs": 60000
11
+ "grantTtlMs": 60000,
12
+ "autoConfirmBoundedAllows": ["external_directory"]
12
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.json CHANGED
@@ -8,5 +8,6 @@
8
8
  "maxToolTranscriptTokens": 1200,
9
9
  "maxRelevantResultTokens": 800,
10
10
  "failureMode": "deny",
11
- "grantTtlMs": 60000
11
+ "grantTtlMs": 60000,
12
+ "autoConfirmBoundedAllows": ["external_directory"]
12
13
  }
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ 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";
@@ -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 config;
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
  };
@@ -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
+ }