@erichll/pi-auto-review 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 - 2026-08-28
4
+
5
+ - Upgrade `/auto-review-policy-audit` from redacted statistics to an
6
+ evidence-driven permission-configuration optimizer. Reports now separate
7
+ suggested allow rules, keep-ask rules, and insufficient evidence, and emit a
8
+ copyable JSON fragment targeting the selected project or global
9
+ permission-system configuration. Suggestions remain advisory and never read
10
+ or modify permission configuration.
11
+ - Discover candidates from actual audit data without built-in tool, executable,
12
+ Git, RTK, package-manager, or read-only command catalogs. Valid observed
13
+ surfaces and sanitized Bash templates—including unknown tools and write
14
+ operations—can qualify after the configured minimum number of successful
15
+ ask-path approvals. Denials, gate failures, unavailable confirmations,
16
+ forwarded requests, and structurally unsafe Bash variants block a suggestion.
17
+ - Migrate the policy-audit database transactionally to schema v2. Existing
18
+ aggregate counts and `collecting_since` are preserved, while the new
19
+ `recommendations_since` boundary prevents pre-migration data from producing
20
+ allow suggestions. Valid custom tool names and sanitized Bash templates are
21
+ retained for reporting. Templates replace paths, URLs, assignments, quoted
22
+ values, and option values with wildcards; raw commands and non-Bash tool
23
+ arguments or inputs remain excluded.
24
+
3
25
  ## 0.13.0 - 2026-08-28
4
26
 
5
27
  - Add compatibility with `@gotgenes/pi-permission-system` 27.1 directional
package/README.md CHANGED
@@ -334,11 +334,18 @@ only daily, redacted aggregates. Collection is enabled by default and retains
334
334
  180 days. It starts when this version first initializes successfully; no
335
335
  permission-system JSONL or RTK history is read or imported.
336
336
 
337
- Before storage, Bash values become fixed signatures such as `git:push`,
338
- `rtk:git:status`, `npm:test`, or `<path-command>`. Paths become only
337
+ Before storage, Bash values become observed command templates such as
338
+ `git status --short`, `cat *`, or `npm test * --token *`. URLs, paths,
339
+ assignments, quoted values, and option values are replaced with `*`; safe bare
340
+ command words remain in plaintext so the report can emit a matching rule.
341
+ Paths used for path-surface statistics become only
339
342
  `workspace`, `temp`, `home`, `external`, `sensitive`, or `unknown`. Request IDs,
340
- project locations, and matched-rule patterns are HMACed. The database never
341
- stores raw commands, paths, URLs, credentials, tool inputs, or project names.
343
+ project locations, and matched-rule patterns are HMACed. Syntactically valid
344
+ custom tool names are also retained in plaintext; invalid names stay anonymous
345
+ as `<custom-tool>`. The database never stores raw commands, raw paths,
346
+ URLs, credentials, or non-Bash tool arguments and inputs. Because safe bare Bash
347
+ words are retained, command templates can contain non-secret filenames or
348
+ project-specific labels; inspect suggestions before copying them.
342
349
 
343
350
  Data lives at
