@erichll/pi-auto-review 0.14.0 → 0.15.1

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,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.1 - 2026-08-31
4
+
5
+ - Adapt the policy audit to `@gotgenes/pi-permission-system` v28.0.0. Ask
6
+ decisions made by a registered authorizer chain link — including
7
+ pi-auto-review's own model review — now broadcast `authorizer_allowed` /
8
+ `authorizer_denied` instead of `user_approved` / `user_denied`. The audit
9
+ whitelist records both new values verbatim instead of collapsing them into
10
+ `unknown`, and approval sources surface them under their own name.
11
+ - Keep suggestion evidence strictly human/session-based: `authorizer_allowed`
12
+ deliberately does not join `SUCCESS_RESOLUTIONS`, because counting the
13
+ extension's own machine review as evidence would create a feedback loop
14
+ (model review allows → evidence → suggested auto-allow rule → future review
15
+ bypassed). Authorizer-resolved rows still count as "reviewed" for the
16
+ display-only low-risk candidate list so those surfaces stay visible.
17
+ - Bump the `@gotgenes/pi-permission-system` peer range to `^28.0.0`.
18
+
19
+ ## 0.15.0 - 2026-08-29
20
+
21
+ - Fix a deterministic reviewer-unavailable denial for large CJK tool inputs.
22
+ The reviewer input estimator counted one token per UTF-8 byte, tripling the
23
+ estimate for CJK text, so multi-kilobyte Chinese payloads (e.g. a long plan
24
+ document submitted through a tool call) synchronously failed the
25
+ `reviewer_input_budget_exceeded` preflight before the reviewer model was
26
+ ever called, and the request was denied as "Automatic review is
27
+ unavailable." CJK code points now estimate at one token each and remaining
28
+ code points at their UTF-8 byte length over three (estimator
29
+ `conservative:cjk-aware`).
30
+ - Record the denial when a reviewer-unavailable review fails closed, so
31
+ `/auto-review-approve` — the recovery command that verdict recommends —
32
+ actually has a matching denial to approve. Previously the recommendation
33
+ was dead text for this failure class.
34
+ - Let an explicit human override (`/auto-review-approve` trusted exact retry)
35
+ bypass the reviewer input-budget preflight failure. A sizing estimate is
36
+ not a safety verdict and must not veto an already-authorized retry; the
37
+ review proceeds with truncated evidence.
38
+ - Show "waiting for you" in the review widget while a `ctx.ui` prompt blocks
39
+ the session during an active review (pi >= 0.84.4 `ui_prompt_start` /
40
+ `ui_prompt_end`). The widget previously kept claiming "Waiting for
41
+ <model>…" while pi was actually waiting on the user (permission dialog,
42
+ `/review-denials`, break-glass challenge). Completed outcome summaries and
43
+ sessions without a live widget are unaffected.
44
+ - Fix `/auto-review-approve` and `/auto-review-break-glass` never applying in
45
+ real TUI sessions. The exact-match request hash included `toolCallId`, but a
46
+ retry is always a brand-new model tool call with a fresh tool call ID, so
47
+ the authorization could never match the retried request. The hash now
48
+ excludes both retry-minted identifiers (`id` was already excluded).
49
+ - Fix break-glass authorization being discarded on every retry. The stored
50
+ authorization was bound to the denial-time turn scope, but the command flow
51
+ appends a user message (`pi.sendUserMessage`) before the agent retries, so
52
+ the retry always lands in a later turn whose scope differs. Consumption now
53
+ binds on session and request hash plus the confirmation TTL instead of the
54
+ turn scope. The non-critical approve path already behaved this way.
55
+ - Reset the turn circuit breaker when a break-glass allow is consumed, so the
56
+ human-authorized scope can continue instead of staying blocked for the rest
57
+ of the turn. Fresh repeated denials still trip it again after three
58
+ consecutive failures.
59
+ - Clean up break-glass authorization bookkeeping: an expired authorization
60
+ that was never consumed no longer blocks re-approving the same action, and
61
+ a fresh denial of an action whose approval was already consumed re-arms
62
+ one-shot approval (one approval per denial).
63
+
3
64
  ## 0.14.0 - 2026-08-28
4
65
 
5
66
  - Upgrade `/auto-review-policy-audit` from redacted statistics to an
package/README.md CHANGED
@@ -192,7 +192,9 @@ The host-generated override:
192
192
  - expires after 60 seconds and is consumed once;
193
193
  - remains separate from untrusted user/tool evidence;
194
194
  - still goes through deterministic hard denies and model review; and
