@erichll/pi-auto-review 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-08-24
4
+
5
+ - **Structured network destination.** Network approval requests now carry
6
+ separate host, port, and protocol fields alongside the existing combined
7
+ destination string. One-shot grants bind each component independently, so a
8
+ grant issued for one host-port-protocol combination cannot be reused for a
9
+ different one.
10
+ - **Deferred reviews are more visible.** The review widget now highlights
11
+ deferred verdicts with a warning accent instead of the previous muted style,
12
+ making pending human decisions easier to spot.
13
+
14
+ ## 0.10.0 - 2026-08-22
15
+
16
+ - Replace the review footer and newly appended transcript entries with one
17
+ above-editor `pi-auto-review` widget. Each permission check independently
18
+ replaces the previous display, shows the configured reviewer model while it
19
+ runs, and updates in place with its final outcome and metadata. The latest
20
+ result remains until the next check and is cleared on session change or
21
+ shutdown.
22
+ - Prevent late concurrent completions and permission decisions from overwriting
23
+ a newer check. A current local denial adds `Local confirmation · denied` to
24
+ that result only. Widget failures fall back to notifications without changing
25
+ authorization behavior.
26
+ - Remove tool-call grouping, timers, capacity eviction, and tool/turn flush
27
+ wiring for new feedback while retaining the historical entry renderer. Bound
28
+ target and rationale text with visible ellipses, preserve width-aware wrapping,
29
+ and keep token, duration, and call-count numbers in the normal metadata color.
30
+
3
31
  ## 0.9.0 - 2026-08-22
4
32
 
5
33
  - Require `@gotgenes/pi-permission-system` `^27.0.0` and register through the
package/README.md CHANGED
@@ -11,19 +11,34 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
11
11
  - authorizer: `pi-auto-review`
12
12
  - default reviewer model: `codex-auto-review`
13
13
 
