@erichll/pi-auto-review 0.11.1 → 0.12.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 - 2026-08-27
4
+
5
+ - Add the private policy audit
6
+ compare current-project or anonymous global surface/command hotspots, denial
7
+ rates, approval sources, and rule-hit fingerprints; then review repeatedly
8
+ approved read-only operations as candidates for narrowly scoped `allow`
9
+ rules. Mutation, project execution, network, arbitrary-shell, unknown, and
10
+ custom-tool activity is called out as keep-`ask` evidence rather than an
11
+ automatic relaxation. The report does not edit policy or claim that an
12
+ unobserved rule is unused.
13
+ - Add default-on, 180-day permission-policy auditing backed by built-in
14
+ `node:sqlite`. Permission decisions are immediately reduced to fixed command,
15
+ risk, path-class, source, and anonymous rule-hit aggregates; raw commands,
16
+ paths, URLs, credentials, request IDs, and project names are never stored.
17
+ - Add `/auto-review-policy-audit` as an extension-owned custom-entry report.
18
+ It is not registered as an Agent tool or packaged skill, so audit reporting
19
+ adds no tool schema or skill metadata to the model context. Reports default
20
+ to the current project and can explicitly show an anonymous global aggregate.
21
+ - Require Node.js 22.13.0 or newer. No SQLite CLI, system library, npm SQLite
22
+ package, RTK, or historical permission-log import is required.
23
+
3
24
  ## 0.11.1 - 2026-08-25
4
25
 
5
26
  - Align the package version with `@erichll/pi-sandbox` `0.11.1`. No
