@erichll/pi-auto-review 0.11.0 → 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 +26 -0
- package/README.md +59 -1
- package/package.json +2 -2
- package/src/config.json +5 -1
- package/src/index.ts +122 -2
- package/src/policy-audit/classifier.ts +234 -0
- package/src/policy-audit/index.ts +172 -0
- package/src/policy-audit/report.ts +140 -0
- package/src/policy-audit/store.ts +298 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
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
|
+
|
|
24
|
+
## 0.11.1 - 2026-08-25
|
|
25
|
+
|
|
26
|
+
- Align the package version with `@erichll/pi-sandbox` `0.11.1`. No
|
|
27
|
+
pi-auto-review runtime or public API changes.
|
|
28
|
+
|
|
3
29
|
## 0.11.0 - 2026-08-24
|
|
4
30
|
|
|
5
31
|
- **Structured network destination.** Network approval requests now carry
|
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
|
|
@@ -100,6 +105,12 @@ Use the user-global file for normal customization. It may set any legal key:
|
|
|
100
105
|
}
|
|
101
106
|
```
|
|
102
107
|
|
|
108
|
+
For a complete `@gotgenes/pi-permission-system` config that wires
|
|
109
|
+
`pi-auto-review` into the authorizer chain — a copyable baseline covering
|
|
110
|
+
read/write/edit, a read-only bash allowlist, an MCP discovery policy, and a
|
|
111
|
+
`path` deny block for secret and credential files — see
|
|
112
|
+
[`examples/pi-permission-system.config.example.json`](examples/pi-permission-system.config.example.json).
|
|
113
|
+
|
|
103
114
|
Package defaults:
|
|
104
115
|
|
|
105
116
|
```json
|
|
@@ -116,7 +127,11 @@ Package defaults:
|
|
|
116
127
|
"breakGlassEnabled": true,
|
|
117
128
|
"failureMode": "deny",
|
|
118
129
|
"grantTtlMs": 60000,
|
|
119
|
-
"autoConfirmBoundedAllows": ["external_directory", "path"]
|
|
130
|
+
"autoConfirmBoundedAllows": ["external_directory", "path"],
|
|
131
|
+
"policyAudit": {
|
|
132
|
+
"enabled": true,
|
|
133
|
+
"retentionDays": 180
|
|
134
|
+
}
|
|
120
135
|
}
|
|
121
136
|
```
|
|
122
137
|
|
|
@@ -130,6 +145,11 @@ and grant TTL, set `failureMode` to `"deny"`, set `breakGlassEnabled` to
|
|
|
130
145
|
select a model, raise a trusted limit, or weaken fail-closed behavior. Invalid
|
|
131
146
|
configuration disables the reviewer for that session.
|
|
132
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
|
+
|
|
133
153
|
### Deadlines and retries
|
|
134
154
|
|
|
135
155
|
`timeoutMs` is one deadline shared by model resolution, authentication, model
|
|
@@ -307,6 +327,44 @@ Writes to the installed reviewer package, its user-global configuration,
|
|
|
307
327
|
project and global security configuration, and the global audit directory are
|
|
308
328
|
deterministically denied.
|
|
309
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
|
+
|
|
310
368
|
## Telemetry
|
|
311
369
|
|
|
312
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.
|
|
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
|
-
...
|
|
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
|
+
}
|