@erichll/pi-auto-review 0.14.0 → 0.15.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 +45 -0
- package/README.md +12 -6
- package/package.json +5 -5
- package/src/broker/broker.ts +10 -1
- package/src/broker/grants.ts +3 -1
- package/src/broker/overrides.ts +17 -9
- package/src/index.ts +130 -13
- package/src/policy-audit/report.ts +57 -19
- package/src/user-feedback.ts +62 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0 - 2026-08-29
|
|
4
|
+
|
|
5
|
+
- Fix a deterministic reviewer-unavailable denial for large CJK tool inputs.
|
|
6
|
+
The reviewer input estimator counted one token per UTF-8 byte, tripling the
|
|
7
|
+
estimate for CJK text, so multi-kilobyte Chinese payloads (e.g. a long plan
|
|
8
|
+
document submitted through a tool call) synchronously failed the
|
|
9
|
+
`reviewer_input_budget_exceeded` preflight before the reviewer model was
|
|
10
|
+
ever called, and the request was denied as "Automatic review is
|
|
11
|
+
unavailable." CJK code points now estimate at one token each and remaining
|
|
12
|
+
code points at their UTF-8 byte length over three (estimator
|
|
13
|
+
`conservative:cjk-aware`).
|
|
14
|
+
- Record the denial when a reviewer-unavailable review fails closed, so
|
|
15
|
+
`/auto-review-approve` — the recovery command that verdict recommends —
|
|
16
|
+
actually has a matching denial to approve. Previously the recommendation
|
|
17
|
+
was dead text for this failure class.
|
|
18
|
+
- Let an explicit human override (`/auto-review-approve` trusted exact retry)
|
|
19
|
+
bypass the reviewer input-budget preflight failure. A sizing estimate is
|
|
20
|
+
not a safety verdict and must not veto an already-authorized retry; the
|
|
21
|
+
review proceeds with truncated evidence.
|
|
22
|
+
- Show "waiting for you" in the review widget while a `ctx.ui` prompt blocks
|
|
23
|
+
the session during an active review (pi >= 0.84.4 `ui_prompt_start` /
|
|
24
|
+
`ui_prompt_end`). The widget previously kept claiming "Waiting for
|
|
25
|
+
<model>…" while pi was actually waiting on the user (permission dialog,
|
|
26
|
+
`/review-denials`, break-glass challenge). Completed outcome summaries and
|
|
27
|
+
sessions without a live widget are unaffected.
|
|
28
|
+
- Fix `/auto-review-approve` and `/auto-review-break-glass` never applying in
|
|
29
|
+
real TUI sessions. The exact-match request hash included `toolCallId`, but a
|
|
30
|
+
retry is always a brand-new model tool call with a fresh tool call ID, so
|
|
31
|
+
the authorization could never match the retried request. The hash now
|
|
32
|
+
excludes both retry-minted identifiers (`id` was already excluded).
|
|
33
|
+
- Fix break-glass authorization being discarded on every retry. The stored
|
|
34
|
+
authorization was bound to the denial-time turn scope, but the command flow
|
|
35
|
+
appends a user message (`pi.sendUserMessage`) before the agent retries, so
|
|
36
|
+
the retry always lands in a later turn whose scope differs. Consumption now
|
|
37
|
+
binds on session and request hash plus the confirmation TTL instead of the
|
|
38
|
+
turn scope. The non-critical approve path already behaved this way.
|
|
39
|
+
- Reset the turn circuit breaker when a break-glass allow is consumed, so the
|
|
40
|
+
human-authorized scope can continue instead of staying blocked for the rest
|
|
41
|
+
of the turn. Fresh repeated denials still trip it again after three
|
|
42
|
+
consecutive failures.
|
|
43
|
+
- Clean up break-glass authorization bookkeeping: an expired authorization
|
|
44
|
+
that was never consumed no longer blocks re-approving the same action, and
|
|
45
|
+
a fresh denial of an action whose approval was already consumed re-arms
|
|
46
|
+
one-shot approval (one approval per denial).
|
|
47
|
+
|
|
3
48
|
## 0.14.0 - 2026-08-28
|
|
4
49
|
|
|
5
50
|
- 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
|
|
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,
|
|
206
|
-
request ID. The
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
3
|
+
"version": "0.15.0",
|
|
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,13 +47,13 @@
|
|
|
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.
|
|
51
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
50
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
52
52
|
"@gotgenes/pi-permission-system": "^27.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
55
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
57
57
|
"@gotgenes/pi-permission-system": "27.1.0",
|
|
58
58
|
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
package/src/broker/broker.ts
CHANGED
|
@@ -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
|
|
180
|
+
review,
|
|
172
181
|
circuitBreakerTripped: breaker.tripped,
|
|
173
182
|
denialSource: "reviewer",
|
|
174
183
|
recoveryCommand: "/auto-review-approve",
|
package/src/broker/grants.ts
CHANGED
|
@@ -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,
|
package/src/broker/overrides.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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 +
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
@@ -243,10 +243,14 @@ export function buildPolicyAuditReport(
|
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
// Shared renderer helpers — kept terse so the TUI can fit one full report
|
|
247
|
+
// on a typical screen without scrolling past statistics.
|
|
248
|
+
type Row = { label: string; count: number; denied: number; denialRate: number };
|
|
249
|
+
|
|
250
|
+
function itemLines(items: readonly Row[]): string[] {
|
|
247
251
|
return items.length === 0
|
|
248
252
|
? ["- None above the minimum count."]
|
|
249
|
-
: items.map((item) => `- ${item.
|
|
253
|
+
: items.map((item) => `- ${item.label}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
|
|
250
254
|
}
|
|
251
255
|
|
|
252
256
|
function recommendationLines(items: readonly PolicyRecommendationItem[]): string[] {
|
|
@@ -255,33 +259,67 @@ function recommendationLines(items: readonly PolicyRecommendationItem[]): string
|
|
|
255
259
|
: items.map((item) => `- ${displayRule(item)}: ${item.successfulEvidence} successful, ${item.denied} denied — ${item.reason}`);
|
|
256
260
|
}
|
|
257
261
|
|
|
262
|
+
// Compress the five "hotspot" rankings (surface / signature / bash category /
|
|
263
|
+
// approval source / low-risk) into one table-like section. Each row keeps the
|
|
264
|
+
// original three fields (count, denied, denial rate); zero-denied rows drop
|
|
265
|
+
// the redundant denial-rate field so the eye can scan allowed-only traffic.
|
|
266
|
+
function hotspotRow(item: PolicyAuditReportItem): string {
|
|
267
|
+
const denied = `${item.denied} denied`;
|
|
268
|
+
const rate = item.denied === 0 ? "" : `, ${item.denialRate}% denial`;
|
|
269
|
+
return `- ${item.name}: ${item.count} (${denied}${rate})`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderHotspotSections(report: PolicyAuditReport): string[] {
|
|
273
|
+
// Approval sources already encodes the same count as surfaces via a
|
|
274
|
+
// different key, so skip it when nothing was denied — it is allowed-only
|
|
275
|
+
// noise in that case. Low-risk review candidates overlap with the generic
|
|
276
|
+
// keep-ask section and are dropped entirely; the keep-ask section is the
|
|
277
|
+
// authoritative place for "what still asks".
|
|
278
|
+
const groups: Array<{ title: string; items: PolicyAuditReportItem[] }> = [
|
|
279
|
+
{ title: "Surface hotspots", items: report.surfaces },
|
|
280
|
+
{ title: "Tool and command signatures", items: report.tools },
|
|
281
|
+
{ title: "Bash semantic categories", items: report.bashCategories },
|
|
282
|
+
];
|
|
283
|
+
if (report.approvalSources.some((item) => item.denied > 0)) {
|
|
284
|
+
groups.push({ title: "Approval sources with denials", items: report.approvalSources });
|
|
285
|
+
}
|
|
286
|
+
if (report.ruleFingerprints.length > 0) {
|
|
287
|
+
groups.push({ title: "Anonymous rule-hit fingerprints",
|
|
288
|
+
items: report.ruleFingerprints.map((f) => ({ name: f.fingerprint, count: f.count, denied: 0, denialRate: 0 })) });
|
|
289
|
+
}
|
|
290
|
+
return groups.flatMap((group) => ["", `## ${group.title}`, "", ...group.items.map(hotspotRow)]);
|
|
291
|
+
}
|
|
292
|
+
|
|
258
293
|
export function renderPolicyAuditMarkdown(report: PolicyAuditReport): string {
|
|
259
294
|
const suggested = report.suggestedAllowRules.length === 0
|
|
260
295
|
? ["- None."]
|
|
261
296
|
: report.suggestedAllowRules.map((item) =>
|
|
262
297
|
`- ${displayRule(item)} = allow: ${item.successfulEvidence} successful ask-path samples, zero denials — ${item.rationale}`
|
|
263
298
|
);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
299
|
+
// The candidate keep-ask list (denied / blocked candidates) is the
|
|
300
|
+
// actionable one; the generic keep-ask and the ranked recommendations
|
|
301
|
+
// overlap heavily and are kept only as a single fallback line so the
|
|
302
|
+
// user sees they exist without a wall of duplicates.
|
|
303
|
+
const candidateKeepAsk = report.keepAskRules.filter((item) => item.denied > 0 ||
|
|
304
|
+
/denied|forwarded|unsafe|unparseable|authorization failure|parse_failed/i.test(item.reason));
|
|
305
|
+
const genericKeepAsk = report.keepAskRules.filter((item) => !candidateKeepAsk.includes(item));
|
|
306
|
+
const keepAskFallback = genericKeepAsk.length > 0
|
|
307
|
+
? [`- ${genericKeepAsk.length} more surface(s) remain ask-by-default with no copyable rule; see full report for details.`]
|
|
308
|
+
: [];
|
|
267
309
|
return [
|
|
268
310
|
"# Permission policy audit", "",
|
|
269
|
-
`Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}
|
|
311
|
+
`Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}**`,
|
|
270
312
|
`Decisions: **${report.total}** · Allowed: **${report.allowed}** · Denied: **${report.denied}** · Denial rate: **${report.denialRate}%**`, "",
|
|
271
313
|
"## Suggested allow rules", "", ...suggested, "",
|
|
272
|
-
"## Copyable permission config", "", `Target: \`${report.configTarget}\``,
|
|
314
|
+
"## Copyable permission config", "", `Target: \`${report.configTarget}\``,
|
|
315
|
+
"Place narrow allow entries after broader matching ask entries (last-match-wins).", "",
|
|
273
316
|
"```json", report.configFragment, "```", "",
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"##
|
|
280
|
-
"
|
|
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}`),
|
|
317
|
+
"## Keep ask",
|
|
318
|
+
"Denied candidates, authorization failures, unsafe Bash variants, and forwarded requests remain ask-by-default.",
|
|
319
|
+
...recommendationLines(candidateKeepAsk), ...keepAskFallback, "",
|
|
320
|
+
"## Insufficient evidence", "", ...recommendationLines(report.insufficientEvidence),
|
|
321
|
+
...renderHotspotSections(report), "",
|
|
322
|
+
"## Warnings", "",
|
|
323
|
+
"- Suggestions are evidence-based decision aids and never modify permission configuration. See the package README for the full list of caveats.",
|
|
286
324
|
].join("\n");
|
|
287
325
|
}
|
package/src/user-feedback.ts
CHANGED
|
@@ -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 === "
|
|
587
|
-
?
|
|
588
|
-
:
|
|
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
|
-
|
|
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);
|