package/README.md CHANGED
@@ -22,6 +22,7 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
22
22
  - [Reviewer context and token budgets](#reviewer-context-and-token-budgets)
23
23
  - [Sandbox integration](#sandbox-integration)
24
24
  - [Trust boundary](#trust-boundary)
25
+ - [Permission policy audit](#permission-policy-audit)
25
26
  - [Telemetry](#telemetry)
26
27
  - [Real-model smoke test](#real-model-smoke-test)
27
28
 
@@ -32,6 +33,10 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
32
33
  > install the permission system separately (once per machine) before this
33
34
  > extension. Version 0.10.0 requires permission-system 27.x:
34
35
 
36
+ Node.js 22.13.0 or newer is required. Permission auditing uses Node's built-in
37
+ `node:sqlite`; it does not require a SQLite CLI, system SQLite library, or npm
38
+ SQLite package.
39
+
35
40
  ```bash
36
41
  pi install npm:@gotgenes/pi-permission-system
37
42
  pi install npm:@erichll/pi-auto-review
@@ -122,7 +127,11 @@ Package defaults:
122
127
  "breakGlassEnabled": true,
123
128
  "failureMode": "deny",
124
129
  "grantTtlMs": 60000,
125
- "autoConfirmBoundedAllows": ["external_directory", "path"]
130
+ "autoConfirmBoundedAllows": ["external_directory", "path"],
131
+ "policyAudit": {
132
+ "enabled": true,
133
+ "retentionDays": 180
134
+ }
126
135
  }
127
136
  ```
128
137
 
@@ -136,6 +145,11 @@ and grant TTL, set `failureMode` to `"deny"`, set `breakGlassEnabled` to
136
145
  select a model, raise a trusted limit, or weaken fail-closed behavior. Invalid
137
146
  configuration disables the reviewer for that session.
138
147
 
148
+ The trusted user config may set `policyAudit.enabled` and a retention of
149
+ 1–3,650 days. Project config may only set `enabled: false` or shorten the
150
+ inherited retention; it cannot re-enable globally disabled collection or
151
+ extend retention.
152
+
139
153
  ### Deadlines and retries
140
154
 
141
155
  `timeoutMs` is one deadline shared by model resolution, authentication, model
@@ -313,6 +327,44 @@ Writes to the installed reviewer package, its user-global configuration,
313
327
  project and global security configuration, and the global audit directory are
314
328
  deterministically denied.
315
329
 
330
+ ## Permission policy audit
331
+
332
+ The extension observes terminal `permissions:decision` broadcasts and stores
333
+ only daily, redacted aggregates. Collection is enabled by default and retains
334
+ 180 days. It starts when this version first initializes successfully; no
335
+ permission-system JSONL or RTK history is read or imported.
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
339
+ `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.
342
+
343
+ Data lives at
344
+ `~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
345
+ owner-only HMAC key. The directory is mode `0700`; the key, database, WAL, and
346
+ SHM are mode `0600`. Initialization, lock, write, or corruption failures disable
347
+ auditing and warn once without changing any permission result. A corrupt
348
+ database is not deleted or rebuilt automatically. Disable new collection with:
349
+
350
+ ```json
351
+ {
352
+ "policyAudit": { "enabled": false }
353
+ }
354
+ ```
355
+
356
+ Run `/auto-review-policy-audit` for a durable TUI report that is not sent to the
357
+ LLM. Options are `--days 1..retention`, `--top 1..50`, `--min-count >=1`, and
358
+ `--scope current|all`; defaults are `30`, `20`, `5`, and `current`.
359
+
360
+ The report is an extension-owned custom entry. It is deliberately not exposed
361
+ as an Agent tool or packaged skill, so neither a tool schema nor skill metadata
362
+ is added to the model context. Permission changes remain a separate, explicit
363
+ user operation.
364
+
365
+ `PI_AUTO_REVIEW_AUDIT_FILE` remains a test/release observation sink. It is not
366
+ an input to this audit and supplies no RTK token or parsing metrics.
367
+
316
368
  ## Telemetry
317
369
 
318
370
  Every actual model call emits an internal `review_attempt`; each approval emits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.11.1",
3
+ "version": "0.12.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",
@@ -24,7 +24,7 @@
24
24
  "access": "public"
25
25
  },
26
26
  "engines": {
27
- "node": ">=22"
27
+ "node": ">=22.13.0"
28
28
  },
29
29
  "files": [
30
30
  "src",
package/src/config.json CHANGED
@@ -11,5 +11,9 @@
11
11
  "breakGlassEnabled": true,
12
12
  "failureMode": "deny",
13
13
  "grantTtlMs": 60000,
14
- "autoConfirmBoundedAllows": ["external_directory", "path"]
14
+ "autoConfirmBoundedAllows": ["external_directory", "path"],
15
+ "policyAudit": {
16
+ "enabled": true,
17
+ "retentionDays": 180
18
+ }
15
19
  }
package/src/index.ts CHANGED
@@ -65,6 +65,13 @@ export {
65
65
  type SandboxRequestContext,
66
66
  } from "./integrations/sandbox.ts";
67
67
  import { parseHostPort } from "./integrations/sandbox.ts";
68
+ import {
69
+ parsePolicyAuditArguments,
70
+ PolicyAuditController,
71
+ type PermissionDecisionLike,
72
+ type PolicyAuditArguments,
73
+ type PolicyAuditConfig,
74
+ } from "./policy-audit/index.ts";
68
75
  export { parseHostPort };
69
76
 
70
77
  type ReasoningLevel =
@@ -92,6 +99,7 @@ export type Config = {
92
99
  failureMode: "deny" | "defer";
93
100
  grantTtlMs: number;
94
101
  autoConfirmBoundedAllows: readonly BoundedSurface[];
102
+ policyAudit: Readonly<PolicyAuditConfig>;
95
103
  };
96
104
 
97
105
  type CompletionMessage = {
@@ -296,6 +304,7 @@ const DEFAULT_CONFIG: Config = {
296
304
  failureMode: "deny",
297
305
  grantTtlMs: 60_000,
298
306
  autoConfirmBoundedAllows: Object.freeze(["external_directory", "path"]),
307
+ policyAudit: Object.freeze({ enabled: true, retentionDays: 180 }),
299
308
  };
300
309
 
301
310
  function writeOptionalAuditFile(event: unknown): void {
@@ -364,6 +373,7 @@ function validateConfig(value: unknown, source: string): Config {
364
373
  "failureMode",
365
374
  "grantTtlMs",
366
375
  "autoConfirmBoundedAllows",
376
+ "policyAudit",
367
377
  ]);
368
378
  const unknownKeys = Object.keys(raw).filter((key) => !allowedKeys.has(key));
369
379
  if (unknownKeys.length > 0) {
@@ -372,6 +382,10 @@ function validateConfig(value: unknown, source: string): Config {
372
382
  );
373
383
  }
374
384
  const config = { ...DEFAULT_CONFIG, ...raw };
385
+ config.policyAudit = {
386
+ ...DEFAULT_CONFIG.policyAudit,
387
+ ...(raw.policyAudit as Partial<PolicyAuditConfig> | undefined),
388
+ };
375
389
  if (
376
390
  typeof config.model !== "string" ||
377
391
  !config.model.trim() ||
@@ -453,11 +467,25 @@ function validateConfig(value: unknown, source: string): Config {
453
467
  `${EXTENSION_NAME}: maxReviewerInputTokens must be 2048..32768`,
454
468
  );
455
469
  }
470
+ if (raw.policyAudit !== undefined &&
471
+ (!raw.policyAudit || typeof raw.policyAudit !== "object" || Array.isArray(raw.policyAudit))) {
472
+ throw new Error(`${EXTENSION_NAME}: policyAudit must be an object`);
473
+ }
474
+ const policyAuditKeys = Object.keys((raw.policyAudit ?? {}) as Record<string, unknown>);
475
+ if (policyAuditKeys.some((key) => key !== "enabled" && key !== "retentionDays")) {
476
+ throw new Error(`${EXTENSION_NAME}: policyAudit only accepts enabled and retentionDays`);
477
+ }
478
+ if (typeof config.policyAudit.enabled !== "boolean" ||
479
+ !Number.isInteger(config.policyAudit.retentionDays) ||
480
+ config.policyAudit.retentionDays < 1 || config.policyAudit.retentionDays > 3_650) {
481
+ throw new Error(`${EXTENSION_NAME}: policyAudit requires enabled boolean and retentionDays 1..3650`);
482
+ }
456
483
  return {
457
484
  ...config,
458
485
  autoConfirmBoundedAllows: Object.freeze([
459
486
  ...config.autoConfirmBoundedAllows,
460
487
  ]),
488
+ policyAudit: Object.freeze({ ...config.policyAudit }),
461
489
  };
462
490
  }
463
491
 
@@ -500,11 +528,20 @@ export function applyUserConfig(
500
528
  if (!value || typeof value !== "object" || Array.isArray(value)) {
501
529
  throw new Error(`${EXTENSION_NAME}: ${source} must be an object`);
502
530
  }
531
+ const overlay = value as Record<string, unknown>;
532
+ if (overlay.policyAudit !== undefined &&
533
+ (!overlay.policyAudit || typeof overlay.policyAudit !== "object" || Array.isArray(overlay.policyAudit))) {
534
+ throw new Error(`${EXTENSION_NAME}: ${source} policyAudit must be an object`);
535
+ }
503
536
  return validateConfig(
504
537
  {
505
538
  ...packageConfig,
506
539
  autoConfirmBoundedAllows: [...packageConfig.autoConfirmBoundedAllows],
507
- ...(value as Record<string, unknown>),
540
+ ...overlay,
541
+ policyAudit: {
542
+ ...packageConfig.policyAudit,
543
+ ...(overlay.policyAudit as object | undefined),
544
+ },
508
545
  },
509
546
  source,
510
547
  );
@@ -569,6 +606,7 @@ export function applyProjectConfig(
569
606
  "breakGlassEnabled",
570
607
  "failureMode",
571
608
  "autoConfirmBoundedAllows",
609
+ "policyAudit",
572
610
  ]);
573
611
  const forbidden = Object.keys(raw).filter((key) => !allowed.has(key));
574
612
  if (forbidden.length > 0) {
@@ -625,6 +663,27 @@ export function applyProjectConfig(
625
663
  ...new Set(raw.autoConfirmBoundedAllows as BoundedSurface[]),
626
664
  ];
627
665
  }
666
+ if (raw.policyAudit !== undefined) {
667
+ if (!raw.policyAudit || typeof raw.policyAudit !== "object" || Array.isArray(raw.policyAudit)) {
668
+ throw new Error(`${EXTENSION_NAME}: project policyAudit must be an object`);
669
+ }
670
+ const audit = raw.policyAudit as Record<string, unknown>;
671
+ const forbiddenAuditKeys = Object.keys(audit).filter((key) => key !== "enabled" && key !== "retentionDays");
672
+ if (forbiddenAuditKeys.length > 0) {
673
+ throw new Error(`${EXTENSION_NAME}: project policyAudit cannot set: ${forbiddenAuditKeys.join(", ")}`);
674
+ }
675
+ if (audit.enabled !== undefined && audit.enabled !== false) {
676
+ throw new Error(`${EXTENSION_NAME}: project policyAudit may only disable collection`);
677
+ }
678
+ if (audit.retentionDays !== undefined &&
679
+ (!Number.isInteger(audit.retentionDays) || Number(audit.retentionDays) < 1 || Number(audit.retentionDays) > trusted.policyAudit.retentionDays)) {
680
+ throw new Error(`${EXTENSION_NAME}: project policyAudit.retentionDays may only shorten retention`);
681
+ }
682
+ merged.policyAudit = {
683
+ enabled: audit.enabled === false ? false : trusted.policyAudit.enabled,
684
+ retentionDays: audit.retentionDays === undefined ? trusted.policyAudit.retentionDays : Number(audit.retentionDays),
685
+ };
686
+ }
628
687
  return Object.freeze(validateConfig(merged, "effective project config"));
629
688
  }
630
689
 
@@ -1901,6 +1960,33 @@ export type PiAutoReviewExtensionOptions = {
1901
1960
  allowUntrustedWorkspace?: boolean;
1902
1961
  };
1903
1962
 
1963
+ const POLICY_AUDIT_ENTRY_TYPE = "pi-auto-review-policy-audit";
1964
+
1965
+ function renderPolicyAuditEntry(
1966
+ entry: { data?: unknown },
1967
+ _options: unknown,
1968
+ theme: { fg(color: string, text: string): string },
1969
+ ): { render(width: number): string[]; invalidate(): void } | undefined {
1970
+ const markdown = entry.data && typeof entry.data === "object" &&
1971
+ typeof (entry.data as { markdown?: unknown }).markdown === "string"
1972
+ ? (entry.data as { markdown: string }).markdown
1973
+ : undefined;
1974
+ if (!markdown) return undefined;
1975
+ return {
1976
+ render(width: number) {
1977
+ const max = Math.max(20, width - 2);
1978
+ return markdown.split("\n").flatMap((line) => {
1979
+ const output: string[] = [];
1980
+ for (let offset = 0; offset < Math.max(1, line.length); offset += max) {
1981
+ output.push(theme.fg("muted", line.slice(offset, offset + max)));
1982
+ }
1983
+ return output;
1984
+ });
1985
+ },
1986
+ invalidate() {},
1987
+ };
1988
+ }
1989
+
1904
1990
  export function createPiAutoReviewExtension(
1905
1991
  options: PiAutoReviewExtensionOptions = {},
1906
1992
  ): (pi: ExtensionAPI) => void {
@@ -1916,6 +2002,7 @@ export function createPiAutoReviewExtension(
1916
2002
  return (pi: ExtensionAPI): void => {
1917
2003
  try {
1918
2004
  pi.registerEntryRenderer(USER_REVIEW_ENTRY_TYPE, renderUserReviewEntry);
2005
+ pi.registerEntryRenderer(POLICY_AUDIT_ENTRY_TYPE, renderPolicyAuditEntry);
1919
2006
  } catch {
1920
2007
  // Renderer registration is observational.
1921
2008
  }
@@ -1936,6 +2023,36 @@ export function createPiAutoReviewExtension(
1936
2023
  () => config.autoConfirmBoundedAllows,
1937
2024
  );
1938
2025
  const reviewWidget = new UserReviewWidgetController();
2026
+ const policyAudit = new PolicyAuditController({
2027
+ config: () => config.policyAudit,
2028
+ cwd: () => context?.cwd,
2029
+ warn: (message) => {
2030
+ console.error(message);
2031
+ notifyUserReview(context, { type: "warning", message });
2032
+ },
2033
+ });
2034
+
2035
+ const runPolicyAuditReport = async (args: PolicyAuditArguments) =>
2036
+ policyAudit.report(args);
2037
+
2038
+ pi.registerCommand("auto-review-policy-audit", {
2039
+ description: "Show a persistent, redacted permission-policy audit report",
2040
+ handler: async (rawArgs, ctx) => {
2041
+ try {
2042
+ const args = parsePolicyAuditArguments(rawArgs, config.policyAudit.retentionDays);
2043
+ const result = await runPolicyAuditReport(args);
2044
+ pi.appendEntry(POLICY_AUDIT_ENTRY_TYPE, {
2045
+ markdown: result.markdown,
2046
+ report: result.report,
2047
+ });
2048
+ } catch (error) {
2049
+ ctx.ui.notify(
2050
+ `Permission policy audit unavailable: ${error instanceof Error ? error.message : String(error)}`,
2051
+ "warning",
2052
+ );
2053
+ }
2054
+ },
2055
+ });
1939
2056
 
1940
2057
  const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
1941
2058
  writeOptionalAuditFile(event);
@@ -2288,6 +2405,7 @@ export function createPiAutoReviewExtension(
2288
2405
  );
2289
2406
  context = ctx;
2290
2407
  broker = createBroker();
2408
+ policyAudit.warmup();
2291
2409
  try {
2292
2410
  disposeBrokerService = publishBoundaryBroker(broker);
2293
2411
  } catch (error) {
@@ -2320,6 +2438,7 @@ export function createPiAutoReviewExtension(
2320
2438
 
2321
2439
  pi.events.on("permissions:decision", (event) => {
2322
2440
  reviewWidget.permissionDecision(event);
2441
+ policyAudit.record(event as PermissionDecisionLike);
2323
2442
  });
2324
2443
 
2325
2444
  pi.events.on("permissions:ready", (event) => {
@@ -2516,7 +2635,7 @@ export function createPiAutoReviewExtension(
2516
2635
  }
2517
2636
  });
2518
2637
 
2519
- pi.on("session_shutdown", () => {
2638
+ pi.on("session_shutdown", async () => {
2520
2639
  shuttingDown = true;
2521
2640
  registrationEpoch++;
2522
2641
  reviewWidget.clear(context);
@@ -2530,6 +2649,7 @@ export function createPiAutoReviewExtension(
2530
2649
  reviewResults.clear();
2531
2650
  uiAutoConfirmer.clear();
2532
2651
  context = undefined;
2652
+ await policyAudit.close();
2533
2653
  });
2534
2654
  };
2535
2655
  }
@@ -0,0 +1,234 @@
1
+ import { homedir, tmpdir } from "node:os";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+
4
+ export type PolicyAuditRisk =
5
+ | "read_only"
6
+ | "workspace_mutation"
7
+ | "project_code_execution"
8
+ | "network"
9
+ | "network_mutation"
10
+ | "arbitrary_shell"
11
+ | "unknown";
12
+
13
+ export type PolicyAuditPathClass =
14
+ | "workspace"
15
+ | "temp"
16
+ | "home"
17
+ | "external"
18
+ | "sensitive"
19
+ | "unknown";
20
+
21
+ export type PolicyAuditCommandFeature =
22
+ | "env_prefix"
23
+ | "compound"
24
+ | "pipeline"
25
+ | "relative_executable";
26
+
27
+ export type ClassifiedPermission = {
28
+ surface: string;
29
+ signature: string;
30
+ bashCategory: string;
31
+ risk: PolicyAuditRisk;
32
+ pathClass: PolicyAuditPathClass;
33
+ features: readonly PolicyAuditCommandFeature[];
34
+ };
35
+
36
+ const KNOWN_SURFACES = new Set([
37
+ "bash",
38
+ "bash_escalated",
39
+ "read",
40
+ "write",
41
+ "edit",
42
+ "grep",
43
+ "find",
44
+ "ls",
45
+ "path",
46
+ "external_directory",
47
+ "skill",
48
+ "mcp",
49
+ "network",
50
+ ]);
51
+
52
+ const SENSITIVE_PATH = /(?:^|[/\\])(?:\.ssh|\.aws|\.gnupg|\.kube|\.azure|\.npmrc|\.netrc|\.env(?:\.[^/\\]+)?|auth\.json|credentials?)(?:[/\\]|$)/i;
53
+ const READ_ONLY_COMMANDS = new Set([
54
+ "cat", "find", "grep", "head", "ls", "pwd", "rg", "sed", "sort",
55
+ "stat", "tail", "wc", "which",
56
+ ]);
57
+ const MUTATING_COMMANDS = new Set([
58
+ "chmod", "chown", "cp", "install", "ln", "mkdir", "mv", "rm", "rmdir",
59
+ "touch", "truncate",
60
+ ]);
61
+ const NETWORK_COMMANDS = new Set(["curl", "dig", "host", "nc", "ping", "ssh", "wget"]);
62
+ const SHELL_COMMANDS = new Set(["bash", "dash", "fish", "sh", "zsh"]);
63
+ const GIT_ACTIONS = new Set([
64
+ "add", "branch", "checkout", "clean", "clone", "commit", "diff", "fetch",
65
+ "log", "merge", "pull", "push", "rebase", "remote", "reset", "restore",
66
+ "send-pack", "show", "status", "switch", "tag",
67
+ ]);
68
+ const RTK_ACTIONS = new Set(["diff", "find", "grep", "ls", "read", "rg", "smart", "wc"]);
69
+ const PACKAGE_ACTIONS = new Set([
70
+ "add", "build", "check", "coverage", "dev", "format", "install", "lint",
71
+ "publish", "start", "test", "typecheck", "uninstall", "update", "upgrade",
72
+ ]);
73
+
74
+ function within(path: string, parent: string): boolean {
75
+ const rel = relative(parent, path);
76
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
77
+ }
78
+
79
+ export function classifyPath(
80
+ value: unknown,
81
+ cwd: string,
82
+ home = homedir(),
83
+ temp = tmpdir(),
84
+ ): PolicyAuditPathClass {
85
+ if (typeof value !== "string" || !value.trim()) return "unknown";
86
+ const raw = value.trim();
87
+ if (SENSITIVE_PATH.test(raw)) return "sensitive";
88
+ if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith(".")) {
89
+ return "unknown";
90
+ }
91
+ const expanded = raw === "~" || raw.startsWith("~/")
92
+ ? resolve(home, raw.slice(2))
93
+ : resolve(cwd, raw);
94
+ if (within(expanded, resolve(cwd))) return "workspace";
95
+ if (within(expanded, resolve(temp))) return "temp";
96
+ if (within(expanded, resolve(home))) return "home";
97
+ return "external";
98
+ }
99
+
100
+ function shellTokens(command: string): string[] {
101
+ return command
102
+ .trim()
103
+ .split(/\s+/u)
104
+ .map((token) => token.replace(/^["']|["']$/g, ""));
105
+ }
106
+
107
+ function executableIndex(tokens: readonly string[]): number {
108
+ let index = 0;
109
+ if (tokens[index] === "env") index++;
110
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) {
111
+ index++;
112
+ }
113
+ return index;
114
+ }
115
+
116
+ function safeCommandName(token: string | undefined): string {
117
+ if (!token) return "unknown";
118
+ const name = token.split(/[\\/]/).at(-1)?.toLowerCase() || "unknown";
119
+ return /^[a-z0-9][a-z0-9+._-]{0,31}$/.test(name) ? name : "unknown";
120
+ }
121
+
122
+ function knownSignature(tokens: readonly string[], index: number): string {
123
+ const executable = safeCommandName(tokens[index]);
124
+ const sub = safeCommandName(tokens[index + 1]);
125
+ const third = safeCommandName(tokens[index + 2]);
126
+ if (executable === "rtk") {
127
+ if (sub === "git") return `rtk:git:${GIT_ACTIONS.has(third) ? third : "other"}`;
128
+ return `rtk:${RTK_ACTIONS.has(sub) ? sub : "other"}`;
129
+ }
130
+ if (executable === "git") return `git:${GIT_ACTIONS.has(sub) ? sub : "other"}`;
131
+ if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
132
+ const action = sub === "run" ? third : sub;
133
+ return `${executable}:${PACKAGE_ACTIONS.has(action) ? action : "custom-script"}`;
134
+ }
135
+ if (READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable) ||
136
+ NETWORK_COMMANDS.has(executable) || SHELL_COMMANDS.has(executable)) {
137
+ return executable;
138
+ }
139
+ if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) {
140
+ return "<path-command>";
141
+ }
142
+ return "<other-command>";
143
+ }
144
+
145
+ function commandRisk(signature: string, executable: string, command: string): PolicyAuditRisk {
146
+ if (/&&|\|\||;|\n|`|\$\(|(^|[^|])\|([^|]|$)/.test(command)) return "arbitrary_shell";
147
+ if (["git:push", "git:send-pack", "rtk:git:push", "rtk:git:send-pack"].includes(signature)) return "network_mutation";
148
+ if (/^(?:npm|pnpm|yarn|bun):(install|publish|add|upgrade|update)$/.test(signature)) {
149
+ return signature.endsWith(":publish") ? "network_mutation" : "project_code_execution";
150
+ }
151
+ if (/^(?:npm|pnpm|yarn|bun):/.test(signature)) return "project_code_execution";
152
+ if (NETWORK_COMMANDS.has(executable)) {
153
+ return /(?:^|\s)(?:--upload-file|-T|-X\s*(?:POST|PUT|PATCH|DELETE)|--data|-d)(?:\s|=|$)/i.test(command)
154
+ ? "network_mutation"
155
+ : "network";
156
+ }
157
+ 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)) {
158
+ return "workspace_mutation";
159
+ }
160
+ if (READ_ONLY_COMMANDS.has(executable) || /^(?:rtk:)?git:(?:status|diff|log|show|branch)$/.test(signature)) {
161
+ return "read_only";
162
+ }
163
+ if (SHELL_COMMANDS.has(executable) || signature === "<path-command>") {
164
+ return "arbitrary_shell";
165
+ }
166
+ return "unknown";
167
+ }
168
+
169
+ export function classifyBash(command: unknown, cwd: string): ClassifiedPermission {
170
+ if (typeof command !== "string" || !command.trim()) {
171
+ return { surface: "bash", signature: "<other-command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
172
+ }
173
+ const tokens = shellTokens(command);
174
+ const index = executableIndex(tokens);
175
+ const executable = safeCommandName(tokens[index]);
176
+ const signature = knownSignature(tokens, index);
177
+ const features: PolicyAuditCommandFeature[] = [];
178
+ if (index > 0) features.push("env_prefix");
179
+ if (/&&|\|\||;|\n|`|\$\(/.test(command)) features.push("compound");
180
+ if (/(^|[^|])\|([^|]|$)/.test(command)) features.push("pipeline");
181
+ if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) features.push("relative_executable");
182
+ const bashCategory = /^(?:rtk:)?git:/.test(signature)
183
+ ? "version_control"
184
+ : /^(?:npm|pnpm|yarn|bun):/.test(signature)
185
+ ? "package_script"
186
+ : NETWORK_COMMANDS.has(executable)
187
+ ? "network"
188
+ : READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable)
189
+ ? "filesystem"
190
+ : "shell";
191
+ const pathToken = tokens.slice(index + 1)
192
+ .map((token) => token.includes("=") ? token.slice(token.indexOf("=") + 1) : token.replace(/^@/, ""))
193
+ .find((token) => token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.startsWith("~/"));
194
+ return {
195
+ surface: "bash",
196
+ signature,
197
+ bashCategory,
198
+ risk: commandRisk(signature, executable, command),
199
+ pathClass: classifyPath(pathToken, cwd),
200
+ features: [...new Set(features)].sort(),
201
+ };
202
+ }
203
+
204
+ export function classifyPermission(surfaceValue: unknown, value: unknown, cwd: string): ClassifiedPermission {
205
+ const rawSurface = typeof surfaceValue === "string" ? surfaceValue.trim().toLowerCase() : "";
206
+ if (rawSurface === "bash" || rawSurface === "bash_escalated") {
207
+ const classified = classifyBash(value, cwd);
208
+ return { ...classified, surface: rawSurface || "bash" };
209
+ }
210
+ const surface = KNOWN_SURFACES.has(rawSurface) ? rawSurface : "custom_tool";
211
+ if (["path", "external_directory", "read", "write", "edit"].includes(surface)) {
212
+ const pathClass = classifyPath(value, cwd);
213
+ const risk = surface === "read"
214
+ ? "read_only"
215
+ : surface === "write" || surface === "edit"
216
+ ? "workspace_mutation"
217
+ : "unknown";
218
+ return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk, pathClass, features: [] };
219
+ }
220
+ if (["grep", "find", "ls"].includes(surface)) {
221
+ return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk: "read_only", pathClass: "unknown", features: [] };
222
+ }
223
+ if (surface === "network") {
224
+ return { surface, signature: "<network>", bashCategory: "not_bash", risk: "network", pathClass: "unknown", features: [] };
225
+ }
226
+ return {
227
+ surface,
228
+ signature: surface === "custom_tool" ? "<custom-tool>" : `<${surface}>`,
229
+ bashCategory: "not_bash",
230
+ risk: "unknown",
231
+ pathClass: "unknown",
232
+ features: [],
233
+ };
234
+ }
@@ -0,0 +1,172 @@
1
+ import { homedir } from "node:os";
2
+ import { createHash } from "node:crypto";
3
+ import { classifyPermission } from "./classifier.ts";
4
+ import { buildPolicyAuditReport, renderPolicyAuditMarkdown, type PolicyAuditReport } from "./report.ts";
5
+ import { defaultPolicyAuditDirectory, PolicyAuditStore, type PolicyAuditStoreOptions } from "./store.ts";
6
+
7
+ export * from "./classifier.ts";
8
+ export * from "./report.ts";
9
+ export * from "./store.ts";
10
+
11
+ export type PolicyAuditConfig = { enabled: boolean; retentionDays: number };
12
+
13
+ export type PermissionDecisionLike = {
14
+ requestId?: unknown;
15
+ surface?: unknown;
16
+ value?: unknown;
17
+ result?: unknown;
18
+ resolution?: unknown;
19
+ origin?: unknown;
20
+ matchedPattern?: unknown;
21
+ forwarding?: unknown;
22
+ };
23
+
24
+ export type PolicyAuditArguments = { days: number; top: number; minCount: number; scope: "current" | "all" };
25
+
26
+ const PERMISSION_RESOLUTIONS = new Set([
27
+ "policy_allow",
28
+ "policy_deny",
29
+ "session_approved",
30
+ "infrastructure_auto_allowed",
31
+ "user_approved",
32
+ "user_approved_for_session",
33
+ "user_denied",
34
+ "auto_approved",
35
+ "confirmation_unavailable",
36
+ "gate_error",
37
+ ]);
38
+
39
+ export function parsePolicyAuditArguments(raw: string, retentionDays: number): PolicyAuditArguments {
40
+ const result: PolicyAuditArguments = { days: Math.min(30, retentionDays), top: 20, minCount: 5, scope: "current" };
41
+ const tokens = raw.trim() ? raw.trim().split(/\s+/u) : [];
42
+ for (let index = 0; index < tokens.length; index++) {
43
+ const token = tokens[index];
44
+ const [flag, inline] = token.split("=", 2);
45
+ const value = inline ?? tokens[++index];
46
+ if (!value) throw new Error(`missing value for ${flag}`);
47
+ if (flag === "--scope") {
48
+ if (value !== "current" && value !== "all") throw new Error("--scope must be current or all");
49
+ result.scope = value;
50
+ continue;
51
+ }
52
+ const numeric = Number(value);
53
+ if (!Number.isInteger(numeric)) throw new Error(`${flag} must be an integer`);
54
+ if (flag === "--days" && numeric >= 1 && numeric <= retentionDays) result.days = numeric;
55
+ else if (flag === "--top" && numeric >= 1 && numeric <= 50) result.top = numeric;
56
+ else if (flag === "--min-count" && numeric >= 1) result.minCount = numeric;
57
+ else if (!["--days", "--top", "--min-count"].includes(flag)) throw new Error(`unknown option ${flag}`);
58
+ else throw new Error(`${flag} is outside its allowed range`);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ export type PolicyAuditControllerOptions = {
64
+ config: () => Readonly<PolicyAuditConfig>;
65
+ cwd: () => string | undefined;
66
+ directory?: string;
67
+ warn: (message: string) => void;
68
+ storeOptions?: Omit<PolicyAuditStoreOptions, "directory" | "retentionDays">;
69
+ };
70
+
71
+ export class PolicyAuditController {
72
+ private storePromise?: Promise<PolicyAuditStore>;
73
+ private disabled = false;
74
+ private warned = false;
75
+ private queue = Promise.resolve();
76
+
77
+ constructor(private readonly options: PolicyAuditControllerOptions) {}
78
+
79
+ warmup(): void {
80
+ if (this.options.config().enabled) void this.getStore().catch(() => undefined);
81
+ }
82
+
83
+ private failure(error: unknown): never {
84
+ this.disabled = true;
85
+ const message = `pi-auto-review: policy audit disabled: ${error instanceof Error ? error.message : String(error)}`;
86
+ if (!this.warned) {
87
+ this.warned = true;
88
+ this.options.warn(message);
89
+ }
90
+ throw error;
91
+ }
92
+
93
+ private async getStore(): Promise<PolicyAuditStore> {
94
+ if (this.disabled) throw new Error("policy audit is unavailable");
95
+ if (!this.storePromise) {
96
+ const config = this.options.config();
97
+ this.storePromise = PolicyAuditStore.open({
98
+ directory: this.options.directory ?? defaultPolicyAuditDirectory(homedir()),
99
+ retentionDays: config.retentionDays,
100
+ ...this.options.storeOptions,
101
+ }).catch((error) => this.failure(error));
102
+ }
103
+ return this.storePromise;
104
+ }
105
+
106
+ record(event: PermissionDecisionLike): void {
107
+ const config = this.options.config();
108
+ const cwd = this.options.cwd();
109
+ if (!config.enabled || this.disabled || !cwd) return;
110
+ if (typeof event.requestId !== "string" ||
111
+ (event.result !== "allow" && event.result !== "deny") ||
112
+ typeof event.resolution !== "string") return;
113
+ const result: "allow" | "deny" = event.result;
114
+ // The raw value is classified synchronously and is never captured by the
115
+ // asynchronous queue or handed to SQLite.
116
+ const classified = classifyPermission(event.surface, event.value, cwd);
117
+ const digest = (kind: string, value: string) =>
118
+ createHash("sha256").update(`${kind}\0${value}`).digest("hex");
119
+ const rawOrigin = typeof event.origin === "string" ? event.origin.toLowerCase() : "";
120
+ const origin = rawOrigin.includes("project")
121
+ ? "project"
122
+ : rawOrigin.includes("user") || rawOrigin.includes("global")
123
+ ? "user"
124
+ : rawOrigin.includes("default") || rawOrigin.includes("package")
125
+ ? "default"
126
+ : rawOrigin ? "other" : "none";
127
+ const sanitized = {
128
+ ...classified,
129
+ requestId: digest("request", event.requestId),
130
+ result,
131
+ resolution: PERMISSION_RESOLUTIONS.has(event.resolution) ? event.resolution : "unknown",
132
+ origin,
133
+ forwarded: event.forwarding !== null && event.forwarding !== undefined,
134
+ matchedPattern: typeof event.matchedPattern === "string"
135
+ ? digest("rule", event.matchedPattern)
136
+ : undefined,
137
+ };
138
+ this.queue = this.queue
139
+ .then(async () => (await this.getStore()).record(cwd, sanitized))
140
+ .then(() => undefined)
141
+ .catch((error) => {
142
+ try { this.failure(error); } catch { /* observational failure is contained */ }
143
+ });
144
+ }
145
+
146
+ async report(args: PolicyAuditArguments): Promise<{ report: PolicyAuditReport; markdown: string }> {
147
+ const config = this.options.config();
148
+ if (!config.enabled && !this.storePromise) throw new Error("policy audit collection is disabled by configuration");
149
+ if (args.days > config.retentionDays) throw new Error(`days must be 1..${config.retentionDays}`);
150
+ await this.queue;
151
+ const cwd = this.options.cwd();
152
+ if (!cwd) throw new Error("policy audit has no active project context");
153
+ const store = await this.getStore();
154
+ let result;
155
+ try {
156
+ result = store.query({ ...args, projectPath: cwd });
157
+ } catch (error) {
158
+ return this.failure(error);
159
+ }
160
+ const report = buildPolicyAuditReport(result, args);
161
+ return { report, markdown: renderPolicyAuditMarkdown(report) };
162
+ }
163
+
164
+ async close(): Promise<void> {
165
+ await this.queue;
166
+ // Detach before awaiting so concurrent shutdowns cannot double-close.
167
+ const storePromise = this.storePromise;
168
+ this.storePromise = undefined;
169
+ const store = await storePromise?.catch(() => undefined);
170
+ store?.close();
171
+ }
172
+ }
@@ -0,0 +1,140 @@
1
+ import type { PolicyAuditAggregateRow, PolicyAuditQueryResult } from "./store.ts";
2
+
3
+ export const POLICY_AUDIT_REPORT_VERSION = 1 as const;
4
+
5
+ export type PolicyAuditReportItem = { name: string; count: number; denied: number; denialRate: number };
6
+
7
+ export type PolicyAuditReport = {
8
+ version: typeof POLICY_AUDIT_REPORT_VERSION;
9
+ scope: "current" | "all";
10
+ days: number;
11
+ collectingSince: string;
12
+ fromDay: string;
13
+ throughDay: string;
14
+ total: number;
15
+ allowed: number;
16
+ denied: number;
17
+ denialRate: number;
18
+ surfaces: PolicyAuditReportItem[];
19
+ tools: PolicyAuditReportItem[];
20
+ bashCategories: PolicyAuditReportItem[];
21
+ approvalSources: PolicyAuditReportItem[];
22
+ lowRiskReviewCandidates: PolicyAuditReportItem[];
23
+ keepAskRecommendations: PolicyAuditReportItem[];
24
+ ruleFingerprints: Array<{ fingerprint: string; count: number }>;
25
+ warnings: string[];
26
+ };
27
+
28
+ export type PolicyAuditReportOptions = {
29
+ scope: "current" | "all";
30
+ days: number;
31
+ top: number;
32
+ minCount: number;
33
+ };
34
+
35
+ function rate(denied: number, total: number): number {
36
+ return total === 0 ? 0 : Math.round((denied / total) * 10_000) / 100;
37
+ }
38
+
39
+ function rank(
40
+ rows: readonly PolicyAuditAggregateRow[],
41
+ key: (row: PolicyAuditAggregateRow) => string,
42
+ top: number,
43
+ minCount: number,
44
+ ): PolicyAuditReportItem[] {
45
+ const grouped = new Map<string, { count: number; denied: number }>();
46
+ for (const row of rows) {
47
+ const name = key(row);
48
+ const current = grouped.get(name) ?? { count: 0, denied: 0 };
49
+ current.count += row.count;
50
+ if (row.result === "deny") current.denied += row.count;
51
+ grouped.set(name, current);
52
+ }
53
+ return [...grouped]
54
+ .map(([name, value]) => ({ name, ...value, denialRate: rate(value.denied, value.count) }))
55
+ .filter((item) => item.count >= minCount)
56
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
57
+ .slice(0, top);
58
+ }
59
+
60
+ function reviewed(row: PolicyAuditAggregateRow): boolean {
61
+ return ["user_approved", "user_approved_for_session", "user_denied", "auto_approved"].includes(row.resolution);
62
+ }
63
+
64
+ export function buildPolicyAuditReport(
65
+ result: PolicyAuditQueryResult,
66
+ options: PolicyAuditReportOptions,
67
+ ): PolicyAuditReport {
68
+ const total = result.rows.reduce((sum, row) => sum + row.count, 0);
69
+ const denied = result.rows.filter((row) => row.result === "deny").reduce((sum, row) => sum + row.count, 0);
70
+ 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
+ );
74
+ const fingerprintCounts = new Map<string, number>();
75
+ for (const row of result.rows) {
76
+ if (row.ruleFingerprint === "none") continue;
77
+ fingerprintCounts.set(row.ruleFingerprint, (fingerprintCounts.get(row.ruleFingerprint) ?? 0) + row.count);
78
+ }
79
+ const warnings = [
80
+ "Counts begin at first successful enablement; no historical logs are imported.",
81
+ "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.",
83
+ ];
84
+ if (total === 0) warnings.unshift("No matching permission decisions were collected for this window and scope.");
85
+ return {
86
+ version: POLICY_AUDIT_REPORT_VERSION,
87
+ scope: options.scope,
88
+ days: options.days,
89
+ collectingSince: result.collectingSince,
90
+ fromDay: result.fromDay,
91
+ throughDay: result.throughDay,
92
+ total,
93
+ allowed: total - denied,
94
+ denied,
95
+ denialRate: rate(denied, total),
96
+ surfaces: rank(result.rows, (row) => row.surface, options.top, options.minCount),
97
+ tools: rank(result.rows, (row) => row.signature, options.top, options.minCount),
98
+ bashCategories: rank(result.rows.filter((row) => row.bashCategory !== "not_bash"), (row) => row.bashCategory, options.top, options.minCount),
99
+ approvalSources: rank(result.rows, (row) => `${row.resolution}/${row.origin}${row.forwarded ? "/forwarded" : ""}`, options.top, options.minCount),
100
+ lowRiskReviewCandidates: rank(lowRisk, (row) => `${row.surface}:${row.signature}`, options.top, options.minCount),
101
+ keepAskRecommendations: rank(keepAsk, (row) => `${row.risk}:${row.surface}:${row.signature}`, options.top, options.minCount),
102
+ ruleFingerprints: [...fingerprintCounts]
103
+ .map(([fingerprint, count]) => ({ fingerprint, count }))
104
+ .filter((item) => item.count >= options.minCount)
105
+ .sort((a, b) => b.count - a.count || a.fingerprint.localeCompare(b.fingerprint))
106
+ .slice(0, options.top),
107
+ warnings,
108
+ };
109
+ }
110
+
111
+ function itemLines(items: readonly PolicyAuditReportItem[]): string[] {
112
+ return items.length === 0
113
+ ? ["- None above the minimum count."]
114
+ : items.map((item) => `- ${item.name}: ${item.count} (${item.denied} denied, ${item.denialRate}% denial)`);
115
+ }
116
+
117
+ 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}`);
121
+ 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
+ "",
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}`),
139
+ ].join("\n");
140
+ }
@@ -0,0 +1,298 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ constants,
6
+ existsSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import type { ClassifiedPermission } from "./classifier.ts";
14
+
15
+ export const POLICY_AUDIT_SCHEMA_VERSION = 1;
16
+ export const POLICY_AUDIT_DATABASE_NAME = "policy-audit.sqlite";
17
+ export const POLICY_AUDIT_KEY_NAME = "policy-audit.key";
18
+
19
+ type RunResult = { changes: number | bigint };
20
+ type Statement = {
21
+ run(...params: unknown[]): RunResult;
22
+ get(...params: unknown[]): Record<string, unknown> | undefined;
23
+ all(...params: unknown[]): Record<string, unknown>[];
24
+ };
25
+ type Database = {
26
+ exec(sql: string): void;
27
+ prepare(sql: string): Statement;
28
+ close(): void;
29
+ };
30
+ type SqliteModule = { DatabaseSync: new (path: string, options?: Record<string, unknown>) => Database };
31
+
32
+ export type SanitizedPermissionDecision = ClassifiedPermission & {
33
+ requestId: string;
34
+ result: "allow" | "deny";
35
+ resolution: string;
36
+ origin: string;
37
+ forwarded: boolean;
38
+ matchedPattern?: string;
39
+ };
40
+
41
+ export type PolicyAuditStoreOptions = {
42
+ directory: string;
43
+ retentionDays: number;
44
+ now?: () => Date;
45
+ sqliteLoader?: () => Promise<SqliteModule>;
46
+ };
47
+
48
+ export type PolicyAuditQuery = {
49
+ days: number;
50
+ top: number;
51
+ minCount: number;
52
+ scope: "current" | "all";
53
+ projectPath: string;
54
+ };
55
+
56
+ export type PolicyAuditAggregateRow = {
57
+ surface: string;
58
+ signature: string;
59
+ bashCategory: string;
60
+ risk: string;
61
+ pathClass: string;
62
+ result: string;
63
+ resolution: string;
64
+ origin: string;
65
+ forwarded: boolean;
66
+ features: string;
67
+ ruleFingerprint: string;
68
+ count: number;
69
+ };
70
+
71
+ export type PolicyAuditQueryResult = {
72
+ collectingSince: string;
73
+ fromDay: string;
74
+ throughDay: string;
75
+ rows: PolicyAuditAggregateRow[];
76
+ };
77
+
78
+ function dayString(date: Date): string {
79
+ return date.toISOString().slice(0, 10);
80
+ }
81
+
82
+ function addDays(date: Date, delta: number): Date {
83
+ const copy = new Date(date);
84
+ copy.setUTCDate(copy.getUTCDate() + delta);
85
+ return copy;
86
+ }
87
+
88
+ function loadOrCreateKey(directory: string): Buffer {
89
+ const keyPath = join(directory, POLICY_AUDIT_KEY_NAME);
90
+ try {
91
+ const fd = openSync(keyPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
92
+ try {
93
+ writeFileSync(fd, randomBytes(32));
94
+ } finally {
95
+ closeSync(fd);
96
+ }
97
+ } catch (error) {
98
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
99
+ throw error;
100
+ }
101
+ }
102
+ chmodSync(keyPath, 0o600);
103
+ const key = readFileSync(keyPath);
104
+ if (key.length !== 32) throw new Error("policy audit key has an invalid length");
105
+ return key;
106
+ }
107
+
108
+ function normalizeOrigin(value: string): string {
109
+ const lowered = value.toLowerCase();
110
+ if (lowered.includes("project")) return "project";
111
+ if (lowered.includes("user") || lowered.includes("global")) return "user";
112
+ if (lowered.includes("default") || lowered.includes("package")) return "default";
113
+ return value === "none" ? "none" : "other";
114
+ }
115
+
116
+ export class PolicyAuditStore {
117
+ static async open(options: PolicyAuditStoreOptions): Promise<PolicyAuditStore> {
118
+ mkdirSync(options.directory, { recursive: true, mode: 0o700 });
119
+ chmodSync(options.directory, 0o700);
120
+ const key = loadOrCreateKey(options.directory);
121
+ const sqlite = await (options.sqliteLoader ?? (() => import("node:sqlite") as Promise<SqliteModule>))();
122
+ const databasePath = join(options.directory, POLICY_AUDIT_DATABASE_NAME);
123
+ const db = new sqlite.DatabaseSync(databasePath);
124
+ chmodSync(databasePath, 0o600);
125
+ const store = new PolicyAuditStore(db, key, databasePath, options.retentionDays, options.now ?? (() => new Date()));
126
+ try {
127
+ store.initialize();
128
+ return store;
129
+ } catch (error) {
130
+ try { db.close(); } catch { /* preserve initialization error */ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ private constructor(
136
+ private readonly db: Database,
137
+ private readonly key: Buffer,
138
+ private readonly databasePath: string,
139
+ private readonly retentionDays: number,
140
+ private readonly now: () => Date,
141
+ private closed = false,
142
+ ) {}
143
+
144
+ private initialize(): void {
145
+ this.db.exec("PRAGMA busy_timeout=75; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;");
146
+ this.db.exec(`
147
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
148
+ CREATE TABLE IF NOT EXISTS seen_requests (
149
+ request_hash TEXT PRIMARY KEY,
150
+ seen_day TEXT NOT NULL
151
+ ) STRICT;
152
+ CREATE TABLE IF NOT EXISTS daily_permission_stats (
153
+ day TEXT NOT NULL,
154
+ project_hash TEXT NOT NULL,
155
+ surface TEXT NOT NULL,
156
+ signature TEXT NOT NULL,
157
+ bash_category TEXT NOT NULL,
158
+ risk TEXT NOT NULL,
159
+ path_class TEXT NOT NULL,
160
+ result TEXT NOT NULL,
161
+ resolution TEXT NOT NULL,
162
+ origin TEXT NOT NULL,
163
+ forwarded INTEGER NOT NULL,
164
+ features TEXT NOT NULL,
165
+ rule_fingerprint TEXT NOT NULL,
166
+ count INTEGER NOT NULL,
167
+ PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint)
168
+ ) STRICT;
169
+ CREATE INDEX IF NOT EXISTS daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
170
+ `);
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");
174
+ }
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
+ }
180
+
181
+ private hash(kind: string, value: string): string {
182
+ return createHmac("sha256", this.key).update(`${kind}\0${value}`).digest("hex");
183
+ }
184
+
185
+ projectHash(projectPath: string): string {
186
+ return this.hash("project", resolve(projectPath));
187
+ }
188
+
189
+ ruleFingerprint(pattern: string | undefined): string {
190
+ return pattern ? this.hash("rule", pattern).slice(0, 16) : "none";
191
+ }
192
+
193
+ private retry<T>(operation: () => T): T {
194
+ let last: unknown;
195
+ for (let attempt = 0; attempt < 3; attempt++) {
196
+ try {
197
+ return operation();
198
+ } catch (error) {
199
+ last = error;
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ if (!/busy|locked/i.test(message) || attempt === 2) throw error;
202
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * (attempt + 1));
203
+ }
204
+ }
205
+ throw last;
206
+ }
207
+
208
+ record(projectPath: string, event: SanitizedPermissionDecision): boolean {
209
+ return this.retry(() => {
210
+ const day = dayString(this.now());
211
+ this.pruneIfNeeded(day);
212
+ this.db.exec("BEGIN IMMEDIATE");
213
+ try {
214
+ const requestHash = this.hash("request", event.requestId);
215
+ const inserted = this.db.prepare("INSERT OR IGNORE INTO seen_requests(request_hash,seen_day) VALUES(?,?)").run(requestHash, day);
216
+ if (Number(inserted.changes) === 0) {
217
+ this.db.exec("COMMIT");
218
+ return false;
219
+ }
220
+ this.db.prepare(`
221
+ 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)
224
+ ON CONFLICT DO UPDATE SET count=count+1
225
+ `).run(
226
+ day,
227
+ this.projectHash(projectPath),
228
+ event.surface,
229
+ event.signature,
230
+ event.bashCategory,
231
+ event.risk,
232
+ event.pathClass,
233
+ event.result,
234
+ event.resolution,
235
+ normalizeOrigin(event.origin),
236
+ event.forwarded ? 1 : 0,
237
+ event.features.join(","),
238
+ this.ruleFingerprint(event.matchedPattern),
239
+ );
240
+ this.db.exec("COMMIT");
241
+ this.secureSidecars();
242
+ return true;
243
+ } catch (error) {
244
+ try { this.db.exec("ROLLBACK"); } catch { /* preserve original error */ }
245
+ throw error;
246
+ }
247
+ });
248
+ }
249
+
250
+ private pruneIfNeeded(today: string): void {
251
+ const last = this.db.prepare("SELECT value FROM meta WHERE key='last_cleanup_day'").get()?.value;
252
+ if (last === today) return;
253
+ const cutoff = dayString(addDays(this.now(), -this.retentionDays + 1));
254
+ this.db.prepare("DELETE FROM daily_permission_stats WHERE day < ?").run(cutoff);
255
+ this.db.prepare("DELETE FROM seen_requests WHERE seen_day < ?").run(cutoff);
256
+ this.db.prepare("INSERT INTO meta(key,value) VALUES('last_cleanup_day',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(today);
257
+ }
258
+
259
+ query(input: PolicyAuditQuery): PolicyAuditQueryResult {
260
+ return this.retry(() => {
261
+ const throughDay = dayString(this.now());
262
+ const fromDay = dayString(addDays(this.now(), -input.days + 1));
263
+ const projectClause = input.scope === "current" ? "AND project_hash = ?" : "";
264
+ const params: unknown[] = [fromDay];
265
+ if (input.scope === "current") params.push(this.projectHash(input.projectPath));
266
+ 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
268
+ FROM daily_permission_stats
269
+ WHERE day >= ? ${projectClause}
270
+ GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint
271
+ `).all(...params).map((row) => ({
272
+ surface: String(row.surface), signature: String(row.signature), bashCategory: String(row.bash_category),
273
+ risk: String(row.risk), pathClass: String(row.path_class), result: String(row.result),
274
+ resolution: String(row.resolution), origin: String(row.origin), forwarded: Number(row.forwarded) === 1,
275
+ features: String(row.features), ruleFingerprint: String(row.rule_fingerprint), count: Number(row.count),
276
+ }));
277
+ const collectingSince = String(this.db.prepare("SELECT value FROM meta WHERE key='collecting_since'").get()?.value ?? "unknown");
278
+ return { collectingSince, fromDay, throughDay, rows };
279
+ });
280
+ }
281
+
282
+ private secureSidecars(): void {
283
+ for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
284
+ if (existsSync(path)) chmodSync(path, 0o600);
285
+ }
286
+ }
287
+
288
+ close(): void {
289
+ // Concurrent session_shutdown handlers must not reject a second close.
290
+ if (this.closed) return;
291
+ this.closed = true;
292
+ this.db.close();
293
+ }
294
+ }
295
+
296
+ export function defaultPolicyAuditDirectory(home: string): string {
297
+ return join(home, ".pi", "agent", "extensions", "pi-auto-review");
298
+ }