@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.
- package/CHANGELOG.md +67 -0
- package/README.md +48 -10
- 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/classifier.ts +117 -141
- package/src/policy-audit/report.ts +221 -36
- package/src/policy-audit/store.ts +74 -14
- package/src/user-feedback.ts +62 -6
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
import type { PolicyAuditAggregateRow, PolicyAuditQueryResult } from "./store.ts";
|
|
2
2
|
|
|
3
|
-
export const POLICY_AUDIT_REPORT_VERSION =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
|
119
|
-
? ["- None
|
|
120
|
-
: report.
|
|
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
|
-
"##
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"##
|
|
131
|
-
"
|
|
132
|
-
|
|
133
|
-
...
|
|
134
|
-
|
|
135
|
-
"
|
|
136
|
-
|
|
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 =
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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,
|
|
223
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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);
|