@erichll/pi-auto-review 0.13.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.
@@ -1,20 +1,33 @@
1
1
  import type { PolicyAuditAggregateRow, PolicyAuditQueryResult } from "./store.ts";
2
2
 
3
- export const POLICY_AUDIT_REPORT_VERSION = 1 as const;
3
+ export const POLICY_AUDIT_REPORT_VERSION = 2 as const;
4
4
 
5
5
  export type PolicyAuditReportItem = { name: string; count: number; denied: number; denialRate: number };
6
+ export type SuggestedAllowRule = {
7
+ surface: string; pattern?: string; action: "allow"; safetyClass: string;
8
+ successfulEvidence: number; denied: 0; rationale: string;
9
+ };
10
+ export type PolicyRecommendationItem = {
11
+ surface: string; pattern?: string; successfulEvidence: number; denied: number; reason: string;
12
+ };
6
13
 
7
14
  export type PolicyAuditReport = {
8
15
  version: typeof POLICY_AUDIT_REPORT_VERSION;
9
16
  scope: "current" | "all";
10
17
  days: number;
11
18
  collectingSince: string;
19
+ recommendationsSince: string;
12
20
  fromDay: string;
13
21
  throughDay: string;
14
22
  total: number;
15
23
  allowed: number;
16
24
  denied: number;
17
25
  denialRate: number;
26
+ configTarget: string;
27
+ configFragment: string;
28
+ suggestedAllowRules: SuggestedAllowRule[];
29
+ keepAskRules: PolicyRecommendationItem[];
30
+ insufficientEvidence: PolicyRecommendationItem[];
18
31
  surfaces: PolicyAuditReportItem[];
19
32
  tools: PolicyAuditReportItem[];
20
33
  bashCategories: PolicyAuditReportItem[];
@@ -26,21 +39,21 @@ export type PolicyAuditReport = {
26
39
  };
27
40
 
28
41
  export type PolicyAuditReportOptions = {
29
- scope: "current" | "all";
30
- days: number;
31
- top: number;
32
- minCount: number;
42
+ scope: "current" | "all"; days: number; top: number; minCount: number;
33
43
  };
34
44
 
45
+ const SUCCESS_RESOLUTIONS = new Set([
46
+ "user_approved", "user_approved_for_session", "session_approved", "auto_approved",
47
+ ]);
48
+ const FAILURE_RESOLUTIONS = new Set(["confirmation_unavailable", "gate_error"]);
49
+
35
50
  function rate(denied: number, total: number): number {
36
51
  return total === 0 ? 0 : Math.round((denied / total) * 10_000) / 100;
37
52
  }
38
53
 
39
54
  function rank(
40
- rows: readonly PolicyAuditAggregateRow[],
41
- key: (row: PolicyAuditAggregateRow) => string,
42
- top: number,
43
- minCount: number,
55
+ rows: readonly PolicyAuditAggregateRow[], key: (row: PolicyAuditAggregateRow) => string,
56
+ top: number, minCount: number,
44
57
  ): PolicyAuditReportItem[] {
45
58
  const grouped = new Map<string, { count: number; denied: number }>();
46
59
  for (const row of rows) {
@@ -61,16 +74,128 @@ function reviewed(row: PolicyAuditAggregateRow): boolean {
61
74
  return ["user_approved", "user_approved_for_session", "user_denied", "auto_approved"].includes(row.resolution);
62
75
  }
63
76
 
77
+ type CandidateGroup = {
78
+ surface: string; pattern?: string; safetyClass: string; successfulEvidence: number;
79
+ denied: number; blockers: Set<string>;
80
+ };
81
+
82
+ function recommendationGroups(rows: readonly PolicyAuditAggregateRow[]): CandidateGroup[] {
83
+ const groups = new Map<string, CandidateGroup>();
84
+ for (const row of rows) {
85
+ if (!row.candidateSurface) continue;
86
+ // Forwarded evidence is reported separately: it cannot prove either the
87
+ // current project's cwd or a safe global scope, and must not poison local
88
+ // evidence for the same observed candidate.
89
+ const key = `${row.candidateSurface}\0${row.candidatePattern}\0${row.forwarded ? "forwarded" : "local"}`;
90
+ const group = groups.get(key) ?? {
91
+ surface: row.candidateSurface,
92
+ ...(row.candidatePattern ? { pattern: row.candidatePattern } : {}),
93
+ safetyClass: row.candidateSafetyClass,
94
+ successfulEvidence: 0,
95
+ denied: 0,
96
+ blockers: new Set<string>(),
97
+ };
98
+ if (row.result === "allow" && SUCCESS_RESOLUTIONS.has(row.resolution)) group.successfulEvidence += row.count;
99
+ if (row.result === "deny") group.denied += row.count;
100
+ if (row.forwarded) group.blockers.add("forwarded requests lack the requesting cwd");
101
+ if (!row.candidateEligible) group.blockers.add(row.candidateBlocker || "unsafe or unparseable variant observed");
102
+ if (FAILURE_RESOLUTIONS.has(row.resolution)) group.blockers.add(`authorization failure: ${row.resolution}`);
103
+ groups.set(key, group);
104
+ }
105
+ return [...groups.values()].sort((a, b) =>
106
+ b.successfulEvidence - a.successfulEvidence || a.surface.localeCompare(b.surface) ||
107
+ (a.pattern ?? "").localeCompare(b.pattern ?? "")
108
+ );
109
+ }
110
+
111
+ function displayRule(item: { surface: string; pattern?: string }): string {
112
+ return item.pattern ? `${item.surface}[${JSON.stringify(item.pattern)}]` : item.surface;
113
+ }
114
+
115
+ function buildConfigFragment(rules: readonly SuggestedAllowRule[]): string {
116
+ const bash: Record<string, "allow"> = {};
117
+ const permission: Record<string, "allow" | Record<string, "allow">> = {};
118
+ for (const rule of rules) {
119
+ if (rule.surface === "bash" && rule.pattern) bash[rule.pattern] = "allow";
120
+ }
121
+ if (Object.keys(bash).length > 0) permission.bash = bash;
122
+ for (const rule of rules) {
123
+ if (rule.surface !== "bash") permission[rule.surface] = "allow";
124
+ }
125
+ return JSON.stringify({ permission }, null, 2);
126
+ }
127
+
128
+ function genericKeepAskRules(
129
+ rows: readonly PolicyAuditAggregateRow[], minCount: number, top: number,
130
+ ): PolicyRecommendationItem[] {
131
+ const grouped = new Map<string, { surface: string; risk: string; total: number; successful: number; denied: number }>();
132
+ for (const row of rows) {
133
+ if (row.candidateSurface) continue;
134
+ const surface = row.surface === "custom_tool" ? row.signature : `${row.surface}:${row.signature}`;
135
+ const key = `${surface}\0${row.risk}`;
136
+ const group = grouped.get(key) ?? { surface, risk: row.risk, total: 0, successful: 0, denied: 0 };
137
+ group.total += row.count;
138
+ if (row.result === "allow" && SUCCESS_RESOLUTIONS.has(row.resolution)) group.successful += row.count;
139
+ if (row.result === "deny") group.denied += row.count;
140
+ grouped.set(key, group);
141
+ }
142
+ return [...grouped.values()]
143
+ .filter((group) => group.total >= minCount)
144
+ .sort((a, b) => b.total - a.total || a.surface.localeCompare(b.surface))
145
+ .slice(0, top)
146
+ .map((group) => ({
147
+ surface: group.surface,
148
+ successfulEvidence: group.successful,
149
+ denied: group.denied,
150
+ reason: `${group.total} observed but no valid copyable permission rule could be derived (${group.risk})`,
151
+ }));
152
+ }
153
+
64
154
  export function buildPolicyAuditReport(
65
- result: PolicyAuditQueryResult,
66
- options: PolicyAuditReportOptions,
155
+ result: PolicyAuditQueryResult, options: PolicyAuditReportOptions,
67
156
  ): PolicyAuditReport {
68
157
  const total = result.rows.reduce((sum, row) => sum + row.count, 0);
69
158
  const denied = result.rows.filter((row) => row.result === "deny").reduce((sum, row) => sum + row.count, 0);
70
159
  const lowRisk = result.rows.filter((row) => row.risk === "read_only" && reviewed(row));
71
- const keepAsk = result.rows.filter((row) =>
72
- ["workspace_mutation", "project_code_execution", "network", "network_mutation", "arbitrary_shell", "unknown"].includes(row.risk)
73
- );
160
+ const keepAsk = result.rows.filter((row) => !row.candidateSurface);
161
+ const groups = recommendationGroups(result.rows);
162
+ const suggestedAllowRules: SuggestedAllowRule[] = groups
163
+ .filter((group) => group.successfulEvidence >= options.minCount && group.denied === 0 && group.blockers.size === 0)
164
+ .slice(0, options.top)
165
+ .map((group) => ({
166
+ surface: group.surface,
167
+ ...(group.pattern ? { pattern: group.pattern } : {}),
168
+ action: "allow",
169
+ safetyClass: group.safetyClass,
170
+ successfulEvidence: group.successfulEvidence,
171
+ denied: 0,
172
+ rationale: group.safetyClass === "observed_bash_template"
173
+ ? "observed Bash template; zero denials, failures, and structurally unsafe variants"
174
+ : "observed permission surface; zero denials and authorization failures",
175
+ }));
176
+ const candidateKeepAskRules: PolicyRecommendationItem[] = groups
177
+ .filter((group) => group.denied > 0 || group.blockers.size > 0)
178
+ .map((group) => ({
179
+ surface: group.surface,
180
+ ...(group.pattern ? { pattern: group.pattern } : {}),
181
+ successfulEvidence: group.successfulEvidence,
182
+ denied: group.denied,
183
+ reason: [group.denied ? `${group.denied} denied` : "", ...group.blockers].filter(Boolean).join("; "),
184
+ }));
185
+ const keepAskRules = [
186
+ ...candidateKeepAskRules,
187
+ ...genericKeepAskRules(keepAsk, options.minCount, options.top),
188
+ ].slice(0, options.top);
189
+ const insufficientEvidence: PolicyRecommendationItem[] = groups
190
+ .filter((group) => group.successfulEvidence > 0 && group.successfulEvidence < options.minCount && group.denied === 0 && group.blockers.size === 0)
191
+ .slice(0, options.top)
192
+ .map((group) => ({
193
+ surface: group.surface,
194
+ ...(group.pattern ? { pattern: group.pattern } : {}),
195
+ successfulEvidence: group.successfulEvidence,
196
+ denied: 0,
197
+ reason: `needs ${options.minCount - group.successfulEvidence} more successful ask-path sample(s)`,
198
+ }));
74
199
  const fingerprintCounts = new Map<string, number>();
75
200
  for (const row of result.rows) {
76
201
  if (row.ruleFingerprint === "none") continue;
@@ -78,8 +203,10 @@ export function buildPolicyAuditReport(
78
203
  }
79
204
  const warnings = [
80
205
  "Counts begin at first successful enablement; no historical logs are imported.",
206
+ "Suggestions are evidence-based decision aids and never modify permission configuration.",
207
+ "Migrated pre-v2 counts remain in statistics but cannot contribute recommendation evidence.",
81
208
  "Rule fingerprints are anonymous hit indicators, not proof that unlisted rules are unused.",
82
- "High frequency alone must not be used to relax mutation, network, arbitrary-shell, unknown, or custom-tool policy.",
209
+ "A suggestion reflects repeated approval history, not an independent proof that the capability is safe.",
83
210
  ];
84
211
  if (total === 0) warnings.unshift("No matching permission decisions were collected for this window and scope.");
85
212
  return {
@@ -87,12 +214,20 @@ export function buildPolicyAuditReport(
87
214
  scope: options.scope,
88
215
  days: options.days,
89
216
  collectingSince: result.collectingSince,
217
+ recommendationsSince: result.recommendationsSince,
90
218
  fromDay: result.fromDay,
91
219
  throughDay: result.throughDay,
92
220
  total,
93
221
  allowed: total - denied,
94
222
  denied,
95
223
  denialRate: rate(denied, total),
224
+ configTarget: options.scope === "current"
225
+ ? ".pi/extensions/pi-permission-system/config.json"
226
+ : "~/.pi/agent/extensions/pi-permission-system/config.json",
227
+ configFragment: buildConfigFragment(suggestedAllowRules),
228
+ suggestedAllowRules,
229
+ keepAskRules,
230
+ insufficientEvidence,
96
231
  surfaces: rank(result.rows, (row) => row.surface, options.top, options.minCount),
97
232
  tools: rank(result.rows, (row) => row.signature, options.top, options.minCount),
98
233
  bashCategories: rank(result.rows.filter((row) => row.bashCategory !== "not_bash"), (row) => row.bashCategory, options.top, options.minCount),
@@ -108,33 +243,83 @@ export function buildPolicyAuditReport(
108
243
  };
109
244
  }
110
245
 
111
- function itemLines(items: readonly PolicyAuditReportItem[]): string[] {
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[] {
112
251
  return items.length === 0
113
252
  ? ["- None above the minimum count."]
114
- : items.map((item) => `- ${item.name}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
253
+ : items.map((item) => `- ${item.label}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
254
+ }
255
+
256
+ function recommendationLines(items: readonly PolicyRecommendationItem[]): string[] {
257
+ return items.length === 0
258
+ ? ["- None."]
259
+ : items.map((item) => `- ${displayRule(item)}: ${item.successfulEvidence} successful, ${item.denied} denied — ${item.reason}`);
260
+ }
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)]);
115
291
  }
116
292
 
117
293
  export function renderPolicyAuditMarkdown(report: PolicyAuditReport): string {
118
- const fingerprints = report.ruleFingerprints.length === 0
119
- ? ["- None above the minimum count."]
120
- : report.ruleFingerprints.map((item) => `- ${item.fingerprint}: ${item.count}`);
294
+ const suggested = report.suggestedAllowRules.length === 0
295
+ ? ["- None."]
296
+ : report.suggestedAllowRules.map((item) =>
297
+ `- ${displayRule(item)} = allow: ${item.successfulEvidence} successful ask-path samples, zero denials — ${item.rationale}`
298
+ );
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
+ : [];
121
309
  return [
122
- "# Permission policy audit",
123
- "",
310
+ "# Permission policy audit", "",
124
311
  `Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}**`,
125
- `Decisions: **${report.total}** · Allowed: **${report.allowed}** · Denied: **${report.denied}** · Denial rate: **${report.denialRate}%**`,
126
- "",
127
- "## Surface hotspots", "", ...itemLines(report.surfaces), "",
128
- "## Tool and command signatures", "", ...itemLines(report.tools), "",
129
- "## Bash semantic categories", "", ...itemLines(report.bashCategories), "",
130
- "## Approval sources", "", ...itemLines(report.approvalSources), "",
131
- "## Low-risk review candidates", "",
132
- "These read-only requests were still reviewed; inspect them before considering a narrower allow rule.",
133
- ...itemLines(report.lowRiskReviewCandidates), "",
134
- "## Keep-ask recommendations", "",
135
- "These mutation, execution, network, shell, or unknown requests should remain ask-by-default.",
136
- ...itemLines(report.keepAskRecommendations), "",
137
- "## Anonymous rule-hit fingerprints", "", ...fingerprints, "",
138
- "## Warnings", "", ...report.warnings.map((warning) => `- ${warning}`),
312
+ `Decisions: **${report.total}** · Allowed: **${report.allowed}** · Denied: **${report.denied}** · Denial rate: **${report.denialRate}%**`, "",
313
+ "## Suggested allow rules", "", ...suggested, "",
314
+ "## Copyable permission config", "", `Target: \`${report.configTarget}\``,
315
+ "Place narrow allow entries after broader matching ask entries (last-match-wins).", "",
316
+ "```json", report.configFragment, "```", "",
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.",
139
324
  ].join("\n");
140
325
  }
@@ -12,7 +12,7 @@ import {
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import type { ClassifiedPermission } from "./classifier.ts";
14
14
 
15
- export const POLICY_AUDIT_SCHEMA_VERSION = 1;
15
+ export const POLICY_AUDIT_SCHEMA_VERSION = 2;
16
16
  export const POLICY_AUDIT_DATABASE_NAME = "policy-audit.sqlite";
17
17
  export const POLICY_AUDIT_KEY_NAME = "policy-audit.key";
18
18
 
@@ -65,11 +65,17 @@ export type PolicyAuditAggregateRow = {
65
65
  forwarded: boolean;
66
66
  features: string;
67
67
  ruleFingerprint: string;
68
+ candidateSurface: string;
69
+ candidatePattern: string;
70
+ candidateSafetyClass: string;
71
+ candidateEligible: boolean;
72
+ candidateBlocker: string;
68
73
  count: number;
69
74
  };
70
75
 
71
76
  export type PolicyAuditQueryResult = {
72
77
  collectingSince: string;
78
+ recommendationsSince: string;
73
79
  fromDay: string;
74
80
  throughDay: string;
75
81
  rows: PolicyAuditAggregateRow[];
@@ -149,6 +155,22 @@ export class PolicyAuditStore {
149
155
  request_hash TEXT PRIMARY KEY,
150
156
  seen_day TEXT NOT NULL
151
157
  ) STRICT;
158
+ `);
159
+ const version = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value;
160
+ const now = this.now().toISOString();
161
+ if (version !== undefined && version !== "1" && version !== String(POLICY_AUDIT_SCHEMA_VERSION)) {
162
+ throw new Error("unsupported policy audit schema version");
163
+ }
164
+ if (version === "1") this.migrateV1(now);
165
+ else this.createV2Table();
166
+ this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('schema_version',?)").run(String(POLICY_AUDIT_SCHEMA_VERSION));
167
+ this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('collecting_since',?)").run(now);
168
+ this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('recommendations_since',?)").run(now);
169
+ this.secureSidecars();
170
+ }
171
+
172
+ private createV2Table(): void {
173
+ this.db.exec(`
152
174
  CREATE TABLE IF NOT EXISTS daily_permission_stats (
153
175
  day TEXT NOT NULL,
154
176
  project_hash TEXT NOT NULL,
@@ -163,19 +185,45 @@ export class PolicyAuditStore {
163
185
  forwarded INTEGER NOT NULL,
164
186
  features TEXT NOT NULL,
165
187
  rule_fingerprint TEXT NOT NULL,
188
+ candidate_surface TEXT NOT NULL,
189
+ candidate_pattern TEXT NOT NULL,
190
+ candidate_safety_class TEXT NOT NULL,
191
+ candidate_eligible INTEGER NOT NULL,
192
+ candidate_blocker TEXT NOT NULL,
166
193
  count INTEGER NOT NULL,
167
- PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint)
194
+ PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
168
195
  ) STRICT;
169
196
  CREATE INDEX IF NOT EXISTS daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
170
197
  `);
171
- const version = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value;
172
- if (version !== undefined && version !== String(POLICY_AUDIT_SCHEMA_VERSION)) {
173
- throw new Error("unsupported policy audit schema version");
198
+ }
199
+
200
+ private migrateV1(now: string): void {
201
+ this.db.exec("BEGIN IMMEDIATE");
202
+ try {
203
+ this.db.exec(`
204
+ CREATE TABLE daily_permission_stats_v2 (
205
+ day TEXT NOT NULL, project_hash TEXT NOT NULL, surface TEXT NOT NULL, signature TEXT NOT NULL,
206
+ bash_category TEXT NOT NULL, risk TEXT NOT NULL, path_class TEXT NOT NULL, result TEXT NOT NULL,
207
+ resolution TEXT NOT NULL, origin TEXT NOT NULL, forwarded INTEGER NOT NULL, features TEXT NOT NULL,
208
+ rule_fingerprint TEXT NOT NULL, candidate_surface TEXT NOT NULL, candidate_pattern TEXT NOT NULL,
209
+ candidate_safety_class TEXT NOT NULL, candidate_eligible INTEGER NOT NULL, candidate_blocker TEXT NOT NULL,
210
+ count INTEGER NOT NULL,
211
+ PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
212
+ ) STRICT;
213
+ INSERT INTO daily_permission_stats_v2
214
+ SELECT day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,'','','',0,'',count
215
+ FROM daily_permission_stats;
216
+ DROP TABLE daily_permission_stats;
217
+ ALTER TABLE daily_permission_stats_v2 RENAME TO daily_permission_stats;
218
+ CREATE INDEX daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
219
+ `);
220
+ this.db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(String(POLICY_AUDIT_SCHEMA_VERSION));
221
+ this.db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('recommendations_since',?)").run(now);
222
+ this.db.exec("COMMIT");
223
+ } catch (error) {
224
+ try { this.db.exec("ROLLBACK"); } catch { /* preserve migration error */ }
225
+ throw error;
174
226
  }
175
- const now = this.now().toISOString();
176
- this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('schema_version',?)").run(String(POLICY_AUDIT_SCHEMA_VERSION));
177
- this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('collecting_since',?)").run(now);
178
- this.secureSidecars();
179
227
  }
180
228
 
181
229
  private hash(kind: string, value: string): string {
@@ -219,8 +267,9 @@ export class PolicyAuditStore {
219
267
  }
220
268
  this.db.prepare(`
221
269
  INSERT INTO daily_permission_stats(
222
- day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,count
223
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1)
270
+ day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
271
+ candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,count
272
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)
224
273
  ON CONFLICT DO UPDATE SET count=count+1
225
274
  `).run(
226
275
  day,
@@ -236,6 +285,11 @@ export class PolicyAuditStore {
236
285
  event.forwarded ? 1 : 0,
237
286
  event.features.join(","),
238
287
  this.ruleFingerprint(event.matchedPattern),
288
+ event.candidate?.surface ?? "",
289
+ event.candidate?.pattern ?? "",
290
+ event.candidate?.safetyClass ?? "",
291
+ event.candidate?.eligible ? 1 : 0,
292
+ event.candidate?.blocker ?? "",
239
293
  );
240
294
  this.db.exec("COMMIT");
241
295
  this.secureSidecars();
@@ -264,18 +318,24 @@ export class PolicyAuditStore {
264
318
  const params: unknown[] = [fromDay];
265
319
  if (input.scope === "current") params.push(this.projectHash(input.projectPath));
266
320
  const rows = this.db.prepare(`
267
- SELECT surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,SUM(count) AS count
321
+ SELECT surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
322
+ candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,SUM(count) AS count
268
323
  FROM daily_permission_stats
269
324
  WHERE day >= ? ${projectClause}
270
- GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint
325
+ GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
326
+ candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker
271
327
  `).all(...params).map((row) => ({
272
328
  surface: String(row.surface), signature: String(row.signature), bashCategory: String(row.bash_category),
273
329
  risk: String(row.risk), pathClass: String(row.path_class), result: String(row.result),
274
330
  resolution: String(row.resolution), origin: String(row.origin), forwarded: Number(row.forwarded) === 1,
275
331
  features: String(row.features), ruleFingerprint: String(row.rule_fingerprint), count: Number(row.count),
332
+ candidateSurface: String(row.candidate_surface), candidatePattern: String(row.candidate_pattern),
333
+ candidateSafetyClass: String(row.candidate_safety_class), candidateEligible: Number(row.candidate_eligible) === 1,
334
+ candidateBlocker: String(row.candidate_blocker),
276
335
  }));
277
336
  const collectingSince = String(this.db.prepare("SELECT value FROM meta WHERE key='collecting_since'").get()?.value ?? "unknown");
278
- return { collectingSince, fromDay, throughDay, rows };
337
+ const recommendationsSince = String(this.db.prepare("SELECT value FROM meta WHERE key='recommendations_since'").get()?.value ?? "unknown");
338
+ return { collectingSince, recommendationsSince, fromDay, throughDay, rows };
279
339
  });
280
340
  }
281
341
 
@@ -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);