14
+ ## Contents
15
+
16
+ - [Install and enable](#install-and-enable)
17
+ - [Security model](#security-model)
18
+ - [Configuration](#configuration)
19
+ - [Deadlines and retries](#deadlines-and-retries)
20
+ - [Operator feedback and exact retry](#operator-feedback-and-exact-retry)
21
+ - [Boundary broker API](#boundary-broker-api)
22
+ - [Reviewer context and token budgets](#reviewer-context-and-token-budgets)
23
+ - [Sandbox integration](#sandbox-integration)
24
+ - [Trust boundary](#trust-boundary)
25
+ - [Telemetry](#telemetry)
26
+ - [Real-model smoke test](#real-model-smoke-test)
27
+
14
28
  ## Install and enable
15
29
 
16
30
  > **Prerequisite:** pi-auto-review is an authorizer inside
17
31
  > `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
18
32
  > install the permission system separately (once per machine) before this
19
- > extension. Version 0.9.0 requires permission-system 27.x:
33
+ > extension. Version 0.10.0 requires permission-system 27.x:
20
34
 
21
35
  ```bash
22
36
  pi install npm:@gotgenes/pi-permission-system
23
37
  pi install npm:@erichll/pi-auto-review
24
38
  ```
25
39
 
26
- Install the package outside the agent-writable workspace:
40
+ Install the package outside the agent-writable workspace (see
41
+ [Trust boundary](#trust-boundary)):
27
42
 
28
43
  Add it to the permission-system authorizer chain:
29
44
 
@@ -132,16 +147,20 @@ only a fixed, budget-checked schema correction.
132
147
 
133
148
  ## Operator feedback and exact retry
134
149
 
135
- Interactive sessions show a best-effort footer while review is running and
136
- structured results for allow, local confirmation, defer, deny, or circuit
137
- breaker outcomes. Local permission-system asks that belong to the same tool
138
- call are grouped into one TUI entry; each request still has its own model call,
139
- verdict, grant, local confirmation, and audit record. Forwarded asks and
140
- non-TUI modes remain immediate and ungrouped. Group members retain their own
141
- surface, outcome, target, rationale, model, token usage, and duration. UI
142
- entries use unbordered muted text and place each target and rationale together
143
- on the same line as that member's surface and outcome. UI delivery never
144
- changes the authorization result.
150
+ Interactive sessions show the current permission check in a single widget
151
+ above the editor. Each check first shows its surface, compact target, and the
152
+ dynamically configured reviewer model, then replaces that content in place
153
+ with the outcome, target and rationale, model, token usage, duration, and any
154
+ extra call count. A new check replaces the previous result; the latest result
155
+ remains visible until then and is cleared when the session changes or shuts
156
+ down. Concurrent older checks cannot overwrite the most recently started one.
157
+
158
+ Every request still has its own model call, verdict, grant, local confirmation,
159
+ and audit record. No new review-result transcript entries are written. Existing
160
+ `pi-auto-review` entries in historical sessions remain renderable. Non-TUI
161
+ modes retain best-effort notifications, and a failed TUI widget update falls
162
+ back to the same notification path. UI delivery never changes the authorization
163
+ result.
145
164
 
146
165
  In an interactive TUI, `/auto-review-approve` lists up to ten recent
147
166
  non-critical model denials from the current session. Selecting one asks the
@@ -288,7 +307,7 @@ Writes to the installed reviewer package, its user-global configuration,
288
307
  project and global security configuration, and the global audit directory are
289
308
  deterministically denied.
290
309
 
291
- ## Telemetry and smoke testing
310
+ ## Telemetry
292
311
 
293
312
  Every actual model call emits an internal `review_attempt`; each approval emits
294
313
  one `review_complete`. Events contain stable status/error classes, timings,
@@ -297,6 +316,8 @@ prompt or response text, provider errors, credentials, headers, or URL query
297
316
  values. Usage is marked `unknown_provenance` when pi-ai cannot distinguish
298
317
  provider counters from initialized values, and `unavailable` when absent.
299
318
 
319
+ ## Real-model smoke test
320
+
300
321
  For a controlled real-model smoke test, load only the provider, reviewer,
301
322
  sandbox, and audit listener:
302
323
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.9.0",
4
- "description": "Model-backed boundary approval broker for Pi",
3
+ "version": "0.11.0",
4
+ "description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -22,6 +22,9 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
22
22
  path: request.path,
23
23
  resolvedPath: request.resolvedPath,
24
24
  destination: request.destination,
25
+ destinationHost: request.destinationHost,
26
+ destinationPort: request.destinationPort,
27
+ destinationProtocol: request.destinationProtocol,
25
28
  toolCallId: request.toolCallId,
26
29
  toolName: request.toolName,
27
30
  skillName: request.skillName,
@@ -22,6 +22,9 @@ export type BoundaryRequest = {
22
22
  path?: string;
23
23
  resolvedPath?: string;
24
24
  destination?: string;
25
+ destinationHost?: string;
26
+ destinationPort?: number;
27
+ destinationProtocol?: string;
25
28
  toolCallId?: string;
26
29
  toolName?: string;
27
30
  skillName?: string;
package/src/index.ts CHANGED
@@ -11,11 +11,12 @@ import {
11
11
  completeSimple,
12
12
  type ApiStreamSimpleFunction,
13
13
  } from "@earendil-works/pi-ai/compat";
14
- import type {
15
- AuthorizerLog,
16
- AuthorizerVerdict,
17
- PermissionQuery,
18
- PromptPermissionDetails,
14
+ import {
15
+ getPermissionsService,
16
+ type AuthorizerLog,
17
+ type AuthorizerVerdict,
18
+ type PermissionQuery,
19
+ type PromptPermissionDetails,
19
20
  } from "@gotgenes/pi-permission-system";
20
21
  import {
21
22
  buildClassifierTranscript,
@@ -37,15 +38,13 @@ import {
37
38
  } from "./broker/index.ts";
38
39
  import { PermissionUiAutoConfirmer } from "./ui-auto-confirm.ts";
39
40
  import {
40
- buildUserReviewEntryData,
41
41
  buildUserReviewNotice,
42
42
  buildUserReviewStatus,
43
+ buildUserReviewWidgetData,
43
44
  notifyUserReview,
44
- presentUserReview,
45
- ReviewEntryBatcher,
46
45
  renderUserReviewEntry,
47
46
  reviewTargetFromRequest,
48
- setUserReviewStatus,
47
+ UserReviewWidgetController,
49
48
  USER_REVIEW_ENTRY_TYPE,
50
49
  type UserReviewOutcome,
51
50
  type UserReviewUsage,
@@ -65,6 +64,8 @@ export {
65
64
  type SandboxNetworkTrap,
66
65
  type SandboxRequestContext,
67
66
  } from "./integrations/sandbox.ts";
67
+ import { parseHostPort } from "./integrations/sandbox.ts";
68
+ export { parseHostPort };
68
69
 
69
70
  type ReasoningLevel =
70
71
  | "off"
@@ -252,6 +253,7 @@ type ReviewResult = {
252
253
  durationMs: number;
253
254
  transcript: TranscriptResult;
254
255
  summary: ReviewExecutionSummary;
256
+ unavailable?: boolean;
255
257
  };
256
258
 
257
259
  class ReviewExecutionError extends Error {
@@ -726,52 +728,6 @@ type PermissionsService = {
726
728
  ): () => void;
727
729
  };
728
730
 
729
- function promptPayloadRecord(
730
- details: PromptPermissionDetails,
731
- ): Record<string, unknown> | undefined {
732
- const payload = (details as unknown as Record<string, unknown>).payload;
733
- return payload && typeof payload === "object" && !Array.isArray(payload)
734
- ? payload as Record<string, unknown>
735
- : undefined;
736
- }
737
-
738
- function promptIsForwarded(details: PromptPermissionDetails): boolean {
739
- if ((details as unknown as Record<string, unknown>).forwarding) return true;
740
- const request = promptPayloadRecord(details)?.request;
741
- if (!request || typeof request !== "object" || Array.isArray(request)) {
742
- return false;
743
- }
744
- const requester = (request as Record<string, unknown>).requester;
745
- return Boolean(
746
- requester && typeof requester === "object" && !Array.isArray(requester) &&
747
- (requester as Record<string, unknown>).forwarded === true
748
- );
749
- }
750
-
751
- function reliableFullCommand(
752
- details: PromptPermissionDetails,
753
- ): string | undefined {
754
- const payload = promptPayloadRecord(details);
755
- const evidence = payload?.evidence;
756
- if (Array.isArray(evidence)) {
757
- for (const item of evidence) {
758
- if (!item || typeof item !== "object" || Array.isArray(item)) continue;
759
- const entry = item as Record<string, unknown>;
760
- if (entry.label === "full command" && typeof entry.text === "string" &&
761
- entry.text.trim()) {
762
- return entry.text;
763
- }
764
- }
765
- }
766
- if (payload?.kind !== "bash_external_directory") return undefined;
767
- const request = payload.request;
768
- if (!request || typeof request !== "object" || Array.isArray(request)) {
769
- return undefined;
770
- }
771
- const value = (request as Record<string, unknown>).value;
772
- return typeof value === "string" && value.trim() ? value : undefined;
773
- }
774
-
775
731
  function boundaryRequest(
776
732
  ctx: ExtensionContext,
777
733
  details: PromptPermissionDetails,
@@ -795,6 +751,7 @@ function boundaryRequest(
795
751
  typeof deterministicPolicy === "string"
796
752
  ? deterministicPolicy
797
753
  : JSON.stringify(deterministicPolicy);
754
+ const destParsed = parseHostPort(evidence.destination);
798
755
  return {
799
756
  id: details.requestId,
800
757
  source: "permission-system",
@@ -805,6 +762,8 @@ function boundaryRequest(
805
762
  path: evidence.path,
806
763
  resolvedPath: evidence.resolvedPath,
807
764
  destination: evidence.destination,
765
+ destinationHost: destParsed?.host,
766
+ destinationPort: destParsed?.port,
808
767
  toolCallId:
809
768
  typeof (details as unknown as Record<string, unknown>).toolCallId ===
810
769
  "string"
@@ -1976,7 +1935,7 @@ export function createPiAutoReviewExtension(
1976
1935
  const uiAutoConfirmer = new PermissionUiAutoConfirmer(
1977
1936
  () => config.autoConfirmBoundedAllows,
1978
1937
  );
1979
- const reviewEntryBatcher = new ReviewEntryBatcher(pi);
1938
+ const reviewWidget = new UserReviewWidgetController();
1980
1939
 
1981
1940
  const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
1982
1941
  writeOptionalAuditFile(event);
@@ -2032,6 +1991,7 @@ export function createPiAutoReviewExtension(
2032
1991
  durationMs: execution.summary.durationMs,
2033
1992
  transcript: execution.summary.transcript,
2034
1993
  summary: execution.summary,
1994
+ unavailable: true,
2035
1995
  });
2036
1996
  }
2037
1997
  emitTelemetry(
@@ -2314,7 +2274,7 @@ export function createPiAutoReviewExtension(
2314
2274
  disposeAuthorizer?.();
2315
2275
  disposeAuthorizer = undefined;
2316
2276
  registeredSessionId = undefined;
2317
- reviewEntryBatcher.flushAll();
2277
+ reviewWidget.clear(context ?? ctx);
2318
2278
  disposeBrokerService?.();
2319
2279
  broker?.clear();
2320
2280
  reviewResults.clear();
@@ -2359,18 +2319,8 @@ export function createPiAutoReviewExtension(
2359
2319
  });
2360
2320
 
2361
2321
  pi.events.on("permissions:decision", (event) => {
2362
- reviewEntryBatcher.permissionDecision(event);
2363
- });
2364
-
2365
- pi.on("tool_execution_start", (event, ctx) => {
2366
- reviewEntryBatcher.toolExecutionStarted(
2367
- ctx.sessionManager.getSessionId(),
2368
- event.toolCallId,
2369
- event.args,
2370
- );
2322
+ reviewWidget.permissionDecision(event);
2371
2323
  });
2372
- pi.on("turn_end", () => reviewEntryBatcher.flushAll());
2373
- pi.on("agent_end", () => reviewEntryBatcher.flushAll());
2374
2324
 
2375
2325
  pi.events.on("permissions:ready", (event) => {
2376
2326
  const ready = event && typeof event === "object" && !Array.isArray(event)
@@ -2395,20 +2345,19 @@ export function createPiAutoReviewExtension(
2395
2345
  registeredSessionId = undefined;
2396
2346
  }
2397
2347
 
2398
- void import("@gotgenes/pi-permission-system").then((module) => {
2399
- if (shuttingDown || epoch !== registrationEpoch || !context) return;
2400
- const service = module.getPermissionsService(sessionId) as
2401
- | PermissionsService
2402
- | undefined;
2403
- if (!service) {
2404
- console.error(
2405
- `${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
2406
- );
2407
- return;
2408
- }
2409
- let dispose: (() => void) | undefined;
2410
- try {
2411
- dispose = service.registerAuthorizer(
2348
+ if (shuttingDown || epoch !== registrationEpoch || !context) return;
2349
+ const service = getPermissionsService(sessionId) as
2350
+ | PermissionsService
2351
+ | undefined;
2352
+ if (!service) {
2353
+ console.error(
2354
+ `${EXTENSION_NAME}: permissions service unavailable for session ${sessionId}`,
2355
+ );
2356
+ return;
2357
+ }
2358
+ let dispose: (() => void) | undefined;
2359
+ try {
2360
+ dispose = service.registerAuthorizer(
2412
2361
  EXTENSION_NAME,
2413
2362
  async (details, query, log: AuthorizerLog) => {
2414
2363
  const evidence = normalizePermissionEvidence(details);
@@ -2425,185 +2374,152 @@ export function createPiAutoReviewExtension(
2425
2374
  surface,
2426
2375
  rationale: reason,
2427
2376
  };
2428
- presentUserReview(
2429
- pi,
2430
- context,
2431
- buildUserReviewNotice(unavailable),
2432
- buildUserReviewEntryData(unavailable),
2433
- );
2377
+ notifyUserReview(context, buildUserReviewNotice(unavailable));
2434
2378
  return { kind: "deny", reason };
2435
2379
  }
2436
2380
 
2437
2381
  const request = boundaryRequest(context, details, query);
2438
2382
  const target = reviewTargetFromRequest(request);
2439
- setUserReviewStatus(
2440
- context,
2441
- buildUserReviewStatus(surface, target),
2383
+ const reviewContext = context;
2384
+ const widgetGeneration = reviewWidget.begin(request.id, reviewContext, {
2385
+ surface,
2386
+ target,
2387
+ model: config.model,
2388
+ });
2389
+ const decision = await broker.review(request, {
2390
+ sessionId: reviewContext.sessionManager.getSessionId(),
2391
+ scopeKey: currentTurnScope(reviewContext),
2392
+ issueGrant: false,
2393
+ });
2394
+ const result = reviewResults.get(request.id);
2395
+ reviewResults.delete(request.id);
2396
+ const allowCapped =
2397
+ decision.kind === "allow" && boundedRequest(surface);
2398
+ const autoConfirmQueued =
2399
+ allowCapped &&
2400
+ reviewContext.mode === "tui" &&
2401
+ reviewContext.hasUI &&
2402
+ uiAutoConfirmer.stage(request.id, surface);
2403
+
2404
+ let userOutcome: UserReviewOutcome;
2405
+ if (decision.kind === "deny" && decision.circuitBreakerTripped) {
2406
+ userOutcome = "circuit_breaker";
2407
+ } else if (allowCapped && autoConfirmQueued) {
2408
+ userOutcome = "auto_confirm";
2409
+ } else if (allowCapped) {
2410
+ userOutcome = "needs_confirmation";
2411
+ } else if (decision.kind === "allow") {
2412
+ userOutcome = "allow";
2413
+ } else if (decision.kind === "defer") {
2414
+ userOutcome = "defer";
2415
+ } else {
2416
+ userOutcome = "deny";
2417
+ }
2418
+ const reviewMeta = userReviewMetaFromResult(result, config.model);
2419
+ const noticeInput = {
2420
+ outcome: result?.unavailable ? "unavailable" as const : userOutcome,
2421
+ surface,
2422
+ target,
2423
+ rationale: decision.review.rationale,
2424
+ recoveryCommand:
2425
+ decision.kind === "deny"
2426
+ ? decision.recoveryCommand
2427
+ : undefined,
2428
+ ...reviewMeta,
2429
+ };
2430
+ const notice = buildUserReviewNotice(noticeInput);
2431
+ reviewWidget.complete(
2432
+ request.id,
2433
+ widgetGeneration,
2434
+ reviewContext,
2435
+ notice,
2436
+ buildUserReviewWidgetData(noticeInput),
2442
2437
  );
2443
- try {
2444
- const decision = await broker.review(request, {
2445
- sessionId: context.sessionManager.getSessionId(),
2446
- scopeKey: currentTurnScope(context),
2447
- issueGrant: false,
2448
- });
2449
- const result = reviewResults.get(request.id);
2450
- reviewResults.delete(request.id);
2451
- const allowCapped =
2452
- decision.kind === "allow" && boundedRequest(surface);
2453
- const autoConfirmQueued =
2454
- allowCapped &&
2455
- context.mode === "tui" &&
2456
- context.hasUI &&
2457
- uiAutoConfirmer.stage(request.id, surface);
2458
-
2459
- let userOutcome: UserReviewOutcome;
2460
- if (decision.kind === "deny" && decision.circuitBreakerTripped) {
2461
- userOutcome = "circuit_breaker";
2462
- } else if (allowCapped && autoConfirmQueued) {
2463
- userOutcome = "auto_confirm";
2464
- } else if (allowCapped) {
2465
- userOutcome = "needs_confirmation";
2466
- } else if (decision.kind === "allow") {
2467
- userOutcome = "allow";
2468
- } else if (decision.kind === "defer") {
2469
- userOutcome = "defer";
2470
- } else {
2471
- userOutcome = "deny";
2472
- }
2473
- const reviewMeta = userReviewMetaFromResult(result, config.model);
2474
- const noticeInput = {
2475
- outcome: userOutcome,
2476
- surface,
2477
- target,
2478
- rationale: decision.review.rationale,
2479
- recoveryCommand:
2480
- decision.kind === "deny"
2481
- ? decision.recoveryCommand
2482
- : undefined,
2483
- ...reviewMeta,
2484
- };
2485
- const notice = buildUserReviewNotice(noticeInput);
2486
- const entryData = buildUserReviewEntryData(noticeInput);
2487
- const localSessionId = context.sessionManager.getSessionId();
2488
- if (
2489
- context.mode === "tui" &&
2490
- context.hasUI &&
2491
- request.toolCallId &&
2492
- localSessionId &&
2493
- !promptIsForwarded(details)
2494
- ) {
2495
- reviewEntryBatcher.enqueue({
2496
- sessionId: localSessionId,
2497
- toolCallId: request.toolCallId,
2498
- fullCommand: reliableFullCommand(details),
2499
- member: {
2500
- requestId: request.id,
2501
- ...noticeInput,
2502
- },
2503
- notice,
2504
- data: entryData,
2505
- ctx: context,
2506
- });
2507
- } else {
2508
- presentUserReview(pi, context, notice, entryData);
2509
- }
2510
-
2511
- log.review("pi_auto_review_decision", {
2512
- requestId: request.id,
2513
- toolCallId: request.toolCallId,
2514
- surface,
2515
- model: config.model,
2516
- reviewerModel: reviewMeta.model,
2517
- outcome: allowCapped ? "defer" : decision.kind,
2518
- reviewerOutcome: decision.review.outcome,
2519
- riskLevel: decision.review.riskLevel,
2520
- userAuthorization: decision.review.userAuthorization,
2521
- rationale: decision.review.rationale,
2522
- allowCapped,
2523
- autoConfirmQueued,
2524
- userOutcome,
2525
- circuitBreakerTripped:
2526
- decision.kind === "deny"
2527
- ? decision.circuitBreakerTripped
2528
- : false,
2529
- attempts: result?.attempts ?? 0,
2530
- retryErrors: result?.retryErrors ?? [],
2531
- durationMs: result?.durationMs,
2532
- usageAvailability: reviewMeta.usage?.availability,
2533
- usage: reviewMeta.usage,
2534
- transcriptUserCharacters: result?.transcript.userCharacters,
2535
- transcriptToolCharacters: result?.transcript.toolCharacters,
2536
- transcriptRelevantResultCharacters:
2537
- result?.transcript.relevantResultCharacters,
2538
- transcriptTruncated: result?.transcript.truncated,
2539
- command: request.command,
2540
- path: request.path,
2541
- resolvedPath: request.resolvedPath,
2542
- destination: request.destination,
2543
- agentName: request.agentName,
2544
- requesterSessionId: request.requesterSessionId,
2545
- accessIntent: request.accessIntent,
2546
- authorization:
2547
- decision.kind === "allow"
2548
- ? decision.authorization
2549
- : undefined,
2550
- });
2551
2438
 
2552
- if (allowCapped || decision.kind === "defer") {
2553
- return { kind: "defer" };
2554
- }
2555
- if (decision.kind === "allow") return { kind: "allow" };
2556
- const denyInstruction =
2557
- decision.denialSource === "hard-deny"
2558
- ? LOCAL_HARD_DENY_AGENT_INSTRUCTION
2559
- : decision.recoveryCommand === "/auto-review-break-glass"
2560
- ? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
2561
- : decision.recoveryCommand === "/auto-review-approve"
2562
- ? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
2563
- : "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
2564
- return {
2565
- kind: "deny",
2566
- reason: `${decision.review.rationale} ${denyInstruction}`,
2567
- };
2568
- } finally {
2569
- setUserReviewStatus(context, undefined);
2439
+ log.review("pi_auto_review_decision", {
2440
+ requestId: request.id,
2441
+ toolCallId: request.toolCallId,
2442
+ surface,
2443
+ model: config.model,
2444
+ reviewerModel: reviewMeta.model,
2445
+ outcome: allowCapped ? "defer" : decision.kind,
2446
+ reviewerOutcome: decision.review.outcome,
2447
+ riskLevel: decision.review.riskLevel,
2448
+ userAuthorization: decision.review.userAuthorization,
2449
+ rationale: decision.review.rationale,
2450
+ allowCapped,
2451
+ autoConfirmQueued,
2452
+ userOutcome,
2453
+ circuitBreakerTripped:
2454
+ decision.kind === "deny"
2455
+ ? decision.circuitBreakerTripped
2456
+ : false,
2457
+ attempts: result?.attempts ?? 0,
2458
+ retryErrors: result?.retryErrors ?? [],
2459
+ durationMs: result?.durationMs,
2460
+ usageAvailability: reviewMeta.usage?.availability,
2461
+ usage: reviewMeta.usage,
2462
+ transcriptUserCharacters: result?.transcript.userCharacters,
2463
+ transcriptToolCharacters: result?.transcript.toolCharacters,
2464
+ transcriptRelevantResultCharacters:
2465
+ result?.transcript.relevantResultCharacters,
2466
+ transcriptTruncated: result?.transcript.truncated,
2467
+ command: request.command,
2468
+ path: request.path,
2469
+ resolvedPath: request.resolvedPath,
2470
+ destination: request.destination,
2471
+ agentName: request.agentName,
2472
+ requesterSessionId: request.requesterSessionId,
2473
+ accessIntent: request.accessIntent,
2474
+ authorization:
2475
+ decision.kind === "allow"
2476
+ ? decision.authorization
2477
+ : undefined,
2478
+ });
2479
+
2480
+ if (allowCapped || decision.kind === "defer") {
2481
+ return { kind: "defer" };
2570
2482
  }
2571
- },
2572
- );
2573
- if (shuttingDown || epoch !== registrationEpoch || !context) {
2574
- dispose();
2575
- return;
2576
- }
2577
- disposeAuthorizer?.();
2578
- disposeAuthorizer = dispose;
2579
- registeredSessionId = sessionId;
2580
- writeOptionalAuditFile({
2581
- type: "authorizer_registered",
2582
- sessionId,
2583
- });
2584
- } catch (error) {
2585
- dispose?.();
2586
- console.error(
2587
- `${EXTENSION_NAME}: authorizer registration failed: ${
2588
- error instanceof Error ? error.message : String(error)
2589
- }`,
2590
- );
2483
+ if (decision.kind === "allow") return { kind: "allow" };
2484
+ const denyInstruction =
2485
+ decision.denialSource === "hard-deny"
2486
+ ? LOCAL_HARD_DENY_AGENT_INSTRUCTION
2487
+ : decision.recoveryCommand === "/auto-review-break-glass"
2488
+ ? REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION
2489
+ : decision.recoveryCommand === "/auto-review-approve"
2490
+ ? REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION
2491
+ : "Automatic policy critically denied this action and break-glass authorization is disabled. Do not retry, rephrase, or circumvent it.";
2492
+ return {
2493
+ kind: "deny",
2494
+ reason: `${decision.review.rationale} ${denyInstruction}`,
2495
+ };
2496
+ },
2497
+ );
2498
+ if (shuttingDown || epoch !== registrationEpoch || !context) {
2499
+ dispose();
2500
+ return;
2591
2501
  }
2592
- }).catch((error) => {
2593
- if (shuttingDown || epoch !== registrationEpoch) return;
2502
+ disposeAuthorizer?.();
2503
+ disposeAuthorizer = dispose;
2504
+ registeredSessionId = sessionId;
2505
+ writeOptionalAuditFile({
2506
+ type: "authorizer_registered",
2507
+ sessionId,
2508
+ });
2509
+ } catch (error) {
2510
+ dispose?.();
2594
2511
  console.error(
2595
- `${EXTENSION_NAME}: permission-system import failed: ${
2512
+ `${EXTENSION_NAME}: authorizer registration failed: ${
2596
2513
  error instanceof Error ? error.message : String(error)
2597
2514
  }`,
2598
2515
  );
2599
- });
2516
+ }
2600
2517
  });
2601
2518
 
2602
2519
  pi.on("session_shutdown", () => {
2603
2520
  shuttingDown = true;
2604
2521
  registrationEpoch++;
2605
- setUserReviewStatus(context, undefined);
2606
- reviewEntryBatcher.flushAll();
2522
+ reviewWidget.clear(context);
2607
2523
  disposeAuthorizer?.();
2608
2524
  disposeAuthorizer = undefined;
2609
2525
  registeredSessionId = undefined;
@@ -36,6 +36,21 @@ export type SandboxRequestContext = {
36
36
  agentName?: string;
37
37
  };
38
38
 
39
+ export function parseHostPort(target: string | undefined): {
40
+ host: string;
41
+ port: number | undefined;
42
+ } | undefined {
43
+ if (!target) return undefined;
44
+ const lastColon = target.lastIndexOf(":");
45
+ if (lastColon <= 0) return { host: target, port: undefined };
46
+ const portStr = target.slice(lastColon + 1);
47
+ const port = Number(portStr);
48
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
49
+ return { host: target, port: undefined };
50
+ }
51
+ return { host: target.slice(0, lastColon), port };
52
+ }
53
+
39
54
  export function sandboxTrapToBoundaryRequest(
40
55
  trap: SandboxBoundaryTrap,
41
56
  context: SandboxRequestContext,
@@ -66,6 +81,7 @@ export function sandboxTrapToBoundaryRequest(
66
81
  },
67
82
  };
68
83
  }
84
+ const parsed = parseHostPort(trap.target);
69
85
  return {
70
86
  id,
71
87
  source: "sandbox-runtime",
@@ -74,6 +90,8 @@ export function sandboxTrapToBoundaryRequest(
74
90
  cwd: trap.process?.cwd || context.cwd,
75
91
  command: context.command,
76
92
  destination: trap.target,
93
+ destinationHost: parsed?.host,
94
+ destinationPort: parsed?.port,
77
95
  toolName: trap.process?.exe || undefined,
78
96
  agentName: context.agentName,
79
97
  matchedPolicy: { decision: "ask" },
package/src/policy.ts CHANGED
@@ -156,6 +156,9 @@ export type RelevantBoundaryRequest = {
156
156
  path?: string;
157
157
  resolvedPath?: string;
158
158
  destination?: string;
159
+ destinationHost?: string;
160
+ destinationPort?: number;
161
+ destinationProtocol?: string;
159
162
  toolCallId?: string;
160
163
  toolName?: string;
161
164
  toolInputPreview?: string;
@@ -1098,6 +1101,9 @@ function toolArgumentCoveredByRequest(
1098
1101
  path: request.path,
1099
1102
  resolvedPath: request.resolvedPath,
1100
1103
  destination: request.destination,
1104
+ destinationHost: request.destinationHost,
1105
+ destinationPort: request.destinationPort,
1106
+ destinationProtocol: request.destinationProtocol,
1101
1107
  cwd: request.cwd,
1102
1108
  agentName: request.agentName,
1103
1109
  requesterSessionId: request.requesterSessionId,
@@ -1,10 +1,8 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
2
 
6
3
  export const USER_REVIEW_STATUS_KEY = "pi-auto-review";
7
4
  export const USER_REVIEW_ENTRY_TYPE = "pi-auto-review";
5
+ export const USER_REVIEW_WIDGET_KEY = "pi-auto-review";
8
6
 
9
7
  export type UserReviewOutcome =
10
8
  | "allow"
@@ -45,6 +43,14 @@ export function compactReviewText(value: unknown, max = 90): string {
45
43
  .slice(0, max);
46
44
  }
47
45
 
46
+ /** Collapse whitespace and visibly truncate bounded widget text. */
47
+ export function truncateReviewText(value: unknown, max: number): string {
48
+ const compact = String(value ?? "").replace(/\s+/g, " ").trim();
49
+ if (compact.length <= max) return compact;
50
+ if (max <= 1) return "…".slice(0, max);
51
+ return `${compact.slice(0, max - 1).trimEnd()}…`;
52
+ }
53
+
48
54
  export function reviewTargetFromRequest(request: {
49
55
  resolvedPath?: string;
50
56
  path?: string;
@@ -62,7 +68,7 @@ export function reviewTargetFromRequest(request: {
62
68
  request.toolName ??
63
69
  request.skillName ??
64
70
  request.operation;
65
- const compact = compactReviewText(target, 100);
71
+ const compact = truncateReviewText(target, 100);
66
72
  return compact || undefined;
67
73
  }
68
74
 
@@ -197,8 +203,8 @@ function reviewTargetRationaleLine(input: {
197
203
  target?: string;
198
204
  rationale?: string;
199
205
  }): string | undefined {
200
- const target = compactReviewText(input.target, 100);
201
- const rationale = compactReviewText(input.rationale, 180);
206
+ const target = truncateReviewText(input.target, 100);
207
+ const rationale = truncateReviewText(input.rationale, 180);
202
208
  if (target && rationale) return `${target} · ${rationale}`;
203
209
  return target || rationale || undefined;
204
210
  }
@@ -259,8 +265,10 @@ function outcomeAccent(
259
265
  case "auto_confirm":
260
266
  case "needs_confirmation":
261
267
  return "success";
268
+ // Deferred also warns: it awaits a human decision, so it must be clearly
269
+ // visible (theme warning) and stand out until resolved.
262
270
  case "defer":
263
- return "muted";
271
+ return "warning";
264
272
  case "deny":
265
273
  case "circuit_breaker":
266
274
  return "warning";
@@ -400,7 +408,6 @@ export function renderUserReviewQuoteLines(
400
408
  const contentWidth = Math.max(1, width);
401
409
  const verb = outcomeVerb(data.outcome);
402
410
  const verbAnsi = theme.getFgAnsi(outcomeAccent(data.outcome));
403
- const numberAnsi = theme.getFgAnsi("success");
404
411
  const rendered: string[] = [];
405
412
  for (const [index, line] of data.lines.entries()) {
406
413
  for (const visual of wrapReviewDisplayText(line, contentWidth)) {
@@ -411,12 +418,6 @@ export function renderUserReviewQuoteLines(
411
418
  `${verbAnsi}${verb}\x1b[0m`,
412
419
  );
413
420
  }
414
- if (index === data.lines.length - 1) {
415
- painted = painted.replace(
416
- /(\d+(?:\.\d+)?[kM]?)(?=\s(?:toks|calls)|ms\b|s\b)/g,
417
- `${numberAnsi}$1\x1b[0m`,
418
- );
419
- }
420
421
  rendered.push(withQuoteStyle(theme, painted));
421
422
  }
422
423
  }
@@ -528,34 +529,6 @@ export function renderUserReviewEntry(
528
529
  };
529
530
  }
530
531
 
531
- function presentUserReviewGroupEntry(
532
- pi: ExtensionAPI | undefined,
533
- ctx: ExtensionContext | undefined,
534
- data: UserReviewGroupEntryData,
535
- ): boolean {
536
- if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
537
- try {
538
- pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
539
- return true;
540
- } catch {
541
- return false;
542
- }
543
- }
544
-
545
- function presentUserReviewEntry(
546
- pi: ExtensionAPI | undefined,
547
- ctx: ExtensionContext | undefined,
548
- data: UserReviewEntryData,
549
- ): boolean {
550
- if (!pi || !ctx?.hasUI || ctx.mode !== "tui") return false;
551
- try {
552
- pi.appendEntry(USER_REVIEW_ENTRY_TYPE, data);
553
- return true;
554
- } catch {
555
- return false;
556
- }
557
- }
558
-
559
532
  /** Best-effort toast; never throws into the authorizer path. */
560
533
  export function notifyUserReview(
561
534
  ctx: ExtensionContext | undefined,
@@ -569,184 +542,160 @@ export function notifyUserReview(
569
542
  }
570
543
  }
571
544
 
572
- /** TUI structured entry when possible; otherwise a plain notify toast. */
573
- export function presentUserReview(
574
- pi: ExtensionAPI | undefined,
575
- ctx: ExtensionContext | undefined,
576
- notice: UserReviewNotice,
577
- data: UserReviewEntryData,
578
- ): void {
579
- if (presentUserReviewEntry(pi, ctx, data)) return;
580
- notifyUserReview(ctx, notice);
581
- }
582
-
583
- type PendingReviewGroup = {
584
- sessionId: string;
585
- toolCallId: string;
586
- fullCommand?: string;
587
- members: Map<string, {
588
- member: UserReviewGroupMember;
589
- notice: UserReviewNotice;
590
- data: UserReviewEntryData;
591
- }>;
592
- ctx: ExtensionContext;
593
- createdAt: number;
594
- timer: ReturnType<typeof setTimeout>;
545
+ export type UserReviewWidgetData = UserReviewEntryData & {
546
+ phase: "reviewing" | "complete";
595
547
  };
596
548
 
597
- export type ReviewEntryBatcherOptions = {
598
- maxGroups?: number;
599
- maxMembers?: number;
600
- ttlMs?: number;
601
- now?: () => number;
602
- };
549
+ export function buildUserReviewingWidgetData(input: {
550
+ surface: string;
551
+ target?: string;
552
+ model?: string;
553
+ }): UserReviewWidgetData {
554
+ const target = truncateReviewText(input.target, 100);
555
+ const model = formatReviewModelName(input.model) ?? "reviewer";
556
+ return {
557
+ phase: "reviewing",
558
+ outcome: "defer",
559
+ type: "info",
560
+ lines: joinNoticeLines([
561
+ `Auto-review · reviewing · ${input.surface}`,
562
+ target,
563
+ `Waiting for ${model}…`,
564
+ ]),
565
+ };
566
+ }
603
567
 
604
- /** Buffers only transcript presentation; permission requests remain independent. */
605
- export class ReviewEntryBatcher {
606
- readonly #groups = new Map<string, PendingReviewGroup>();
607
- readonly #maxGroups: number;
608
- readonly #maxMembers: number;
609
- readonly #ttlMs: number;
610
- readonly #now: () => number;
611
-
612
- constructor(
613
- private readonly pi: ExtensionAPI,
614
- options: ReviewEntryBatcherOptions = {},
615
- ) {
616
- this.#maxGroups = options.maxGroups ?? 32;
617
- this.#maxMembers = options.maxMembers ?? 8;
618
- this.#ttlMs = options.ttlMs ?? 60_000;
619
- this.#now = options.now ?? Date.now;
620
- }
568
+ export function buildUserReviewWidgetData(
569
+ input: UserReviewNoticeInput,
570
+ ): UserReviewWidgetData {
571
+ return {
572
+ phase: "complete",
573
+ ...buildUserReviewEntryData(input),
574
+ };
575
+ }
621
576
 
622
- enqueue(input: {
623
- sessionId: string;
624
- toolCallId: string;
625
- fullCommand?: string;
626
- member: UserReviewGroupMember;
627
- notice: UserReviewNotice;
628
- data: UserReviewEntryData;
629
- ctx: ExtensionContext;
630
- }): void {
631
- try {
632
- const key = this.#key(input.sessionId, input.toolCallId);
633
- let group = this.#groups.get(key);
634
- if (group?.members.has(input.member.requestId)) return;
635
- if (group && group.members.size >= this.#maxMembers) {
636
- this.#flushKey(key);
637
- group = undefined;
638
- }
639
- if (!group) {
640
- if (this.#groups.size >= this.#maxGroups) {
641
- const oldest = [...this.#groups.entries()].sort(
642
- (left, right) => left[1].createdAt - right[1].createdAt,
643
- )[0]?.[0];
644
- if (oldest) this.#flushKey(oldest);
645
- }
646
- const timer = setTimeout(() => this.#flushKey(key), this.#ttlMs);
647
- timer.unref?.();
648
- group = {
649
- sessionId: input.sessionId,
650
- toolCallId: input.toolCallId,
651
- fullCommand: input.fullCommand,
652
- members: new Map(),
653
- ctx: input.ctx,
654
- createdAt: this.#now(),
655
- timer,
656
- };
657
- this.#groups.set(key, group);
658
- } else if (!group.fullCommand && input.fullCommand) {
659
- group.fullCommand = input.fullCommand;
660
- }
661
- group.members.set(input.member.requestId, {
662
- member: input.member,
663
- notice: input.notice,
664
- data: input.data,
665
- });
666
- if (input.member.outcome === "deny" ||
667
- input.member.outcome === "circuit_breaker") {
668
- this.#flushKey(key);
577
+ export function renderUserReviewWidgetLines(
578
+ data: UserReviewWidgetData,
579
+ theme: UserReviewTheme,
580
+ width: number,
581
+ ): string[] {
582
+ const rendered: string[] = [];
583
+ const verb = data.phase === "reviewing"
584
+ ? "reviewing"
585
+ : outcomeVerb(data.outcome);
586
+ const accent = data.phase === "reviewing"
587
+ ? "muted"
588
+ : outcomeAccent(data.outcome);
589
+ const verbAnsi = theme.getFgAnsi(accent);
590
+ const mutedAnsi = theme.getFgAnsi("muted");
591
+ for (const [index, line] of data.lines.entries()) {
592
+ for (const visual of wrapReviewDisplayText(line, Math.max(1, width))) {
593
+ let painted = `${mutedAnsi}${visual}\x1b[0m`;
594
+ if (index === 0) {
595
+ painted = painted.replace(
596
+ verb,
597
+ `${verbAnsi}${verb}\x1b[0m${mutedAnsi}`,
598
+ );
669
599
  }
670
- } catch {
671
- presentUserReview(this.pi, input.ctx, input.notice, input.data);
600
+ rendered.push(painted);
672
601
  }
673
602
  }
603
+ return rendered;
604
+ }
605
+
606
+ function setWidget(
607
+ ctx: ExtensionContext | undefined,
608
+ data: UserReviewWidgetData | undefined,
609
+ ): boolean {
610
+ if (!ctx?.hasUI || ctx.mode !== "tui") return false;
611
+ try {
612
+ ctx.ui.setWidget(
613
+ USER_REVIEW_WIDGET_KEY,
614
+ data
615
+ ? (_tui, theme) => ({
616
+ render: (width: number) =>
617
+ renderUserReviewWidgetLines(data, theme, width),
618
+ invalidate() {},
619
+ })
620
+ : undefined,
621
+ { placement: "aboveEditor" },
622
+ );
623
+ return true;
624
+ } catch {
625
+ return false;
626
+ }
627
+ }
674
628
 
675
- toolExecutionStarted(
676
- sessionId: string,
677
- toolCallId: string,
678
- args: unknown,
629
+ /** Owns the single live widget and rejects stale concurrent completions. */
630
+ export class UserReviewWidgetController {
631
+ #generation = 0;
632
+ #current?: {
633
+ requestId: string;
634
+ generation: number;
635
+ ctx: ExtensionContext;
636
+ data?: UserReviewWidgetData;
637
+ notice?: UserReviewNotice;
638
+ };
639
+
640
+ begin(
641
+ requestId: string,
642
+ ctx: ExtensionContext,
643
+ input: { surface: string; target?: string; model?: string },
644
+ ): number {
645
+ const generation = ++this.#generation;
646
+ this.#current = { requestId, generation, ctx };
647
+ setWidget(ctx, buildUserReviewingWidgetData(input));
648
+ return generation;
649
+ }
650
+
651
+ complete(
652
+ requestId: string,
653
+ generation: number,
654
+ ctx: ExtensionContext,
655
+ notice: UserReviewNotice,
656
+ data: UserReviewWidgetData,
679
657
  ): void {
680
- const key = this.#key(sessionId, toolCallId);
681
- const group = this.#groups.get(key);
682
- if (!group) return;
683
- if (!group.fullCommand && args && typeof args === "object" &&
684
- !Array.isArray(args) &&
685
- typeof (args as Record<string, unknown>).command === "string") {
686
- group.fullCommand = String((args as Record<string, unknown>).command);
658
+ if (!ctx.hasUI || ctx.mode !== "tui") {
659
+ notifyUserReview(ctx, notice);
660
+ return;
687
661
  }
688
- this.#flushKey(key);
662
+ if (!this.#isCurrent(requestId, generation)) return;
663
+ this.#current = { requestId, generation, ctx, notice, data };
664
+ if (!setWidget(ctx, data)) notifyUserReview(ctx, notice);
689
665
  }
690
666
 
691
667
  permissionDecision(event: unknown): void {
692
668
  if (!event || typeof event !== "object" || Array.isArray(event)) return;
693
669
  const record = event as Record<string, unknown>;
694
- if (typeof record.requestId !== "string" ||
695
- (record.result !== "allow" && record.result !== "deny")) return;
696
- for (const [key, group] of this.#groups) {
697
- const pending = group.members.get(record.requestId);
698
- if (!pending) continue;
699
- pending.member.permissionResult = record.result;
700
- if (record.result === "deny") this.#flushKey(key);
701
- return;
702
- }
703
- }
704
-
705
- flushAll(): void {
706
- for (const key of [...this.#groups.keys()]) this.#flushKey(key);
707
- }
708
-
709
- get pendingGroups(): number {
710
- return this.#groups.size;
711
- }
712
-
713
- #key(sessionId: string, toolCallId: string): string {
714
- return `${sessionId}\u0000${toolCallId}`;
670
+ const current = this.#current;
671
+ if (
672
+ record.result !== "deny" ||
673
+ typeof record.requestId !== "string" ||
674
+ record.requestId !== current?.requestId ||
675
+ !current.data ||
676
+ current.data.lines.includes("Local confirmation · denied")
677
+ ) return;
678
+ const data = {
679
+ ...current.data,
680
+ lines: [...current.data.lines, "Local confirmation · denied"],
681
+ };
682
+ const notice = {
683
+ type: current.notice?.type ?? data.type,
684
+ message: data.lines.join("\n"),
685
+ };
686
+ this.#current = { ...current, data, notice };
687
+ if (!setWidget(current.ctx, data)) notifyUserReview(current.ctx, notice);
715
688
  }
716
689
 
717
- #flushKey(key: string): void {
718
- const group = this.#groups.get(key);
719
- if (!group) return;
720
- this.#groups.delete(key);
721
- clearTimeout(group.timer);
722
- const pending = [...group.members.values()];
723
- if (pending.length === 0) return;
724
- const members = pending.map((entry) => entry.member);
725
- const data: UserReviewGroupEntryData = {
726
- kind: "group",
727
- type: noticeType(groupOutcome(members)),
728
- sessionId: group.sessionId,
729
- toolCallId: group.toolCallId,
730
- ...(group.fullCommand ? { fullCommand: group.fullCommand } : {}),
731
- members,
732
- };
733
- if (presentUserReviewGroupEntry(this.pi, group.ctx, data)) return;
734
- for (const entry of pending) {
735
- presentUserReview(this.pi, group.ctx, entry.notice, entry.data);
736
- }
690
+ clear(ctx?: ExtensionContext): void {
691
+ ++this.#generation;
692
+ const current = this.#current;
693
+ this.#current = undefined;
694
+ if (current) setWidget(ctx ?? current.ctx, undefined);
737
695
  }
738
- }
739
696
 
740
- /** Best-effort footer status; pass undefined to clear. */
741
- export function setUserReviewStatus(
742
- ctx: ExtensionContext | undefined,
743
- text: string | undefined,
744
- key = USER_REVIEW_STATUS_KEY,
745
- ): void {
746
- if (!ctx?.hasUI) return;
747
- try {
748
- ctx.ui.setStatus?.(key, text);
749
- } catch {
750
- // UI delivery is observational and must not change a decision.
697
+ #isCurrent(requestId: string, generation: number): boolean {
698
+ return this.#current?.requestId === requestId &&
699
+ this.#current.generation === generation;
751
700
  }
752
701
  }