195
- - cannot be reissued for the same request semantics in that session.
195
+ - cannot be reissued while a previous authorization for it is pending, or
196
+ again after consumption until the same action is denied afresh (one approval
197
+ per denial).
196
198
 
197
199
  It is authorization evidence, not a direct allow.
198
200
 
@@ -202,11 +204,15 @@ same session made in the last five minutes. After showing the rationale,
202
204
  surface, cwd, command or target summary, and request-hash fingerprint, it
203
205
  requires an explicit confirmation and a random `BREAK-GLASS <CODE>` phrase
204
206
  within 60 seconds. Successful confirmation creates a 60-second, one-use
205
- authorization bound to the complete request hash, session, scope, and original
206
- request ID. The exact retry reruns local hard-deny checks, then allows directly
207
- without calling the reviewer and, for sandbox adapters, still issues the normal
208
- one-shot grant. Break glass does not reset circuit-breaker history and can be
209
- disabled in trusted or project configuration with `breakGlassEnabled: false`.
207
+ authorization bound to the complete request hash, session, and original
208
+ request ID. The request hash deliberately excludes the per-attempt `id` and
209
+ `toolCallId` fields: the exact retry is a brand-new model tool call issued in
210
+ a later turn, so retry-minted identifiers cannot participate in the match.
211
+ The exact retry reruns local hard-deny checks, then allows directly without
212
+ calling the reviewer and, for sandbox adapters, still issues the normal
213
+ one-shot grant. A break-glass allow resets the turn circuit breaker (a human
214
+ re-authorized the scope), and break glass can be disabled in trusted or
215
+ project configuration with `breakGlassEnabled: false`.
210
216
 
211
217
  ## Boundary broker API
212
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
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",
@@ -47,14 +47,14 @@
47
47
  "test": "node --disable-warning=ExperimentalWarning --experimental-loader ./test/typescript-loader.mjs --test test/*.test.ts"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": "^0.84.1",
51
- "@earendil-works/pi-coding-agent": "^0.84.1",
52
- "@gotgenes/pi-permission-system": "^27.0.0"
50
+ "@earendil-works/pi-ai": "^0.84.4",
51
+ "@earendil-works/pi-coding-agent": "^0.84.4",
52
+ "@gotgenes/pi-permission-system": "^28.0.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@earendil-works/pi-ai": "^0.84.1",
56
- "@earendil-works/pi-coding-agent": "^0.84.1",
57
- "@gotgenes/pi-permission-system": "27.1.0",
55
+ "@earendil-works/pi-ai": "^0.84.4",
56
+ "@earendil-works/pi-coding-agent": "^0.84.4",
57
+ "@gotgenes/pi-permission-system": "28.0.0",
58
58
  "@types/node": "^26.2.0",
59
59
  "typescript": "^6.0.3"
60
60
  }
