@erichll/pi-auto-review 0.11.1 → 0.13.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 +29 -0
- package/README.md +53 -1
- package/package.json +3 -3
- package/src/config.json +5 -1
- package/src/index.ts +126 -4
- package/src/path-surfaces.ts +31 -0
- package/src/policy-audit/classifier.ts +240 -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/src/policy.ts +3 -1
- package/src/ui-auto-confirm.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-08-28
|
|
4
|
+
|
|
5
|
+
- Add compatibility with `@gotgenes/pi-permission-system` 27.1 directional
|
|
6
|
+
`path_*` and `external_directory_*` surfaces while retaining the original
|
|
7
|
+
surface in authorization evidence, UI binding, logs, and policy audits.
|
|
8
|
+
- Keep the permission-system peer range at `^27.0.0` so existing 27.x bare
|
|
9
|
+
`path` and `external_directory` integrations remain supported.
|
|
10
|
+
|
|
11
|
+
## 0.12.0 - 2026-08-27
|
|
12
|
+
|
|
13
|
+
- Add the private policy audit
|
|
14
|
+
compare current-project or anonymous global surface/command hotspots, denial
|
|
15
|
+
rates, approval sources, and rule-hit fingerprints; then review repeatedly
|
|
16
|
+
approved read-only operations as candidates for narrowly scoped `allow`
|
|
17
|
+
rules. Mutation, project execution, network, arbitrary-shell, unknown, and
|
|
18
|
+
custom-tool activity is called out as keep-`ask` evidence rather than an
|
|
19
|
+
automatic relaxation. The report does not edit policy or claim that an
|
|
20
|
+
unobserved rule is unused.
|
|
21
|
+
- Add default-on, 180-day permission-policy auditing backed by built-in
|
|
22
|
+
`node:sqlite`. Permission decisions are immediately reduced to fixed command,
|
|
23
|
+
risk, path-class, source, and anonymous rule-hit aggregates; raw commands,
|
|
24
|
+
paths, URLs, credentials, request IDs, and project names are never stored.
|
|
25
|
+
- Add `/auto-review-policy-audit` as an extension-owned custom-entry report.
|
|
26
|
+
It is not registered as an Agent tool or packaged skill, so audit reporting
|
|
27
|
+
adds no tool schema or skill metadata to the model context. Reports default
|
|
28
|
+
to the current project and can explicitly show an anonymous global aggregate.
|
|
29
|
+
- Require Node.js 22.13.0 or newer. No SQLite CLI, system library, npm SQLite
|
|
30
|
+
package, RTK, or historical permission-log import is required.
|
|
31
|
+
|
|
3
32
|
## 0.11.1 - 2026-08-25
|
|
4
33
|
|
|
5
34
|
- 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.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
56
56
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
57
|
-
"@gotgenes/pi-permission-system": "
|
|
57
|
+
"@gotgenes/pi-permission-system": "27.1.0",
|
|
58
58
|
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|
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,14 @@ 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";
|
|
75
|
+
import { isPathSurface, pathSurfaceInfo } from "./path-surfaces.ts";
|
|
68
76
|
export { parseHostPort };
|
|
69
77
|
|
|
70
78
|
type ReasoningLevel =
|
|
@@ -92,6 +100,7 @@ export type Config = {
|
|
|
92
100
|
failureMode: "deny" | "defer";
|
|
93
101
|
grantTtlMs: number;
|
|
94
102
|
autoConfirmBoundedAllows: readonly BoundedSurface[];
|
|
103
|
+
policyAudit: Readonly<PolicyAuditConfig>;
|
|
95
104
|
};
|
|
96
105
|
|
|
97
106
|
type CompletionMessage = {
|
|
@@ -296,6 +305,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
296
305
|
failureMode: "deny",
|
|
297
306
|
grantTtlMs: 60_000,
|
|
298
307
|
autoConfirmBoundedAllows: Object.freeze(["external_directory", "path"]),
|
|
308
|
+
policyAudit: Object.freeze({ enabled: true, retentionDays: 180 }),
|
|
299
309
|
};
|
|
300
310
|
|
|
301
311
|
function writeOptionalAuditFile(event: unknown): void {
|
|
@@ -364,6 +374,7 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
364
374
|
"failureMode",
|
|
365
375
|
"grantTtlMs",
|
|
366
376
|
"autoConfirmBoundedAllows",
|
|
377
|
+
"policyAudit",
|
|
367
378
|
]);
|
|
368
379
|
const unknownKeys = Object.keys(raw).filter((key) => !allowedKeys.has(key));
|
|
369
380
|
if (unknownKeys.length > 0) {
|
|
@@ -372,6 +383,10 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
372
383
|
);
|
|
373
384
|
}
|
|
374
385
|
const config = { ...DEFAULT_CONFIG, ...raw };
|
|
386
|
+
config.policyAudit = {
|
|
387
|
+
...DEFAULT_CONFIG.policyAudit,
|
|
388
|
+
...(raw.policyAudit as Partial<PolicyAuditConfig> | undefined),
|
|
389
|
+
};
|
|
375
390
|
if (
|
|
376
391
|
typeof config.model !== "string" ||
|
|
377
392
|
!config.model.trim() ||
|
|
@@ -453,11 +468,25 @@ function validateConfig(value: unknown, source: string): Config {
|
|
|
453
468
|
`${EXTENSION_NAME}: maxReviewerInputTokens must be 2048..32768`,
|
|
454
469
|
);
|
|
455
470
|
}
|
|
471
|
+
if (raw.policyAudit !== undefined &&
|
|
472
|
+
(!raw.policyAudit || typeof raw.policyAudit !== "object" || Array.isArray(raw.policyAudit))) {
|
|
473
|
+
throw new Error(`${EXTENSION_NAME}: policyAudit must be an object`);
|
|
474
|
+
}
|
|
475
|
+
const policyAuditKeys = Object.keys((raw.policyAudit ?? {}) as Record<string, unknown>);
|
|
476
|
+
if (policyAuditKeys.some((key) => key !== "enabled" && key !== "retentionDays")) {
|
|
477
|
+
throw new Error(`${EXTENSION_NAME}: policyAudit only accepts enabled and retentionDays`);
|
|
478
|
+
}
|
|
479
|
+
if (typeof config.policyAudit.enabled !== "boolean" ||
|
|
480
|
+
!Number.isInteger(config.policyAudit.retentionDays) ||
|
|
481
|
+
config.policyAudit.retentionDays < 1 || config.policyAudit.retentionDays > 3_650) {
|
|
482
|
+
throw new Error(`${EXTENSION_NAME}: policyAudit requires enabled boolean and retentionDays 1..3650`);
|
|
483
|
+
}
|
|
456
484
|
return {
|
|
457
485
|
...config,
|
|
458
486
|
autoConfirmBoundedAllows: Object.freeze([
|
|
459
487
|
...config.autoConfirmBoundedAllows,
|
|
460
488
|
]),
|
|
489
|
+
policyAudit: Object.freeze({ ...config.policyAudit }),
|
|
461
490
|
};
|
|
462
491
|
}
|
|
463
492
|
|
|
@@ -500,11 +529,20 @@ export function applyUserConfig(
|
|
|
500
529
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
501
530
|
throw new Error(`${EXTENSION_NAME}: ${source} must be an object`);
|
|
502
531
|
}
|
|
532
|
+
const overlay = value as Record<string, unknown>;
|
|
533
|
+
if (overlay.policyAudit !== undefined &&
|
|
534
|
+
(!overlay.policyAudit || typeof overlay.policyAudit !== "object" || Array.isArray(overlay.policyAudit))) {
|
|
535
|
+
throw new Error(`${EXTENSION_NAME}: ${source} policyAudit must be an object`);
|
|
536
|
+
}
|
|
503
537
|
return validateConfig(
|
|
504
538
|
{
|
|
505
539
|
...packageConfig,
|
|
506
540
|
autoConfirmBoundedAllows: [...packageConfig.autoConfirmBoundedAllows],
|
|
507
|
-
...
|
|
541
|
+
...overlay,
|
|
542
|
+
policyAudit: {
|
|
543
|
+
...packageConfig.policyAudit,
|
|
544
|
+
...(overlay.policyAudit as object | undefined),
|
|
545
|
+
},
|
|
508
546
|
},
|
|
509
547
|
source,
|
|
510
548
|
);
|
|
@@ -569,6 +607,7 @@ export function applyProjectConfig(
|
|
|
569
607
|
"breakGlassEnabled",
|
|
570
608
|
"failureMode",
|
|
571
609
|
"autoConfirmBoundedAllows",
|
|
610
|
+
"policyAudit",
|
|
572
611
|
]);
|
|
573
612
|
const forbidden = Object.keys(raw).filter((key) => !allowed.has(key));
|
|
574
613
|
if (forbidden.length > 0) {
|
|
@@ -625,6 +664,27 @@ export function applyProjectConfig(
|
|
|
625
664
|
...new Set(raw.autoConfirmBoundedAllows as BoundedSurface[]),
|
|
626
665
|
];
|
|
627
666
|
}
|
|
667
|
+
if (raw.policyAudit !== undefined) {
|
|
668
|
+
if (!raw.policyAudit || typeof raw.policyAudit !== "object" || Array.isArray(raw.policyAudit)) {
|
|
669
|
+
throw new Error(`${EXTENSION_NAME}: project policyAudit must be an object`);
|
|
670
|
+
}
|
|
671
|
+
const audit = raw.policyAudit as Record<string, unknown>;
|
|
672
|
+
const forbiddenAuditKeys = Object.keys(audit).filter((key) => key !== "enabled" && key !== "retentionDays");
|
|
673
|
+
if (forbiddenAuditKeys.length > 0) {
|
|
674
|
+
throw new Error(`${EXTENSION_NAME}: project policyAudit cannot set: ${forbiddenAuditKeys.join(", ")}`);
|
|
675
|
+
}
|
|
676
|
+
if (audit.enabled !== undefined && audit.enabled !== false) {
|
|
677
|
+
throw new Error(`${EXTENSION_NAME}: project policyAudit may only disable collection`);
|
|
678
|
+
}
|
|
679
|
+
if (audit.retentionDays !== undefined &&
|
|
680
|
+
(!Number.isInteger(audit.retentionDays) || Number(audit.retentionDays) < 1 || Number(audit.retentionDays) > trusted.policyAudit.retentionDays)) {
|
|
681
|
+
throw new Error(`${EXTENSION_NAME}: project policyAudit.retentionDays may only shorten retention`);
|
|
682
|
+
}
|
|
683
|
+
merged.policyAudit = {
|
|
684
|
+
enabled: audit.enabled === false ? false : trusted.policyAudit.enabled,
|
|
685
|
+
retentionDays: audit.retentionDays === undefined ? trusted.policyAudit.retentionDays : Number(audit.retentionDays),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
628
688
|
return Object.freeze(validateConfig(merged, "effective project config"));
|
|
629
689
|
}
|
|
630
690
|
|
|
@@ -638,6 +698,7 @@ function protectedWriteHardDeny(
|
|
|
638
698
|
): { rule: string; reason: string } | undefined {
|
|
639
699
|
const isWrite =
|
|
640
700
|
request.surface === "filesystem-write" ||
|
|
701
|
+
pathSurfaceInfo(request.surface)?.effect === "write" ||
|
|
641
702
|
/\b(?:write|create|delete|rename|chmod|chown)\b/i.test(
|
|
642
703
|
request.operation,
|
|
643
704
|
);
|
|
@@ -714,7 +775,7 @@ function sessionConfig(
|
|
|
714
775
|
}
|
|
715
776
|
|
|
716
777
|
function boundedRequest(surface: string): boolean {
|
|
717
|
-
return
|
|
778
|
+
return isPathSurface(surface);
|
|
718
779
|
}
|
|
719
780
|
|
|
720
781
|
type PermissionsService = {
|
|
@@ -737,7 +798,7 @@ function boundaryRequest(
|
|
|
737
798
|
const surface = evidence.surface;
|
|
738
799
|
const value =
|
|
739
800
|
evidence.resolvedPath ??
|
|
740
|
-
(surface
|
|
801
|
+
(isPathSurface(surface)
|
|
741
802
|
? evidence.path
|
|
742
803
|
: evidence.command ?? evidence.value ?? evidence.destination) ??
|
|
743
804
|
details.skillName ??
|
|
@@ -1901,6 +1962,33 @@ export type PiAutoReviewExtensionOptions = {
|
|
|
1901
1962
|
allowUntrustedWorkspace?: boolean;
|
|
1902
1963
|
};
|
|
1903
1964
|
|
|
1965
|
+
const POLICY_AUDIT_ENTRY_TYPE = "pi-auto-review-policy-audit";
|
|
1966
|
+
|
|
1967
|
+
function renderPolicyAuditEntry(
|
|
1968
|
+
entry: { data?: unknown },
|
|
1969
|
+
_options: unknown,
|
|
1970
|
+
theme: { fg(color: string, text: string): string },
|
|
1971
|
+
): { render(width: number): string[]; invalidate(): void } | undefined {
|
|
1972
|
+
const markdown = entry.data && typeof entry.data === "object" &&
|
|
1973
|
+
typeof (entry.data as { markdown?: unknown }).markdown === "string"
|
|
1974
|
+
? (entry.data as { markdown: string }).markdown
|
|
1975
|
+
: undefined;
|
|
1976
|
+
if (!markdown) return undefined;
|
|
1977
|
+
return {
|
|
1978
|
+
render(width: number) {
|
|
1979
|
+
const max = Math.max(20, width - 2);
|
|
1980
|
+
return markdown.split("\n").flatMap((line) => {
|
|
1981
|
+
const output: string[] = [];
|
|
1982
|
+
for (let offset = 0; offset < Math.max(1, line.length); offset += max) {
|
|
1983
|
+
output.push(theme.fg("muted", line.slice(offset, offset + max)));
|
|
1984
|
+
}
|
|
1985
|
+
return output;
|
|
1986
|
+
});
|
|
1987
|
+
},
|
|
1988
|
+
invalidate() {},
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1904
1992
|
export function createPiAutoReviewExtension(
|
|
1905
1993
|
options: PiAutoReviewExtensionOptions = {},
|
|
1906
1994
|
): (pi: ExtensionAPI) => void {
|
|
@@ -1916,6 +2004,7 @@ export function createPiAutoReviewExtension(
|
|
|
1916
2004
|
return (pi: ExtensionAPI): void => {
|
|
1917
2005
|
try {
|
|
1918
2006
|
pi.registerEntryRenderer(USER_REVIEW_ENTRY_TYPE, renderUserReviewEntry);
|
|
2007
|
+
pi.registerEntryRenderer(POLICY_AUDIT_ENTRY_TYPE, renderPolicyAuditEntry);
|
|
1919
2008
|
} catch {
|
|
1920
2009
|
// Renderer registration is observational.
|
|
1921
2010
|
}
|
|
@@ -1936,6 +2025,36 @@ export function createPiAutoReviewExtension(
|
|
|
1936
2025
|
() => config.autoConfirmBoundedAllows,
|
|
1937
2026
|
);
|
|
1938
2027
|
const reviewWidget = new UserReviewWidgetController();
|
|
2028
|
+
const policyAudit = new PolicyAuditController({
|
|
2029
|
+
config: () => config.policyAudit,
|
|
2030
|
+
cwd: () => context?.cwd,
|
|
2031
|
+
warn: (message) => {
|
|
2032
|
+
console.error(message);
|
|
2033
|
+
notifyUserReview(context, { type: "warning", message });
|
|
2034
|
+
},
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
const runPolicyAuditReport = async (args: PolicyAuditArguments) =>
|
|
2038
|
+
policyAudit.report(args);
|
|
2039
|
+
|
|
2040
|
+
pi.registerCommand("auto-review-policy-audit", {
|
|
2041
|
+
description: "Show a persistent, redacted permission-policy audit report",
|
|
2042
|
+
handler: async (rawArgs, ctx) => {
|
|
2043
|
+
try {
|
|
2044
|
+
const args = parsePolicyAuditArguments(rawArgs, config.policyAudit.retentionDays);
|
|
2045
|
+
const result = await runPolicyAuditReport(args);
|
|
2046
|
+
pi.appendEntry(POLICY_AUDIT_ENTRY_TYPE, {
|
|
2047
|
+
markdown: result.markdown,
|
|
2048
|
+
report: result.report,
|
|
2049
|
+
});
|
|
2050
|
+
} catch (error) {
|
|
2051
|
+
ctx.ui.notify(
|
|
2052
|
+
`Permission policy audit unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
2053
|
+
"warning",
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
},
|
|
2057
|
+
});
|
|
1939
2058
|
|
|
1940
2059
|
const emitTelemetry = (event: ReviewerTelemetryEvent): void => {
|
|
1941
2060
|
writeOptionalAuditFile(event);
|
|
@@ -2288,6 +2407,7 @@ export function createPiAutoReviewExtension(
|
|
|
2288
2407
|
);
|
|
2289
2408
|
context = ctx;
|
|
2290
2409
|
broker = createBroker();
|
|
2410
|
+
policyAudit.warmup();
|
|
2291
2411
|
try {
|
|
2292
2412
|
disposeBrokerService = publishBoundaryBroker(broker);
|
|
2293
2413
|
} catch (error) {
|
|
@@ -2320,6 +2440,7 @@ export function createPiAutoReviewExtension(
|
|
|
2320
2440
|
|
|
2321
2441
|
pi.events.on("permissions:decision", (event) => {
|
|
2322
2442
|
reviewWidget.permissionDecision(event);
|
|
2443
|
+
policyAudit.record(event as PermissionDecisionLike);
|
|
2323
2444
|
});
|
|
2324
2445
|
|
|
2325
2446
|
pi.events.on("permissions:ready", (event) => {
|
|
@@ -2516,7 +2637,7 @@ export function createPiAutoReviewExtension(
|
|
|
2516
2637
|
}
|
|
2517
2638
|
});
|
|
2518
2639
|
|
|
2519
|
-
pi.on("session_shutdown", () => {
|
|
2640
|
+
pi.on("session_shutdown", async () => {
|
|
2520
2641
|
shuttingDown = true;
|
|
2521
2642
|
registrationEpoch++;
|
|
2522
2643
|
reviewWidget.clear(context);
|
|
@@ -2530,6 +2651,7 @@ export function createPiAutoReviewExtension(
|
|
|
2530
2651
|
reviewResults.clear();
|
|
2531
2652
|
uiAutoConfirmer.clear();
|
|
2532
2653
|
context = undefined;
|
|
2654
|
+
await policyAudit.close();
|
|
2533
2655
|
});
|
|
2534
2656
|
};
|
|
2535
2657
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type PathSurfaceFamily = "path" | "external_directory";
|
|
2
|
+
export type PathSurfaceEffect = "read" | "write";
|
|
3
|
+
|
|
4
|
+
export type PathSurfaceInfo = {
|
|
5
|
+
family: PathSurfaceFamily;
|
|
6
|
+
effect?: PathSurfaceEffect;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const PATH_SURFACES: Readonly<Record<string, PathSurfaceInfo>> = Object.freeze({
|
|
10
|
+
path: Object.freeze({ family: "path" }),
|
|
11
|
+
path_read: Object.freeze({ family: "path", effect: "read" }),
|
|
12
|
+
path_write: Object.freeze({ family: "path", effect: "write" }),
|
|
13
|
+
external_directory: Object.freeze({ family: "external_directory" }),
|
|
14
|
+
external_directory_read: Object.freeze({
|
|
15
|
+
family: "external_directory",
|
|
16
|
+
effect: "read",
|
|
17
|
+
}),
|
|
18
|
+
external_directory_write: Object.freeze({
|
|
19
|
+
family: "external_directory",
|
|
20
|
+
effect: "write",
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Return metadata only for permission-system's six explicit path surfaces. */
|
|
25
|
+
export function pathSurfaceInfo(surface: unknown): PathSurfaceInfo | undefined {
|
|
26
|
+
return typeof surface === "string" ? PATH_SURFACES[surface] : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isPathSurface(surface: unknown): boolean {
|
|
30
|
+
return pathSurfaceInfo(surface) !== undefined;
|
|
31
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { homedir, tmpdir } from "node:os";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { pathSurfaceInfo } from "../path-surfaces.ts";
|
|
4
|
+
|
|
5
|
+
export type PolicyAuditRisk =
|
|
6
|
+
| "read_only"
|
|
7
|
+
| "workspace_mutation"
|
|
8
|
+
| "project_code_execution"
|
|
9
|
+
| "network"
|
|
10
|
+
| "network_mutation"
|
|
11
|
+
| "arbitrary_shell"
|
|
12
|
+
| "unknown";
|
|
13
|
+
|
|
14
|
+
export type PolicyAuditPathClass =
|
|
15
|
+
| "workspace"
|
|
16
|
+
| "temp"
|
|
17
|
+
| "home"
|
|
18
|
+
| "external"
|
|
19
|
+
| "sensitive"
|
|
20
|
+
| "unknown";
|
|
21
|
+
|
|
22
|
+
export type PolicyAuditCommandFeature =
|
|
23
|
+
| "env_prefix"
|
|
24
|
+
| "compound"
|
|
25
|
+
| "pipeline"
|
|
26
|
+
| "relative_executable";
|
|
27
|
+
|
|
28
|
+
export type ClassifiedPermission = {
|
|
29
|
+
surface: string;
|
|
30
|
+
signature: string;
|
|
31
|
+
bashCategory: string;
|
|
32
|
+
risk: PolicyAuditRisk;
|
|
33
|
+
pathClass: PolicyAuditPathClass;
|
|
34
|
+
features: readonly PolicyAuditCommandFeature[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const KNOWN_SURFACES = new Set([
|
|
38
|
+
"bash",
|
|
39
|
+
"bash_escalated",
|
|
40
|
+
"read",
|
|
41
|
+
"write",
|
|
42
|
+
"edit",
|
|
43
|
+
"grep",
|
|
44
|
+
"find",
|
|
45
|
+
"ls",
|
|
46
|
+
"path",
|
|
47
|
+
"external_directory",
|
|
48
|
+
"path_read",
|
|
49
|
+
"path_write",
|
|
50
|
+
"external_directory_read",
|
|
51
|
+
"external_directory_write",
|
|
52
|
+
"skill",
|
|
53
|
+
"mcp",
|
|
54
|
+
"network",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const SENSITIVE_PATH = /(?:^|[/\\])(?:\.ssh|\.aws|\.gnupg|\.kube|\.azure|\.npmrc|\.netrc|\.env(?:\.[^/\\]+)?|auth\.json|credentials?)(?:[/\\]|$)/i;
|
|
58
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
59
|
+
"cat", "find", "grep", "head", "ls", "pwd", "rg", "sed", "sort",
|
|
60
|
+
"stat", "tail", "wc", "which",
|
|
61
|
+
]);
|
|
62
|
+
const MUTATING_COMMANDS = new Set([
|
|
63
|
+
"chmod", "chown", "cp", "install", "ln", "mkdir", "mv", "rm", "rmdir",
|
|
64
|
+
"touch", "truncate",
|
|
65
|
+
]);
|
|
66
|
+
const NETWORK_COMMANDS = new Set(["curl", "dig", "host", "nc", "ping", "ssh", "wget"]);
|
|
67
|
+
const SHELL_COMMANDS = new Set(["bash", "dash", "fish", "sh", "zsh"]);
|
|
68
|
+
const GIT_ACTIONS = new Set([
|
|
69
|
+
"add", "branch", "checkout", "clean", "clone", "commit", "diff", "fetch",
|
|
70
|
+
"log", "merge", "pull", "push", "rebase", "remote", "reset", "restore",
|
|
71
|
+
"send-pack", "show", "status", "switch", "tag",
|
|
72
|
+
]);
|
|
73
|
+
const RTK_ACTIONS = new Set(["diff", "find", "grep", "ls", "read", "rg", "smart", "wc"]);
|
|
74
|
+
const PACKAGE_ACTIONS = new Set([
|
|
75
|
+
"add", "build", "check", "coverage", "dev", "format", "install", "lint",
|
|
76
|
+
"publish", "start", "test", "typecheck", "uninstall", "update", "upgrade",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
function within(path: string, parent: string): boolean {
|
|
80
|
+
const rel = relative(parent, path);
|
|
81
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function classifyPath(
|
|
85
|
+
value: unknown,
|
|
86
|
+
cwd: string,
|
|
87
|
+
home = homedir(),
|
|
88
|
+
temp = tmpdir(),
|
|
89
|
+
): PolicyAuditPathClass {
|
|
90
|
+
if (typeof value !== "string" || !value.trim()) return "unknown";
|
|
91
|
+
const raw = value.trim();
|
|
92
|
+
if (SENSITIVE_PATH.test(raw)) return "sensitive";
|
|
93
|
+
if (!raw.startsWith("/") && !raw.startsWith("~") && !raw.startsWith(".")) {
|
|
94
|
+
return "unknown";
|
|
95
|
+
}
|
|
96
|
+
const expanded = raw === "~" || raw.startsWith("~/")
|
|
97
|
+
? resolve(home, raw.slice(2))
|
|
98
|
+
: resolve(cwd, raw);
|
|
99
|
+
if (within(expanded, resolve(cwd))) return "workspace";
|
|
100
|
+
if (within(expanded, resolve(temp))) return "temp";
|
|
101
|
+
if (within(expanded, resolve(home))) return "home";
|
|
102
|
+
return "external";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function shellTokens(command: string): string[] {
|
|
106
|
+
return command
|
|
107
|
+
.trim()
|
|
108
|
+
.split(/\s+/u)
|
|
109
|
+
.map((token) => token.replace(/^["']|["']$/g, ""));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function executableIndex(tokens: readonly string[]): number {
|
|
113
|
+
let index = 0;
|
|
114
|
+
if (tokens[index] === "env") index++;
|
|
115
|
+
while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) {
|
|
116
|
+
index++;
|
|
117
|
+
}
|
|
118
|
+
return index;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function safeCommandName(token: string | undefined): string {
|
|
122
|
+
if (!token) return "unknown";
|
|
123
|
+
const name = token.split(/[\\/]/).at(-1)?.toLowerCase() || "unknown";
|
|
124
|
+
return /^[a-z0-9][a-z0-9+._-]{0,31}$/.test(name) ? name : "unknown";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function knownSignature(tokens: readonly string[], index: number): string {
|
|
128
|
+
const executable = safeCommandName(tokens[index]);
|
|
129
|
+
const sub = safeCommandName(tokens[index + 1]);
|
|
130
|
+
const third = safeCommandName(tokens[index + 2]);
|
|
131
|
+
if (executable === "rtk") {
|
|
132
|
+
if (sub === "git") return `rtk:git:${GIT_ACTIONS.has(third) ? third : "other"}`;
|
|
133
|
+
return `rtk:${RTK_ACTIONS.has(sub) ? sub : "other"}`;
|
|
134
|
+
}
|
|
135
|
+
if (executable === "git") return `git:${GIT_ACTIONS.has(sub) ? sub : "other"}`;
|
|
136
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
|
|
137
|
+
const action = sub === "run" ? third : sub;
|
|
138
|
+
return `${executable}:${PACKAGE_ACTIONS.has(action) ? action : "custom-script"}`;
|
|
139
|
+
}
|
|
140
|
+
if (READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable) ||
|
|
141
|
+
NETWORK_COMMANDS.has(executable) || SHELL_COMMANDS.has(executable)) {
|
|
142
|
+
return executable;
|
|
143
|
+
}
|
|
144
|
+
if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) {
|
|
145
|
+
return "<path-command>";
|
|
146
|
+
}
|
|
147
|
+
return "<other-command>";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function commandRisk(signature: string, executable: string, command: string): PolicyAuditRisk {
|
|
151
|
+
if (/&&|\|\||;|\n|`|\$\(|(^|[^|])\|([^|]|$)/.test(command)) return "arbitrary_shell";
|
|
152
|
+
if (["git:push", "git:send-pack", "rtk:git:push", "rtk:git:send-pack"].includes(signature)) return "network_mutation";
|
|
153
|
+
if (/^(?:npm|pnpm|yarn|bun):(install|publish|add|upgrade|update)$/.test(signature)) {
|
|
154
|
+
return signature.endsWith(":publish") ? "network_mutation" : "project_code_execution";
|
|
155
|
+
}
|
|
156
|
+
if (/^(?:npm|pnpm|yarn|bun):/.test(signature)) return "project_code_execution";
|
|
157
|
+
if (NETWORK_COMMANDS.has(executable)) {
|
|
158
|
+
return /(?:^|\s)(?:--upload-file|-T|-X\s*(?:POST|PUT|PATCH|DELETE)|--data|-d)(?:\s|=|$)/i.test(command)
|
|
159
|
+
? "network_mutation"
|
|
160
|
+
: "network";
|
|
161
|
+
}
|
|
162
|
+
if (MUTATING_COMMANDS.has(executable) || ["git:add", "git:commit", "git:reset", "git:clean", "git:checkout", "git:restore", "git:rebase", "rtk:git:add", "rtk:git:commit"].includes(signature)) {
|
|
163
|
+
return "workspace_mutation";
|
|
164
|
+
}
|
|
165
|
+
if (READ_ONLY_COMMANDS.has(executable) || /^(?:rtk:)?git:(?:status|diff|log|show|branch)$/.test(signature)) {
|
|
166
|
+
return "read_only";
|
|
167
|
+
}
|
|
168
|
+
if (SHELL_COMMANDS.has(executable) || signature === "<path-command>") {
|
|
169
|
+
return "arbitrary_shell";
|
|
170
|
+
}
|
|
171
|
+
return "unknown";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function classifyBash(command: unknown, cwd: string): ClassifiedPermission {
|
|
175
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
176
|
+
return { surface: "bash", signature: "<other-command>", bashCategory: "unknown", risk: "unknown", pathClass: "unknown", features: [] };
|
|
177
|
+
}
|
|
178
|
+
const tokens = shellTokens(command);
|
|
179
|
+
const index = executableIndex(tokens);
|
|
180
|
+
const executable = safeCommandName(tokens[index]);
|
|
181
|
+
const signature = knownSignature(tokens, index);
|
|
182
|
+
const features: PolicyAuditCommandFeature[] = [];
|
|
183
|
+
if (index > 0) features.push("env_prefix");
|
|
184
|
+
if (/&&|\|\||;|\n|`|\$\(/.test(command)) features.push("compound");
|
|
185
|
+
if (/(^|[^|])\|([^|]|$)/.test(command)) features.push("pipeline");
|
|
186
|
+
if (tokens[index]?.includes("/") || tokens[index]?.startsWith(".")) features.push("relative_executable");
|
|
187
|
+
const bashCategory = /^(?:rtk:)?git:/.test(signature)
|
|
188
|
+
? "version_control"
|
|
189
|
+
: /^(?:npm|pnpm|yarn|bun):/.test(signature)
|
|
190
|
+
? "package_script"
|
|
191
|
+
: NETWORK_COMMANDS.has(executable)
|
|
192
|
+
? "network"
|
|
193
|
+
: READ_ONLY_COMMANDS.has(executable) || MUTATING_COMMANDS.has(executable)
|
|
194
|
+
? "filesystem"
|
|
195
|
+
: "shell";
|
|
196
|
+
const pathToken = tokens.slice(index + 1)
|
|
197
|
+
.map((token) => token.includes("=") ? token.slice(token.indexOf("=") + 1) : token.replace(/^@/, ""))
|
|
198
|
+
.find((token) => token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.startsWith("~/"));
|
|
199
|
+
return {
|
|
200
|
+
surface: "bash",
|
|
201
|
+
signature,
|
|
202
|
+
bashCategory,
|
|
203
|
+
risk: commandRisk(signature, executable, command),
|
|
204
|
+
pathClass: classifyPath(pathToken, cwd),
|
|
205
|
+
features: [...new Set(features)].sort(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function classifyPermission(surfaceValue: unknown, value: unknown, cwd: string): ClassifiedPermission {
|
|
210
|
+
const rawSurface = typeof surfaceValue === "string" ? surfaceValue.trim().toLowerCase() : "";
|
|
211
|
+
if (rawSurface === "bash" || rawSurface === "bash_escalated") {
|
|
212
|
+
const classified = classifyBash(value, cwd);
|
|
213
|
+
return { ...classified, surface: rawSurface || "bash" };
|
|
214
|
+
}
|
|
215
|
+
const surface = KNOWN_SURFACES.has(rawSurface) ? rawSurface : "custom_tool";
|
|
216
|
+
const pathSurface = pathSurfaceInfo(surface);
|
|
217
|
+
if (pathSurface || ["read", "write", "edit"].includes(surface)) {
|
|
218
|
+
const pathClass = classifyPath(value, cwd);
|
|
219
|
+
const risk = pathSurface?.effect === "read" || surface === "read"
|
|
220
|
+
? "read_only"
|
|
221
|
+
: pathSurface?.effect === "write" || surface === "write" || surface === "edit"
|
|
222
|
+
? "workspace_mutation"
|
|
223
|
+
: "unknown";
|
|
224
|
+
return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk, pathClass, features: [] };
|
|
225
|
+
}
|
|
226
|
+
if (["grep", "find", "ls"].includes(surface)) {
|
|
227
|
+
return { surface, signature: `<${surface}>`, bashCategory: "not_bash", risk: "read_only", pathClass: "unknown", features: [] };
|
|
228
|
+
}
|
|
229
|
+
if (surface === "network") {
|
|
230
|
+
return { surface, signature: "<network>", bashCategory: "not_bash", risk: "network", pathClass: "unknown", features: [] };
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
surface,
|
|
234
|
+
signature: surface === "custom_tool" ? "<custom-tool>" : `<${surface}>`,
|
|
235
|
+
bashCategory: "not_bash",
|
|
236
|
+
risk: "unknown",
|
|
237
|
+
pathClass: "unknown",
|
|
238
|
+
features: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -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
|
+
}
|
package/src/policy.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPathSurface } from "./path-surfaces.ts";
|
|
2
|
+
|
|
1
3
|
export type RiskLevel = "low" | "medium" | "high" | "critical";
|
|
2
4
|
|
|
3
5
|
export type ModelDecision = {
|
|
@@ -327,7 +329,7 @@ export function normalizePermissionEvidence(
|
|
|
327
329
|
"unknown";
|
|
328
330
|
const value = nonEmptyString(details.value);
|
|
329
331
|
const isBash = surface === "bash" || surface === "bash_escalated";
|
|
330
|
-
const isPath = surface
|
|
332
|
+
const isPath = isPathSurface(surface);
|
|
331
333
|
const command =
|
|
332
334
|
nonEmptyString(details.command) ??
|
|
333
335
|
parsedPreviewCommand(details.toolInputPreview) ??
|
package/src/ui-auto-confirm.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionContext,
|
|
3
3
|
ExtensionUIContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { pathSurfaceInfo } from "./path-surfaces.ts";
|
|
5
6
|
|
|
6
7
|
type UiCustom = ExtensionUIContext["custom"];
|
|
7
8
|
|
|
@@ -61,7 +62,8 @@ export class PermissionUiAutoConfirmer {
|
|
|
61
62
|
|
|
62
63
|
stage(requestId: string, surface: string): boolean {
|
|
63
64
|
this.pruneExpired();
|
|
64
|
-
|
|
65
|
+
const info = pathSurfaceInfo(surface);
|
|
66
|
+
if (!info || !this.enabledSurfaces().includes(info.family)) return false;
|
|
65
67
|
this.pending.set(requestId, {
|
|
66
68
|
surface,
|
|
67
69
|
expiresAt: Date.now() + this.pendingTtlMs,
|
|
@@ -84,10 +86,14 @@ export class PermissionUiAutoConfirmer {
|
|
|
84
86
|
}
|
|
85
87
|
this.pending.delete(event.requestId);
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
const info = pathSurfaceInfo(pending.surface);
|
|
90
|
+
if (!info || !this.enabledSurfaces().includes(info.family)) return;
|
|
88
91
|
// Top-level event.surface is the display tool name (e.g. bash), not the
|
|
89
92
|
// gate. Only request.surface is comparable to the staged allow.
|
|
90
93
|
if (event.gateSurface && event.gateSurface !== pending.surface) return;
|
|
94
|
+
// Directional approvals cannot safely fall back to a legacy payload that
|
|
95
|
+
// omits request.surface: read and write members share the same family.
|
|
96
|
+
if (!event.gateSurface && info.effect) return;
|
|
91
97
|
this.settleActiveForConflict();
|
|
92
98
|
|
|
93
99
|
const attempt: PromptAttempt = {
|