344
351
  `~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
@@ -357,6 +364,31 @@ Run `/auto-review-policy-audit` for a durable TUI report that is not sent to the
357
364
  LLM. Options are `--days 1..retention`, `--top 1..50`, `--min-count >=1`, and
358
365
  `--scope current|all`; defaults are `30`, `20`, `5`, and `current`.
359
366
 
367
+ In addition to the redacted statistics, the report proposes copyable
368
+ permission-system fragments. `--scope current` targets
369
+ `.pi/extensions/pi-permission-system/config.json`; `--scope all` targets
370
+ `~/.pi/agent/extensions/pi-permission-system/config.json`. The extension never
371
+ reads, edits, or rewrites either file. Merge suggested Bash patterns into the
372
+ existing `permission.bash` map and place the narrow allow entries after broader
373
+ matching ask entries, because permission-system uses last-match-wins.
374
+
375
+ Suggestions are discovered entirely from observed audit data; there is no
376
+ built-in tool, executable, Git action, package-manager action, or read-only
377
+ command catalog. Every valid observed permission surface and reliably templated
378
+ Bash command can qualify, including previously unknown tools and write
379
+ operations. Environment prefixes, compound syntax, pipelines, redirection,
380
+ path executables, and unreliable parsing block that Bash template. Forwarded
381
+ requests are statistics-only because the requester's cwd is not available. A
382
+ candidate needs at least `--min-count` successful ask-path approvals and no
383
+ denial, gate error, unavailable confirmation, blocked structural variant, or
384
+ forwarded-only evidence in the selected window. Existing policy/infrastructure
385
+ allows do not count as evidence of user friction. Repeated approval is evidence
386
+ of user preference, not independent proof that a capability is safe.
387
+
388
+ Schema v2 migrates v1 aggregates transactionally. Old totals remain visible,
389
+ `collecting_since` is preserved, and `recommendations_since` records when safe
390
+ recommendation evidence began; pre-migration data cannot produce a suggestion.
391
+
360
392
  The report is an extension-owned custom entry. It is deliberately not exposed
361
393
  as an Agent tool or packaged skill, so neither a tool schema nor skill metadata
362
394
  is added to the model context. Permission changes remain a separate, explicit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -23,8 +23,18 @@ export type PolicyAuditCommandFeature =
23
23
  | "env_prefix"
24
24
  | "compound"
25
25
  | "pipeline"
26
+ | "redirection"
26
27
  | "relative_executable";
27
28
 
29
+ export type PolicyConfigCandidate = {
30
+ surface: string;
31
+ pattern?: string;
32
+ action: "allow";
33
+ safetyClass: "observed_bash_template" | "observed_surface";
34
+ eligible: boolean;
35
+ blocker?: string;
36
+ };
37
+
28
38
  export type ClassifiedPermission = {
29
39
  surface: string;
30
40
  signature: string;
@@ -32,49 +42,12 @@ export type ClassifiedPermission = {
32
42
  risk: PolicyAuditRisk;
33
43
  pathClass: PolicyAuditPathClass;
34
44
  features: readonly PolicyAuditCommandFeature[];
45
+ candidate?: PolicyConfigCandidate;
35
46
  };
36
47
 
37
- const KNOWN_SURFACES = new Set([
38
- "bash",
39
- "bash_escalated",
40
- "read",
41
- "write",
42
- "edit",
43
- "grep",
44
- "find",
45
- "ls",
46
- "path",
47
- "external_directory",
48
- "path_read",
49
- "path_write",
50
- "external_directory_read",
51
- "external_directory_write",
52
- "skill",
53
- "mcp",
54
- "network",
55
- ]);
56
-
48
+ const SAFE_SURFACE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
49
+ const SAFE_COMMAND_WORD = /^[A-Za-z0-9][A-Za-z0-9+._:-]{0,63}$/;
57
50
  const SENSITIVE_PATH = /(?:^|[/\\])(?:\.ssh|\.aws|\.gnupg|\.kube|\.azure|\.npmrc|\.netrc|\.env(?:\.[^/\\]+)?|auth\.json|credentials?)(?:[/\\]|$)/i;
58
- const READ_ONLY_COMMANDS = new Set([
59
- "cat", "find", "grep", "head", "ls", "pwd", "rg", "sed", "sort",
60
- "stat", "tail", "wc", "which",
61
- ]);
62
- const MUTATING_COMMANDS = new Set([
63
- "chmod", "chown", "cp", "install", "ln", "mkdir", "mv", "rm", "rmdir",
64
- "touch", "truncate",
65
- ]);
66
- const NETWORK_COMMANDS = new Set(["curl", "dig", "host", "nc", "ping", "ssh", "wget"]);
67
- const SHELL_COMMANDS = new Set(["bash", "dash", "fish", "sh", "zsh"]);
68
- const GIT_ACTIONS = new Set([
69
- "add", "branch", "checkout", "clean", "clone", "commit", "diff", "fetch",
70
- "log", "merge", "pull", "push", "rebase", "remote", "reset", "restore",
71
- "send-pack", "show", "status", "switch", "tag",
72
- ]);
73
- const RTK_ACTIONS = new Set(["diff", "find", "grep", "ls", "read", "rg", "smart", "wc"]);
74
- const PACKAGE_ACTIONS = new Set([
75
- "add", "build", "check", "coverage", "dev", "format", "install", "lint",
76
- "publish", "start", "test", "typecheck", "uninstall", "update", "upgrade",
77
- ]);
78
51
 
79
52
  function within(path: string, parent: string): boolean {
80
53
  const rel = relative(parent, path);
@@ -90,12 +63,8 @@ export function classifyPath(
90
63
  if (typeof value !== "string" || !value.trim()) return "unknown";
91
64
  const raw = value.trim();
92
65
  if (SENSITIVE_PATH.test(raw)) return "sensitive";
93
- if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith(".")) {
94
- return "unknown";
95
- }
96
- const expanded = raw === "~" || raw.startsWith("~/")
97
- ? resolve(home, raw.slice(2))
98
- : resolve(cwd, raw);
66
+ if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith(".")) return "unknown";
67
+ const expanded = raw === "~" || raw.startsWith("~/") ? resolve(home, raw.slice(2)) : resolve(cwd, raw);
99
68
  if (within(expanded, resolve(cwd))) return "workspace";
100
69
  if (within(expanded, resolve(temp))) return "temp";
101
70
  if (within(expanded, resolve(home))) return "home";
@@ -103,138 +72,145 @@ export function classifyPath(
103
72
  }
104
73
 
105
74
  function shellTokens(command: string): string[] {
106
- return command
107
- .trim()
108
- .split(/\s+/u)
109
- .map((token) => token.replace(/^["']|["']$/g, ""));
75
+ return command.trim().split(/\s+/u);
110
76
  }
111
77
 
112
78
  function executableIndex(tokens: readonly string[]): number {
113
79
  let index = 0;
114
80
  if (tokens[index] === "env") index++;
115
- while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) {
116
- index++;
117
- }
81
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) index++;
118
82
  return index;
119
83
  }
120
84
 
121
- function safeCommandName(token: string | undefined): string {
122
- if (!token) return "unknown";
123
- const name = token.split(/[\\/]/).at(-1)?.toLowerCase() || "unknown";
124
- return /^[a-z0-9][a-z0-9+._-]{0,31}$/.test(name) ? name : "unknown";
85
+ function safeExecutable(token: string | undefined): string | undefined {
86
+ if (!token || token.includes("/") || token.startsWith(".")) return undefined;
87
+ const unquoted = token.replace(/^["']|["']$/g, "");
88
+ return SAFE_COMMAND_WORD.test(unquoted) ? unquoted : undefined;
125
89
  }
126
90
 
127
- function knownSignature(tokens: readonly string[], index: number): string {
128
- const executable = safeCommandName(tokens[index]);
129
- const sub = safeCommandName(tokens[index + 1]);
130
- const third = safeCommandName(tokens[index + 2]);
131
- if (executable === "rtk") {
132
- if (sub === "git") return `rtk:git:${GIT_ACTIONS.has(third) ? third : "other"}`;
133
- return `rtk:${RTK_ACTIONS.has(sub) ? sub : "other"}`;
134
- }
135
- if (executable === "git") return `git:${GIT_ACTIONS.has(sub) ? sub : "other"}`;
136
- if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
137
- const action = sub === "run" ? third : sub;
138
- return `${executable}:${PACKAGE_ACTIONS.has(action) ? action : "custom-script"}`;
139
- }
140
- if (READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable) ||
141
- NETWORK_COMMANDS.has(executable) || SHELL_COMMANDS.has(executable)) {
142
- return executable;
143
- }
144
- if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) {
145
- return "<path-command>";
146
- }
147
- return "<other-command>";
91
+ function reliableShellText(command: string): boolean {
92
+ let quote = "";
93
+ let escaped = false;
94
+ for (const character of command) {
95
+ if (escaped) {
96
+ escaped = false;
97
+ continue;
98
+ }
99
+ if (character === "\\" && quote !== "'") {
100
+ escaped = true;
101
+ continue;
102
+ }
103
+ if (quote) {
104
+ if (character === quote) quote = "";
105
+ } else if (character === "'" || character === '"') {
106
+ quote = character;
107
+ }
108
+ }
109
+ return !quote && !escaped;
148
110
  }
149
111
 
150
- function commandRisk(signature: string, executable: string, command: string): PolicyAuditRisk {
151
- if (/&&|\|\||;|\n|`|\$\(|(^|[^|])\|([^|]|$)/.test(command)) return "arbitrary_shell";
152
- if (["git:push", "git:send-pack", "rtk:git:push", "rtk:git:send-pack"].includes(signature)) return "network_mutation";
153
- if (/^(?:npm|pnpm|yarn|bun):(install|publish|add|upgrade|update)$/.test(signature)) {
154
- return signature.endsWith(":publish") ? "network_mutation" : "project_code_execution";
155
- }
156
- if (/^(?:npm|pnpm|yarn|bun):/.test(signature)) return "project_code_execution";
157
- if (NETWORK_COMMANDS.has(executable)) {
158
- return /(?:^|\s)(?:--upload-file|-T|-X\s*(?:POST|PUT|PATCH|DELETE)|--data|-d)(?:\s|=|$)/i.test(command)
159
- ? "network_mutation"
160
- : "network";
161
- }
162
- if (MUTATING_COMMANDS.has(executable) || ["git:add", "git:commit", "git:reset", "git:clean", "git:checkout", "git:restore", "git:rebase", "rtk:git:add", "rtk:git:commit"].includes(signature)) {
163
- return "workspace_mutation";
164
- }
165
- if (READ_ONLY_COMMANDS.has(executable) || /^(?:rtk:)?git:(?:status|diff|log|show|branch)$/.test(signature)) {
166
- return "read_only";
167
- }
168
- if (SHELL_COMMANDS.has(executable) || signature === "<path-command>") {
169
- return "arbitrary_shell";
170
- }
171
- return "unknown";
112
+ function sanitizedTemplateToken(token: string, followsOption: boolean): string {
113
+ if (!token || /^['"]/.test(token) || /[/\\@]/.test(token) || /:\/\//.test(token)) return "*";
114
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) return "*";
115
+ if (token.startsWith("--") && token.includes("=")) {
116
+ const name = token.slice(0, token.indexOf("="));
117
+ return /^--[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) ? `${name}=*` : "*";
118
+ }
119
+ if (/^--?[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(token)) return token;
120
+ if (followsOption) return "*";
121
+ return SAFE_COMMAND_WORD.test(token) ? token : "*";
122
+ }
123
+
124
+ function observedBashTemplate(tokens: readonly string[], index: number): string | undefined {
125
+ const executable = safeExecutable(tokens[index]);
126
+ if (!executable) return undefined;
127
+ const template = [executable];
128
+ let followsOption = false;
129
+ for (const rawToken of tokens.slice(index + 1)) {
130
+ const token = sanitizedTemplateToken(rawToken, followsOption);
131
+ if (template.at(-1) !== "*" || token !== "*") template.push(token);
132
+ followsOption = /^--?[A-Za-z0-9]/.test(rawToken) && !rawToken.includes("=");
133
+ }
134
+ return template.join(" ");
172
135
  }
173
136
 
174
137
  export function classifyBash(command: unknown, cwd: string): ClassifiedPermission {
175
138
  if (typeof command !== "string" || !command.trim()) {
176
- return { surface: "bash", signature: "<other-command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
139
+ return { surface: "bash", signature: "<command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
177
140
  }
178
141
  const tokens = shellTokens(command);
179
142
  const index = executableIndex(tokens);
180
- const executable = safeCommandName(tokens[index]);
181
- const signature = knownSignature(tokens, index);
182
143
  const features: PolicyAuditCommandFeature[] = [];
183
144
  if (index > 0) features.push("env_prefix");
184
145
  if (/&&|\|\||;|\n|`|\$\(/.test(command)) features.push("compound");
185
146
  if (/(^|[^|])\|([^|]|$)/.test(command)) features.push("pipeline");
147
+ if (/[<>]/.test(command)) features.push("redirection");
186
148
  if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) features.push("relative_executable");
187
- const bashCategory = /^(?:rtk:)?git:/.test(signature)
188
- ? "version_control"
189
- : /^(?:npm|pnpm|yarn|bun):/.test(signature)
190
- ? "package_script"
191
- : NETWORK_COMMANDS.has(executable)
192
- ? "network"
193
- : READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable)
194
- ? "filesystem"
195
- : "shell";
149
+ const uniqueFeatures = [...new Set(features)].sort();
150
+ const structuralBoundary = command.search(/&&|\|\||[;|<>\n]|`|\$\(/);
151
+ const templateTokens = shellTokens(structuralBoundary >= 0 ? command.slice(0, structuralBoundary) : command);
152
+ const template = observedBashTemplate(templateTokens, executableIndex(templateTokens));
153
+ let blocker: string | undefined;
154
+ if (!reliableShellText(command) || !template) blocker = "command could not be templated reliably";
155
+ else if (uniqueFeatures.length > 0) blocker = `unsafe command feature: ${uniqueFeatures.join(", ")}`;
156
+ const candidate: PolicyConfigCandidate | undefined = template ? {
157
+ surface: "bash",
158
+ pattern: template,
159
+ action: "allow",
160
+ safetyClass: "observed_bash_template",
161
+ eligible: blocker === undefined,
162
+ ...(blocker ? { blocker } : {}),
163
+ } : undefined;
196
164
  const pathToken = tokens.slice(index + 1)
197
- .map((token) => token.includes("=") ? token.slice(token.indexOf("=") + 1) : token.replace(/^@/, ""))
165
+ .map((token) => token.replace(/^@/, ""))
198
166
  .find((token) => token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.startsWith("~/"));
199
167
  return {
200
168
  surface: "bash",
201
- signature,
202
- bashCategory,
203
- risk: commandRisk(signature, executable, command),
169
+ signature: template ?? "<command>",
170
+ bashCategory: uniqueFeatures.length === 0 ? "simple" : "structured",
171
+ risk: uniqueFeatures.includes("compound") || uniqueFeatures.includes("pipeline")
172
+ ? "arbitrary_shell"
173
+ : uniqueFeatures.includes("redirection") ? "workspace_mutation" : "unknown",
204
174
  pathClass: classifyPath(pathToken, cwd),
205
- features: [...new Set(features)].sort(),
175
+ features: uniqueFeatures,
176
+ ...(candidate ? { candidate } : {}),
206
177
  };
207
178
  }
208
179
 
209
180
  export function classifyPermission(surfaceValue: unknown, value: unknown, cwd: string): ClassifiedPermission {
210
- const rawSurface = typeof surfaceValue === "string" ? surfaceValue.trim().toLowerCase() : "";
211
- if (rawSurface === "bash" || rawSurface === "bash_escalated") {
181
+ const suppliedSurface = typeof surfaceValue === "string" ? surfaceValue.trim() : "";
182
+ const normalizedSurface = suppliedSurface.toLowerCase();
183
+ if (normalizedSurface === "bash" || normalizedSurface === "bash_escalated") {
212
184
  const classified = classifyBash(value, cwd);
213
- return { ...classified, surface: rawSurface || "bash" };
214
- }
215
- const surface = KNOWN_SURFACES.has(rawSurface) ? rawSurface : "custom_tool";
216
- const pathSurface = pathSurfaceInfo(surface);
217
- if (pathSurface || ["read", "write", "edit"].includes(surface)) {
218
- const pathClass = classifyPath(value, cwd);
219
- const risk = pathSurface?.effect === "read" || surface === "read"
220
- ? "read_only"
221
- : pathSurface?.effect === "write" || surface === "write" || surface === "edit"
222
- ? "workspace_mutation"
223
- : "unknown";
224
- return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk, pathClass, features: [] };
225
- }
226
- if (["grep", "find", "ls"].includes(surface)) {
227
- return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk: "read_only", pathClass: "unknown", features: [] };
228
- }
229
- if (surface === "network") {
230
- return { surface, signature: "<network>", bashCategory: "not_bash", risk: "network", pathClass: "unknown", features: [] };
185
+ if (normalizedSurface === "bash_escalated") {
186
+ const { candidate: _candidate, ...statisticsOnly } = classified;
187
+ return { ...statisticsOnly, surface: normalizedSurface };
188
+ }
189
+ return classified;
231
190
  }
191
+
192
+ const validSurface = SAFE_SURFACE.test(suppliedSurface) ? suppliedSurface : undefined;
193
+ const pathSurface = pathSurfaceInfo(normalizedSurface);
194
+ const pathLike = pathSurface !== undefined || normalizedSurface === "read" || normalizedSurface === "write" || normalizedSurface === "edit";
195
+ const pathClass = pathLike ? classifyPath(value, cwd) : "unknown";
196
+ const risk = pathSurface?.effect === "read" || normalizedSurface === "read"
197
+ ? "read_only"
198
+ : pathSurface?.effect === "write" || normalizedSurface === "write" || normalizedSurface === "edit"
199
+ ? "workspace_mutation"
200
+ : "unknown";
201
+ const candidate: PolicyConfigCandidate | undefined = validSurface ? {
202
+ surface: validSurface,
203
+ action: "allow",
204
+ safetyClass: "observed_surface",
205
+ eligible: true,
206
+ } : undefined;
232
207
  return {
233
- surface,
234
- signature: surface === "custom_tool" ? "<custom-tool>" : `<${surface}>`,
208
+ surface: validSurface ?? "custom_tool",
209
+ signature: validSurface ?? "<custom-tool>",
235
210
  bashCategory: "not_bash",
236
- risk: "unknown",
237
- pathClass: "unknown",
211
+ risk,
212
+ pathClass,
238
213
  features: [],
214
+ ...(candidate ? { candidate } : {}),
239
215
  };
240
216
  }
@@ -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),
@@ -114,26 +249,38 @@ function itemLines(items: readonly PolicyAuditReportItem[]): string[] {
114
249
  : items.map((item) => `- ${item.name}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
115
250
  }
116
251
 
252
+ function recommendationLines(items: readonly PolicyRecommendationItem[]): string[] {
253
+ return items.length === 0
254
+ ? ["- None."]
255
+ : items.map((item) => `- ${displayRule(item)}: ${item.successfulEvidence} successful, ${item.denied} denied — ${item.reason}`);
256
+ }
257
+
117
258
  export function renderPolicyAuditMarkdown(report: PolicyAuditReport): string {
259
+ const suggested = report.suggestedAllowRules.length === 0
260
+ ? ["- None."]
261
+ : report.suggestedAllowRules.map((item) =>
262
+ `- ${displayRule(item)} = allow: ${item.successfulEvidence} successful ask-path samples, zero denials — ${item.rationale}`
263
+ );
118
264
  const fingerprints = report.ruleFingerprints.length === 0
119
265
  ? ["- None above the minimum count."]
120
266
  : report.ruleFingerprints.map((item) => `- ${item.fingerprint}: ${item.count}`);
121
267
  return [
122
- "# Permission policy audit",
123
- "",
124
- `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
- "",
268
+ "# Permission policy audit", "",
269
+ `Scope: **${report.scope}** · Window: **${report.fromDay}–${report.throughDay}** · Collecting since: **${report.collectingSince}** · Recommendations since: **${report.recommendationsSince}**`,
270
+ `Decisions: **${report.total}** · Allowed: **${report.allowed}** · Denied: **${report.denied}** · Denial rate: **${report.denialRate}%**`, "",
271
+ "## Suggested allow rules", "", ...suggested, "",
272
+ "## Copyable permission config", "", `Target: \`${report.configTarget}\``, "",
273
+ "```json", report.configFragment, "```", "",
274
+ "Merge Bash patterns into the existing `permission.bash` map at this same scope. Permission-system uses last-match-wins, so place these narrow allow patterns after any broader ask rule that would match them.", "",
275
+ "## Keep ask", "",
276
+ "Denied candidates, authorization failures, structurally unsafe Bash variants, forwarded requests, and values without a valid rule remain ask-by-default.",
277
+ ...recommendationLines(report.keepAskRules), ...itemLines(report.keepAskRecommendations), "",
278
+ "## Insufficient evidence", "", ...recommendationLines(report.insufficientEvidence), "",
127
279
  "## Surface hotspots", "", ...itemLines(report.surfaces), "",
128
280
  "## Tool and command signatures", "", ...itemLines(report.tools), "",
129
281
  "## Bash semantic categories", "", ...itemLines(report.bashCategories), "",
130
282
  "## 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), "",
283
+ "## Low-risk review candidates", "", ...itemLines(report.lowRiskReviewCandidates), "",
137
284
  "## Anonymous rule-hit fingerprints", "", ...fingerprints, "",
138
285
  "## Warnings", "", ...report.warnings.map((warning) => `- ${warning}`),
139
286
  ].join("\n");
@@ -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