@@ -99,6 +99,9 @@ export class BoundaryApprovalBroker {
99
99
  ? this.#denials.consumeCritical(request, context)
100
100
  : undefined;
101
101
  if (breakGlass) {
102
+ // A human explicitly authorized this action; treat the scope as healthy
103
+ // again, exactly like a reviewer allow would.
104
+ this.#breaker.record(context.scopeKey, false);
102
105
  const review: BoundaryReview = {
103
106
  outcome: "allow",
104
107
  riskLevel: "critical",
@@ -166,9 +169,15 @@ export class BoundaryApprovalBroker {
166
169
  };
167
170
  }
168
171
  const breaker = this.#breaker.record(context.scopeKey, true);
172
+ // Record the failure denial so /auto-review-approve — the exact recovery
173
+ // command this verdict recommends — has a matching denial to approve.
174
+ // Without this the override path was unreachable for reviewer
175
+ // unavailability, and the recovery advice was dead text.
176
+ const review = { ...FAILURE_REVIEW, rationale: reason };
177
+ this.#denials.record(request, context, review);
169
178
  return {
170
179
  kind: "deny",
171
- review: { ...FAILURE_REVIEW, rationale: reason },
180
+ review,
172
181
  circuitBreakerTripped: breaker.tripped,
173
182
  denialSource: "reviewer",
174
183
  recoveryCommand: "/auto-review-approve",
@@ -1,6 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import type { BoundaryGrant, BoundaryRequest } from "./types.ts";
3
3
 
4
+ // Retry stability: a retried action mints a fresh requestId and toolCallId (the
5
+ // model issues a new tool call), so neither may take part in the exact-match
6
+ // hash. Everything else must be identical for an authorization to apply.
4
7
  function stableValue(value: unknown): unknown {
5
8
  if (Array.isArray(value)) return value.map(stableValue);
6
9
  if (!value || typeof value !== "object") return value;
@@ -25,7 +28,6 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
25
28
  destinationHost: request.destinationHost,
26
29
  destinationPort: request.destinationPort,
27
30
  destinationProtocol: request.destinationProtocol,
28
- toolCallId: request.toolCallId,
29
31
  toolName: request.toolName,
30
32
  skillName: request.skillName,
31
33
  toolInputPreview: request.toolInputPreview,
@@ -13,9 +13,7 @@ type PendingOverride = {
13
13
  approvedAt: number;
14
14
  };
15
15
 
16
- type PendingBreakGlass = BoundaryBreakGlassAuthorization & {
17
- scopeKey: string;
18
- };
16
+ type PendingBreakGlass = BoundaryBreakGlassAuthorization;
19
17
 
20
18
  export class RecentDenialStore {
21
19
  readonly #denials: RecentBoundaryDenial[] = [];
@@ -63,6 +61,10 @@ export class RecentDenialStore {
63
61
  scopeKey: context.scopeKey,
64
62
  deniedAt: this.#now(),
65
63
  });
64
+ // A fresh denial is a fresh human decision point: re-enable one-shot
65
+ // approval for this exact action even if a previous approval was already
66
+ // consumed earlier in the session.
67
+ this.#used.delete(this.key(context.sessionId, requestHash));
66
68
  if (this.#denials.length > this.#limit) {
67
69
  this.#denials.length = this.#limit;
68
70
  }
@@ -144,15 +146,18 @@ export class RecentDenialStore {
144
146
  if (index < 0) return;
145
147
  const denial = this.#denials[index];
146
148
  const key = this.key(sessionId, denial.requestHash);
147
- if (this.#breakGlassPending.has(key)) return;
149
+ const pending = this.#breakGlassPending.get(key);
150
+ if (pending) {
151
+ // A pending authorization that was never consumed (the retry never
152
+ // arrived) must not lock break-glass forever once it has expired.
153
+ if (this.#now() - pending.confirmedAt <= this.#overrideTtlMs) return;
154
+ this.#breakGlassPending.delete(key);
155
+ }
148
156
  const authorization = {
149
157
  originalRequestId: denial.requestId,
150
158
  confirmedAt: this.#now(),
151
159
  };
152
- this.#breakGlassPending.set(key, {
153
- ...authorization,
154
- scopeKey,
155
- });
160
+ this.#breakGlassPending.set(key, { ...authorization });
156
161
  this.#denials.splice(index, 1);
157
162
  return {
158
163
  denial: structuredClone(denial),
@@ -164,10 +169,13 @@ export class RecentDenialStore {
164
169
  request: BoundaryRequest,
165
170
  context: BoundaryReviewContext,
166
171
  ): BoundaryBreakGlassAuthorization | undefined {
172
+ // Note: the retry always lands in a later turn than the denial (the
173
+ // command flow appends a user message before retrying), so the scope key
174
+ // is intentionally not compared here; the session + request hash and the
175
+ // confirmation TTL bound the match.
167
176
  const key = this.key(context.sessionId, boundaryRequestHash(request));
168
177
  const pending = this.#breakGlassPending.get(key);
169
178
  if (!pending) return;
170
- if (pending.scopeKey !== context.scopeKey) return;
171
179
  this.#breakGlassPending.delete(key);
172
180
  if (this.#now() - pending.confirmedAt > this.#overrideTtlMs) {
173
181
  return;
package/src/index.ts CHANGED
@@ -178,7 +178,7 @@ type ReviewAttemptObservation = {
178
178
  type PreflightPart = { characters: number; estimatedTokens: number };
179
179
 
180
180
  type ReviewPreflight = {
181
- estimator: "conservative:utf8";
181
+ estimator: "conservative:cjk-aware";
182
182
  maxReviewerInputTokens: number;
183
183
  framingReserveTokens: number;
184
184
  fixedPrompt: PreflightPart;
@@ -898,10 +898,51 @@ function sharedReviewContext(
898
898
  });
899
899
  }
900
900
 
901
+ /**
902
+ * Conservative token estimate for reviewer prompt sizing.
903
+ *
904
+ * CJK code points are estimated at one token each: UTF-8 encodes them as
905
+ * three bytes, so the previous byte-for-token estimator overestimated
906
+ * CJK-heavy review payloads ~3x and synchronously failed the input-budget
907
+ * preflight ("reviewer_input_budget_exceeded") for large CJK tool inputs
908
+ * such as long Chinese plan documents — before the reviewer model was ever
909
+ * called. Remaining code points are estimated from their UTF-8 byte length
910
+ * at 3 bytes per token, slightly conservative for ASCII (typical tokenizers
911
+ * average ~4 bytes per token) and safely conservative for 2-4 byte scripts.
912
+ */
913
+ const OTHER_BYTES_PER_TOKEN = 3;
914
+
915
+ function codePointUtf8Bytes(code: number): number {
916
+ return code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
917
+ }
918
+
919
+ function isCjkCodePoint(code: number): boolean {
920
+ return (
921
+ (code >= 0x3000 && code <= 0x9fff) || // CJK symbols, punctuation, ideographs
922
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK extension A
923
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
924
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul syllables
925
+ (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
926
+ (code >= 0xff00 && code <= 0xffef) // fullwidth/halfwidth forms
927
+ );
928
+ }
929
+
930
+ /** Exported for tests; see the estimator doc comment above. */
931
+ export function estimateReviewerTokens(text: string): number {
932
+ let tokens = 0;
933
+ for (const char of text) {
934
+ const code = char.codePointAt(0) ?? 0;
935
+ tokens += isCjkCodePoint(code)
936
+ ? 1
937
+ : codePointUtf8Bytes(code) / OTHER_BYTES_PER_TOKEN;
938
+ }
939
+ return Math.ceil(tokens);
940
+ }
941
+
901
942
  function preflightPart(text: string): PreflightPart {
902
943
  return {
903
944
  characters: text.length,
904
- estimatedTokens: Buffer.byteLength(text, "utf8"),
945
+ estimatedTokens: estimateReviewerTokens(text),
905
946
  };
906
947
  }
907
948
 
@@ -910,7 +951,7 @@ function combinedPreflightPart(values: readonly string[]): PreflightPart {
910
951
  (total, value) => ({
911
952
  characters: total.characters + value.length,
912
953
  estimatedTokens:
913
- total.estimatedTokens + Buffer.byteLength(value, "utf8"),
954
+ total.estimatedTokens + estimateReviewerTokens(value),
914
955
  }),
915
956
  { characters: 0, estimatedTokens: 0 },
916
957
  );
@@ -966,7 +1007,7 @@ function reviewPreflight(
966
1007
  REVIEWER_FRAMING_RESERVE_TOKENS,
967
1008
  };
968
1009
  return {
969
- estimator: "conservative:utf8",
1010
+ estimator: "conservative:cjk-aware",
970
1011
  maxReviewerInputTokens,
971
1012
  framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
972
1013
  fixedPrompt,
@@ -1489,7 +1530,7 @@ function noModelSummary(): ReviewExecutionSummary {
1489
1530
  compactionState: "none",
1490
1531
  },
1491
1532
  preflight: {
1492
- estimator: "conservative:utf8",
1533
+ estimator: "conservative:cjk-aware",
1493
1534
  maxReviewerInputTokens: DEFAULT_CONFIG.maxReviewerInputTokens,
1494
1535
  framingReserveTokens: REVIEWER_FRAMING_RESERVE_TOKENS,
1495
1536
  fixedPrompt: zero,
@@ -1726,7 +1767,12 @@ async function complete(
1726
1767
  });
1727
1768
 
1728
1769
  try {
1729
- if (transcript.failureCode) {
1770
+ // A budget preflight failure is a sizing estimate, not a safety verdict.
1771
+ // When a human explicitly authorized this exact retry, their decision
1772
+ // must not be vetoed by an estimator: proceed with the truncated
1773
+ // evidence and let the reviewer see the override. The failureCode stays
1774
+ // on the transcript for observability.
1775
+ if (transcript.failureCode && !reviewerContext?.userOverride) {
1730
1776
  incrementError(errorCounts, transcript.failureCode);
1731
1777
  throw new ReviewExecutionError(
1732
1778
  transcript.failureCode,
@@ -1964,6 +2010,69 @@ export type PiAutoReviewExtensionOptions = {
1964
2010
 
1965
2011
  const POLICY_AUDIT_ENTRY_TYPE = "pi-auto-review-policy-audit";
1966
2012
 
2013
+ // Headings flagged for "this is actionable" emphasis. The visual weight
2014
+ // otherwise matches the rest of the report and a user skimming the TUI can
2015
+ // miss the two sections that actually drive decisions.
2016
+ const EMPHASIS_HEADINGS = new Set([
2017
+ "Suggested allow rules",
2018
+ "Keep ask",
2019
+ ]);
2020
+
2021
+ function wrapWidth(text: string, width: number): string[] {
2022
+ if (width <= 0) return [text];
2023
+ const lines: string[] = [];
2024
+ for (const paragraph of text.split("\n")) {
2025
+ if (paragraph.length === 0) {
2026
+ lines.push("");
2027
+ continue;
2028
+ }
2029
+ const tokens = paragraph.split(/(\s+)/);
2030
+ let current = "";
2031
+ let currentWidth = 0;
2032
+ const flush = () => {
2033
+ lines.push(current);
2034
+ current = "";
2035
+ currentWidth = 0;
2036
+ };
2037
+ for (const token of tokens) {
2038
+ if (token.length === 0) continue;
2039
+ const tokenWidth = [...token].reduce(
2040
+ (sum, ch) => sum + ((ch.codePointAt(0) ?? 0) > 0xff ? 2 : 1),
2041
+ 0,
2042
+ );
2043
+ if (tokenWidth > width) {
2044
+ if (current.length > 0) flush();
2045
+ for (const char of token) {
2046
+ const charWidth = (char.codePointAt(0) ?? 0) > 0xff ? 2 : 1;
2047
+ if (currentWidth + charWidth > width) flush();
2048
+ current += char;
2049
+ currentWidth += charWidth;
2050
+ }
2051
+ continue;
2052
+ }
2053
+ if (currentWidth + tokenWidth > width && current.length > 0) flush();
2054
+ current += token;
2055
+ currentWidth += tokenWidth;
2056
+ }
2057
+ if (current.length > 0) flush();
2058
+ }
2059
+ return lines.length > 0 ? lines : [""];
2060
+ }
2061
+
2062
+ function styleAuditLine(theme: { fg(color: string, text: string): string }, line: string): string {
2063
+ if (line.length === 0) return line;
2064
+ if (line.startsWith("```")) return theme.fg("mdCodeBlockBorder", line);
2065
+ if (line.startsWith("# ")) return theme.fg("mdHeading", line);
2066
+ if (line.startsWith("## ")) {
2067
+ const title = line.slice(3).trim();
2068
+ return theme.fg(EMPHASIS_HEADINGS.has(title) ? "success" : "mdHeading", line);
2069
+ }
2070
+ // JSON payload inside the config code block — color it so it reads as a
2071
+ // config block, not a paragraph the user is expected to read.
2072
+ if (/^[ {]/.test(line) || /^["}{,]/.test(line)) return theme.fg("mdCodeBlock", line);
2073
+ return theme.fg("muted", line);
2074
+ }
2075
+
1967
2076
  function renderPolicyAuditEntry(
1968
2077
  entry: { data?: unknown },
1969
2078
  _options: unknown,
@@ -1977,13 +2086,7 @@ function renderPolicyAuditEntry(
1977
2086
  return {
1978
2087
  render(width: number) {
1979
2088
  const max = Math.max(20, width - 2);
1980
- return markdown.split("\n").flatMap((line) => {
1981
- const output: string[] = [];
1982
- for (let offset = 0; offset < Math.max(1, line.length); offset += max) {
1983
- output.push(theme.fg("muted", line.slice(offset, offset + max)));
1984
- }
1985
- return output;
1986
- });
2089
+ return markdown.split("\n").flatMap((line) => wrapWidth(line, max).map((visual) => styleAuditLine(theme, visual)));
1987
2090
  },
1988
2091
  invalidate() {},
1989
2092
  };
@@ -2025,6 +2128,20 @@ export function createPiAutoReviewExtension(
2025
2128
  () => config.autoConfirmBoundedAllows,
2026
2129
  );
2027
2130
  const reviewWidget = new UserReviewWidgetController();
2131
+ // pi >= 0.84.4 notification-only events: while a ctx.ui prompt blocks the
2132
+ // session during an active review, show "waiting for you" instead of the
2133
+ // misleading "Waiting for <model>…". Best-effort registration: on older
2134
+ // pi these event names do not exist and the overlay stays off.
2135
+ try {
2136
+ pi.on("ui_prompt_start", (event) => {
2137
+ reviewWidget.promptStart(event);
2138
+ });
2139
+ pi.on("ui_prompt_end", () => {
2140
+ reviewWidget.promptEnd();
2141
+ });
2142
+ } catch {
2143
+ // Older pi: widget behavior is unchanged.
2144
+ }
2028
2145
  const policyAudit = new PolicyAuditController({
2029
2146
  config: () => config.policyAudit,
2030
2147
  cwd: () => context?.cwd,
@@ -32,6 +32,8 @@ const PERMISSION_RESOLUTIONS = new Set([
32
32
  "user_approved_for_session",
33
33
  "user_denied",
34
34
  "auto_approved",
35
+ "authorizer_allowed",
36
+ "authorizer_denied",
35
37
  "confirmation_unavailable",
36
38
  "gate_error",
37
39
  ]);
@@ -42,6 +42,11 @@ export type PolicyAuditReportOptions = {
42
42
  scope: "current" | "all"; days: number; top: number; minCount: number;
43
43
  };
44
44
 
45
+ // Suggestion evidence must stay strictly human/session-based. Since
46
+ // permission-system v28, decisions made by a registered authorizer chain link
47
+ // (including pi-auto-review's own model review) resolve to `authorizer_allowed`
48
+ // instead of `user_approved`. Counting those here would create a feedback loop:
49
+ // machine review allows → evidence → suggested auto-allow rule → review bypassed.
45
50
  const SUCCESS_RESOLUTIONS = new Set([
46
51
  "user_approved", "user_approved_for_session", "session_approved", "auto_approved",
47
52
  ]);
@@ -70,8 +75,15 @@ function rank(
70
75
  .slice(0, top);
71
76
  }
72
77
 
78
+ // "Reviewed" marks ask-path rows that some reviewer resolved — human clicks and,
79
+ // since permission-system v28, named authorizer links alike — so low-risk
80
+ // candidates stay visible even when only the machine authorizer decides them.
81
+ // Display-only: SUCCESS_RESOLUTIONS still gates suggestion evidence.
73
82
  function reviewed(row: PolicyAuditAggregateRow): boolean {
74
- return ["user_approved", "user_approved_for_session", "user_denied", "auto_approved"].includes(row.resolution);
83
+ return [
84
+ "user_approved", "user_approved_for_session", "user_denied", "auto_approved",
85
+ "authorizer_allowed", "authorizer_denied",
86
+ ].includes(row.resolution);
75
87
  }
76
88
 
77
89
  type CandidateGroup = {
@@ -243,10 +255,14 @@ export function buildPolicyAuditReport(
243
255
  };
244
256
  }
245
257
 
246
- function itemLines(items: readonly PolicyAuditReportItem[]): string[] {
258
+ // Shared renderer helpers — kept terse so the TUI can fit one full report
259
+ // on a typical screen without scrolling past statistics.
260
+ type Row = { label: string; count: number; denied: number; denialRate: number };
261
+
262
+ function itemLines(items: readonly Row[]): string[] {
247
263
  return items.length === 0
248
264
  ? ["- None above the minimum count."]
249
- : items.map((item) => `- ${item.name}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
265
+ : items.map((item) => `- ${item.label}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
250
266
  }
251
267
 
252
268
  function recommendationLines(items: readonly PolicyRecommendationItem[]): string[] {
@@ -255,33 +271,67 @@ function recommendationLines(items: readonly PolicyRecommendationItem[]): string
255
271
  : items.map((item) => `- ${displayRule(item)}: ${item.successfulEvidence} successful, ${item.denied} denied — ${item.reason}`);
256
272
  }
257
273
 
274
+ // Compress the five "hotspot" rankings (surface / signature / bash category /
275
+ // approval source / low-risk) into one table-like section. Each row keeps the
276
+ // original three fields (count, denied, denial rate); zero-denied rows drop
277
+ // the redundant denial-rate field so the eye can scan allowed-only traffic.
278
+ function hotspotRow(item: PolicyAuditReportItem): string {
279
+ const denied = `${item.denied} denied`;
280
+ const rate = item.denied === 0 ? "" : `, ${item.denialRate}% denial`;
281
+ return `- ${item.name}: ${item.count} (${denied}${rate})`;
282
+ }
283
+
284
+ function renderHotspotSections(report: PolicyAuditReport): string[] {
285
+ // Approval sources already encodes the same count as surfaces via a
286
+ // different key, so skip it when nothing was denied — it is allowed-only
287
+ // noise in that case. Low-risk review candidates overlap with the generic
288
+ // keep-ask section and are dropped entirely; the keep-ask section is the
289
+ // authoritative place for "what still asks".
290
+ const groups: Array<{ title: string; items: PolicyAuditReportItem[] }> = [
291
+ { title: "Surface hotspots", items: report.surfaces },
292
+ { title: "Tool and command signatures", items: report.tools },
293
+ { title: "Bash semantic categories", items: report.bashCategories },
294
+ ];
295
+ if (report.approvalSources.some((item) => item.denied > 0)) {
296
+ groups.push({ title: "Approval sources with denials", items: report.approvalSources });
297
+ }
298
+ if (report.ruleFingerprints.length > 0) {
299
+ groups.push({ title: "Anonymous rule-hit fingerprints",
300
+ items: report.ruleFingerprints.map((f) => ({ name: f.fingerprint, count: f.count, denied: 0, denialRate: 0 })) });
301
+ }
302
+ return groups.flatMap((group) => ["", `## ${group.title}`, "", ...group.items.map(hotspotRow)]);
303
+ }
304
+
258
305
  export function renderPolicyAuditMarkdown(report: PolicyAuditReport): string {
259
306
  const suggested = report.suggestedAllowRules.length === 0
260
307
  ? ["- None."]
261
308
  : report.suggestedAllowRules.map((item) =>
262
309
  `- ${displayRule(item)} = allow: ${item.successfulEvidence} successful ask-path samples, zero denials — ${item.rationale}`
263
310
  );
264
- const fingerprints = report.ruleFingerprints.length === 0
265
- ? ["- None above the minimum count."]
266
- : report.ruleFingerprints.map((item) => `- ${item.fingerprint}: ${item.count}`);
311
+ // The candidate keep-ask list (denied / blocked candidates) is the
312
+ // actionable one; the generic keep-ask and the ranked recommendations
313
+ // overlap heavily and are kept only as a single fallback line so the
314
+ // user sees they exist without a wall of duplicates.
315
+ const candidateKeepAsk = report.keepAskRules.filter((item) => item.denied > 0 ||
316
+ /denied|forwarded|unsafe|unparseable|authorization failure|parse_failed/i.test(item.reason));
317
+ const genericKeepAsk = report.keepAskRules.filter((item) => !candidateKeepAsk.includes(item));
318
+ const keepAskFallback = genericKeepAsk.length > 0
319
+ ? [`- ${genericKeepAsk.length} more surface(s) remain ask-by-default with no copyable rule; see full report for details.`]
320
+ : [];
267
321
  return [
268
322
  "# Permission policy audit", "",
269
- `Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}** · Recommendations since: **${report.recommendationsSince}**`,
323
+ `Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}**`,
270
324
  `Decisions: **${report.total}** · Allowed: **${report.allowed}** · Denied: **${report.denied}** · Denial rate: **${report.denialRate}%**`, "",
271
325
  "## Suggested allow rules", "", ...suggested, "",
272
- "## Copyable permission config", "", `Target: \`${report.configTarget}\``, "",
326
+ "## Copyable permission config", "", `Target: \`${report.configTarget}\``,
327
+ "Place narrow allow entries after broader matching ask entries (last-match-wins).", "",
273
328
  "```json", report.configFragment, "```", "",
274
- "Merge Bash patterns into the existing `permission.bash` map at this same scope. Permission-system uses last-match-wins, so place these narrow allow patterns after any broader ask rule that would match them.", "",
275
- "## Keep ask", "",
276
- "Denied candidates, authorization failures, structurally unsafe Bash variants, forwarded requests, and values without a valid rule remain ask-by-default.",
277
- ...recommendationLines(report.keepAskRules), ...itemLines(report.keepAskRecommendations), "",
278
- "## Insufficient evidence", "", ...recommendationLines(report.insufficientEvidence), "",
279
- "## Surface hotspots", "", ...itemLines(report.surfaces), "",
280
- "## Tool and command signatures", "", ...itemLines(report.tools), "",
281
- "## Bash semantic categories", "", ...itemLines(report.bashCategories), "",
282
- "## Approval sources", "", ...itemLines(report.approvalSources), "",
283
- "## Low-risk review candidates", "", ...itemLines(report.lowRiskReviewCandidates), "",
284
- "## Anonymous rule-hit fingerprints", "", ...fingerprints, "",
285
- "## Warnings", "", ...report.warnings.map((warning) => `- ${warning}`),
329
+ "## Keep ask",
330
+ "Denied candidates, authorization failures, unsafe Bash variants, and forwarded requests remain ask-by-default.",
331
+ ...recommendationLines(candidateKeepAsk), ...keepAskFallback, "",
332
+ "## Insufficient evidence", "", ...recommendationLines(report.insufficientEvidence),
333
+ ...renderHotspotSections(report), "",
334
+ "## Warnings", "",
335
+ "- Suggestions are evidence-based decision aids and never modify permission configuration. See the package README for the full list of caveats.",
286
336
  ].join("\n");
287
337
  }
@@ -543,9 +543,33 @@ export function notifyUserReview(
543
543
  }
544
544
 
545
545
  export type UserReviewWidgetData = UserReviewEntryData & {
546
- phase: "reviewing" | "complete";
546
+ phase: "reviewing" | "waiting_user" | "complete";
547
547
  };
548
548
 
549
+ /**
550
+ * Overlay shown while a `ctx.ui` prompt blocks the session during an active
551
+ * review. Without it the widget kept claiming "Waiting for <model>…" while
552
+ * pi was actually waiting on the user (permission dialog, /review-denials,
553
+ * break-glass challenge, ...). Driven by the notification-only
554
+ * `ui_prompt_start` / `ui_prompt_end` events (pi >= 0.84.4).
555
+ */
556
+ export function buildUserWaitingWidgetData(input: {
557
+ kind?: unknown;
558
+ title?: unknown;
559
+ }): UserReviewWidgetData {
560
+ const kind = typeof input.kind === "string" ? input.kind.trim() : "";
561
+ const title = typeof input.title === "string" ? input.title.trim() : "";
562
+ const label = truncateReviewText(title || kind || "input", 48);
563
+ return {
564
+ phase: "waiting_user",
565
+ outcome: "defer",
566
+ type: "info",
567
+ lines: joinNoticeLines([
568
+ `Auto-review · waiting for you · ${label}`,
569
+ ]),
570
+ };
571
+ }
572
+
549
573
  export function buildUserReviewingWidgetData(input: {
550
574
  surface: string;
551
575
  target?: string;
@@ -582,10 +606,12 @@ export function renderUserReviewWidgetLines(
582
606
  const rendered: string[] = [];
583
607
  const verb = data.phase === "reviewing"
584
608
  ? "reviewing"
609
+ : data.phase === "waiting_user"
610
+ ? "waiting"
585
611
  : outcomeVerb(data.outcome);
586
- const accent = data.phase === "reviewing"
587
- ? "muted"
588
- : outcomeAccent(data.outcome);
612
+ const accent = data.phase === "complete"
613
+ ? outcomeAccent(data.outcome)
614
+ : "muted";
589
615
  const verbAnsi = theme.getFgAnsi(accent);
590
616
  const mutedAnsi = theme.getFgAnsi("muted");
591
617
  for (const [index, line] of data.lines.entries()) {
@@ -629,6 +655,8 @@ function setWidget(
629
655
  /** Owns the single live widget and rejects stale concurrent completions. */
630
656
  export class UserReviewWidgetController {
631
657
  #generation = 0;
658
+ /** Set while a `ctx.ui` prompt blocks the session (ui_prompt span). */
659
+ #waiting?: { kind?: unknown; title?: unknown };
632
660
  #current?: {
633
661
  requestId: string;
634
662
  generation: number;
@@ -643,11 +671,38 @@ export class UserReviewWidgetController {
643
671
  input: { surface: string; target?: string; model?: string },
644
672
  ): number {
645
673
  const generation = ++this.#generation;
646
- this.#current = { requestId, generation, ctx };
647
- setWidget(ctx, buildUserReviewingWidgetData(input));
674
+ this.#current = { requestId, generation, ctx, data: buildUserReviewingWidgetData(input) };
675
+ this.#render();
648
676
  return generation;
649
677
  }
650
678
 
679
+ /**
680
+ * Flip the reviewing widget to a "waiting for you" state while a
681
+ * `ctx.ui` prompt blocks the session. Only overlays the reviewing phase:
682
+ * completed outcome summaries make no running claim, and without a live
683
+ * widget there is nothing misleading to correct.
684
+ */
685
+ promptStart(event: { kind?: unknown; title?: unknown }): void {
686
+ this.#waiting = { kind: event?.kind, title: event?.title };
687
+ this.#render();
688
+ }
689
+
690
+ promptEnd(): void {
691
+ if (!this.#waiting) return;
692
+ this.#waiting = undefined;
693
+ this.#render();
694
+ }
695
+
696
+ /** Single render path; ordering of begin/complete/prompt events is irrelevant. */
697
+ #render(): void {
698
+ const current = this.#current;
699
+ if (!current) return;
700
+ const data = this.#waiting && current.data?.phase === "reviewing"
701
+ ? buildUserWaitingWidgetData(this.#waiting)
702
+ : current.data;
703
+ if (data) setWidget(current.ctx, data);
704
+ }
705
+
651
706
  complete(
652
707
  requestId: string,
653
708
  generation: number,
@@ -689,6 +744,7 @@ export class UserReviewWidgetController {
689
744
 
690
745
  clear(ctx?: ExtensionContext): void {
691
746
  ++this.#generation;
747
+ this.#waiting = undefined;
692
748
  const current = this.#current;
693
749
  this.#current = undefined;
694
750
  if (current) setWidget(ctx ?? current.ctx, undefined);