@erichll/pi-auto-review 0.23.0 → 0.25.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 +42 -0
- package/README.md +31 -14
- package/package.json +9 -9
- package/src/broker/grants.ts +3 -0
- package/src/broker/types.ts +6 -0
- package/src/index.ts +3 -0
- package/src/policy-audit/index.ts +45 -0
- package/src/policy-audit/report.ts +19 -1
- package/src/policy-audit/store.ts +65 -18
- package/src/policy.ts +13 -0
- package/src/review/input.ts +3 -0
- package/src/review/prompts.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0 - 2026-10-07
|
|
4
|
+
|
|
5
|
+
- Pass permission-system's matched bash spelling (#910) through the review and
|
|
6
|
+
audit pipeline. The ask payload's `request.matchedSpelling` — the alternate
|
|
7
|
+
spelling the matched rule hit when it did not match the command as typed,
|
|
8
|
+
e.g. the absolute spelling of a relative path argument — is now normalized
|
|
9
|
+
into the reviewer evidence and `BoundaryRequest`, described to the model
|
|
10
|
+
reviewer, carried through hard-deny audits, and persisted in the policy
|
|
11
|
+
audit as an anonymous `spelling_fingerprint` (schema v3; v1 and v2
|
|
12
|
+
databases migrate transactionally). Because `permissions:decision` still
|
|
13
|
+
carries only `matchedPattern`, the spelling is captured from
|
|
14
|
+
authorizer entry (including automatic allow/deny) and
|
|
15
|
+
`permissions:ui_prompt` by request id and joined at record time with a
|
|
16
|
+
bounded FIFO cache; decisions that carry the field directly are used
|
|
17
|
+
as-is. The report grows a "spelling fingerprints" section (v3); the
|
|
18
|
+
spelling stays out of the boundary request hash, which is display-only
|
|
19
|
+
input. On permission-system 39.x the field is simply absent and the audit
|
|
20
|
+
behaves exactly as before. The development baseline moves to ^40.0.2,
|
|
21
|
+
including the /dev/null core-reader exemption fix and bash-chain session
|
|
22
|
+
grant isolation security fix; the peer floor stays `>=39.0.0`. Real
|
|
23
|
+
permission-system integration tests cover alternate spellings, redirect
|
|
24
|
+
exemptions, automatic spelling audits, and sibling-unit isolation.
|
|
25
|
+
- Isolate authorizer tests from the real user audit database: ordinary tests
|
|
26
|
+
disable audit collection, while audit integration uses a temporary HOME.
|
|
27
|
+
Schema v3 is a forward migration; upgrade and restart all audit collectors
|
|
28
|
+
rather than leaving pre-v3 sessions writing to the upgraded database.
|
|
29
|
+
|
|
30
|
+
## 0.24.0 - 2026-10-03
|
|
31
|
+
|
|
32
|
+
- Raise the `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent`
|
|
33
|
+
peer floors to `^1.0.0` and the development baselines to `^1.0.0`, plus
|
|
34
|
+
`@types/node` to `^26.6.4`. Raise the
|
|
35
|
+
`@gotgenes/pi-permission-system` development baseline to `^38.0.2`; the peer
|
|
36
|
+
floor stays `>=36.0.0`. Validated against Pi 1.0.0 with
|
|
37
|
+
pi-permission-system 38.0.2 via `tsc --noEmit` and the full test suite: no
|
|
38
|
+
source changes were required, and the authorizer seam this package consumes
|
|
39
|
+
is unchanged.
|
|
40
|
+
- Move the example's Pi MCP tool rules under `mcp` (`mcp__atlassian__*`,
|
|
41
|
+
`mcp__pencil__*`) and update the README's MCP guidance to match, because
|
|
42
|
+
pi-permission-system 38.0.0 gates Pi's built-in MCP tools on the `mcp`
|
|
43
|
+
surface instead of their top-level names.
|
|
44
|
+
|
|
3
45
|
## 0.23.0 - 2026-10-02
|
|
4
46
|
|
|
5
47
|
- Add opt-in `model: "current"` to review approval requests with the active Pi
|
package/README.md
CHANGED
|
@@ -31,8 +31,10 @@ that dependency is a hard prerequisite (see [Install and enable](#install-and-en
|
|
|
31
31
|
> **Prerequisite:** pi-auto-review is an authorizer inside
|
|
32
32
|
> `@gotgenes/pi-permission-system`. Pi does not auto-install peer packages, so
|
|
33
33
|
> install the permission system separately (once per machine) before this
|
|
34
|
-
> extension. This release line requires Pi 0.
|
|
35
|
-
> permission-system
|
|
34
|
+
> extension. This release line requires Pi 1.0.0 or later and supports
|
|
35
|
+
> permission-system 39.0.0 and later (development baseline validated against
|
|
36
|
+
> permission-system 40.0.2). Use 40.0.2 or later for the bash-chain session-grant
|
|
37
|
+
> security fix; the older peer floor is retained for API compatibility:
|
|
36
38
|
|
|
37
39
|
Node-based Pi requires Node.js 22.13.0 or newer. Permission auditing uses the
|
|
38
40
|
host runtime's built-in SQLite: `node:sqlite` on Node and `bun:sqlite` on Bun,
|
|
@@ -108,6 +110,13 @@ credential rules also keep scanning raw text. When a command fact exceeds the
|
|
|
108
110
|
reviewer bound (10 KiB), the ask defers to a human instead of being reviewed from
|
|
109
111
|
a truncated program.
|
|
110
112
|
|
|
113
|
+
Permission-system 40.x also reports the alternate command spelling a rule
|
|
114
|
+
matched, for example `cat /repo/file` when the command was `cat ./file`.
|
|
115
|
+
The reviewer sees this as explanatory evidence; it does not replace the actual
|
|
116
|
+
command or enter the grant/retry hash. Absolute-path Bash rules now also reach
|
|
117
|
+
relative spellings of that path, so review existing allow/ask/deny rules when
|
|
118
|
+
upgrading from 39.x.
|
|
119
|
+
|
|
111
120
|
Nested interpreter expansion is limited to 32 payloads and 32 KiB of cumulative
|
|
112
121
|
payload text per command fact, across all recursion levels. Exceeding either
|
|
113
122
|
budget keeps the raw-command hard-deny checks and otherwise defers to a human
|
|
@@ -156,19 +165,20 @@ to select a model whose ID is literally `current`.
|
|
|
156
165
|
|
|
157
166
|
For a complete `@gotgenes/pi-permission-system` config that wires
|
|
158
167
|
`pi-auto-review` into the authorizer chain — a copyable baseline covering
|
|
159
|
-
read/write/edit, a read-only bash allowlist,
|
|
168
|
+
read/write/edit, a read-only bash allowlist, MCP tool access, and a
|
|
160
169
|
`path` deny block for secret and credential files — see
|
|
161
170
|
[`examples/pi-permission-system.config.example.json`](examples/pi-permission-system.config.example.json).
|
|
162
171
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
Permission-system 38.0.0 gates Pi's built-in MCP tools on the `mcp` surface, so
|
|
173
|
+
the example writes its `mcp__<server>__*` rules inside a `mcp` block instead of
|
|
174
|
+
as top-level tool names. MCP rules are evaluated last-match-wins across the
|
|
175
|
+
candidates derived for one call: the tool name, its bare server, and `mcp_call`.
|
|
176
|
+
A call the `mcp` block does not match falls through to the universal
|
|
177
|
+
`"*": "ask"`, and any broad catch-all must be written before a narrower
|
|
178
|
+
override or last-match-wins shadows it. A bare server entry such as
|
|
179
|
+
`"atlassian": "allow"` also matches the `mcp connect <server>` candidate list,
|
|
180
|
+
so it auto-allows starting that server — prefer the `server_*` and `server:*`
|
|
181
|
+
forms for tool calls.
|
|
172
182
|
|
|
173
183
|
Package defaults:
|
|
174
184
|
|
|
@@ -407,7 +417,10 @@ assignments, quoted values, and option values are replaced with `*`; safe bare
|
|
|
407
417
|
command words remain in plaintext so the report can emit a matching rule.
|
|
408
418
|
Paths used for path-surface statistics become only
|
|
409
419
|
`workspace`, `temp`, `home`, `external`, `sensitive`, or `unknown`. Request IDs,
|
|
410
|
-
project locations,
|
|
420
|
+
project locations, matched-rule patterns, and alternate command spellings are
|
|
421
|
+
HMACed. Spelling facts are captured at authorizer entry as well as human prompts,
|
|
422
|
+
so automatic allow/deny decisions are included; 39.x requests simply lack the
|
|
423
|
+
field. Reports expose only anonymous spelling fingerprints. Syntactically valid
|
|
411
424
|
custom tool names are also retained in plaintext; invalid names stay anonymous
|
|
412
425
|
as `<custom-tool>`. The database never stores raw commands, raw paths,
|
|
413
426
|
URLs, credentials, or non-Bash tool arguments and inputs. Because safe bare Bash
|
|
@@ -416,7 +429,11 @@ project-specific labels; inspect suggestions before copying them.
|
|
|
416
429
|
|
|
417
430
|
Data lives at
|
|
418
431
|
`~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
|
|
419
|
-
owner-only HMAC key.
|
|
432
|
+
owner-only HMAC key. Version 0.25.0 migrates the database to schema v3; older
|
|
433
|
+
auto-review collectors cannot use that schema. After upgrading, restart or
|
|
434
|
+
reload all sessions using the old extension. Do not delete the database to
|
|
435
|
+
resolve a mixed-version collector warning. Node and Bun use the same database
|
|
436
|
+
schema and key, so
|
|
420
437
|
switching runtimes preserves statistics and request deduplication.
|
|
421
438
|
The directory is mode `0700`; the key, database, WAL, and
|
|
422
439
|
SHM are mode `0600`. Initialization, lock, write, or corruption failures disable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|
|
@@ -47,16 +47,16 @@
|
|
|
47
47
|
"test": "node --disable-warning=ExperimentalWarning --experimental-transform-types --import ./test/register-external-ts.mjs --test test/*.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": "^0.
|
|
51
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
52
|
-
"@gotgenes/pi-permission-system": ">=
|
|
50
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
52
|
+
"@gotgenes/pi-permission-system": ">=39.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "^0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
57
|
-
"@earendil-works/pi-server": "^0.
|
|
58
|
-
"@gotgenes/pi-permission-system": "^
|
|
59
|
-
"@types/node": "^26.6.
|
|
55
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
57
|
+
"@earendil-works/pi-server": "^1.0.0",
|
|
58
|
+
"@gotgenes/pi-permission-system": "^40.0.2",
|
|
59
|
+
"@types/node": "^26.6.4",
|
|
60
60
|
"typescript": "^7.0.2"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/broker/grants.ts
CHANGED
|
@@ -25,6 +25,9 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
|
|
|
25
25
|
executedUnit: request.executedUnit,
|
|
26
26
|
fullCommand: request.fullCommand,
|
|
27
27
|
matchedPattern: request.matchedPattern,
|
|
28
|
+
// matchedSpelling stays out: it is display-only (#910) and derives from the
|
|
29
|
+
// same request, so binding it would change grant hashes when a permission-
|
|
30
|
+
// system upgrade starts (or stops) projecting it.
|
|
28
31
|
path: request.path,
|
|
29
32
|
resolvedPath: request.resolvedPath,
|
|
30
33
|
destination: request.destination,
|
package/src/broker/types.ts
CHANGED
|
@@ -25,6 +25,12 @@ export type BoundaryRequest = {
|
|
|
25
25
|
fullCommand?: string;
|
|
26
26
|
/** Rule (or synthetic sentinel) that produced this ask. */
|
|
27
27
|
matchedPattern?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The spelling of the rule-matched bash unit when it differs from `command`
|
|
30
|
+
* as typed — the absolute spelling of a relative path argument, say
|
|
31
|
+
* (permission-system #910). Display-only: never bound into request hashes.
|
|
32
|
+
*/
|
|
33
|
+
matchedSpelling?: string;
|
|
28
34
|
/**
|
|
29
35
|
* Set only on the bounded reviewer view when a command fact was truncated;
|
|
30
36
|
* never set on the stored request used for hashing or hard denies.
|
package/src/index.ts
CHANGED
|
@@ -356,6 +356,7 @@ export function createPiAutoReviewExtension(
|
|
|
356
356
|
executedUnit: request.executedUnit,
|
|
357
357
|
fullCommand: request.fullCommand,
|
|
358
358
|
matchedPattern: request.matchedPattern,
|
|
359
|
+
matchedSpelling: request.matchedSpelling,
|
|
359
360
|
path: request.path,
|
|
360
361
|
target: request.destination,
|
|
361
362
|
toolName: request.toolName,
|
|
@@ -670,6 +671,7 @@ export function createPiAutoReviewExtension(
|
|
|
670
671
|
|
|
671
672
|
pi.events.on("permissions:ui_prompt", (event) => {
|
|
672
673
|
if (context) uiAutoConfirmer.handlePrompt(event, context);
|
|
674
|
+
policyAudit.notePrompt(event);
|
|
673
675
|
});
|
|
674
676
|
|
|
675
677
|
pi.events.on("permissions:decision", (event) => {
|
|
@@ -716,6 +718,7 @@ export function createPiAutoReviewExtension(
|
|
|
716
718
|
EXTENSION_NAME,
|
|
717
719
|
async (details, query, log: AuthorizerLog) => {
|
|
718
720
|
const evidence = normalizePermissionEvidence(details);
|
|
721
|
+
policyAudit.noteRequest(details.requestId, evidence);
|
|
719
722
|
const surface = evidence.surface;
|
|
720
723
|
if (!context || !broker) {
|
|
721
724
|
const reason = "review context is unavailable";
|
|
@@ -18,6 +18,7 @@ export type PermissionDecisionLike = {
|
|
|
18
18
|
resolution?: unknown;
|
|
19
19
|
origin?: unknown;
|
|
20
20
|
matchedPattern?: unknown;
|
|
21
|
+
matchedSpelling?: unknown;
|
|
21
22
|
forwarding?: unknown;
|
|
22
23
|
};
|
|
23
24
|
|
|
@@ -38,6 +39,9 @@ const PERMISSION_RESOLUTIONS = new Set([
|
|
|
38
39
|
"gate_error",
|
|
39
40
|
]);
|
|
40
41
|
|
|
42
|
+
/** Upper bound on remembered ask spellings awaiting their decision event. */
|
|
43
|
+
const PROMPT_SPELLING_CACHE_LIMIT = 256;
|
|
44
|
+
|
|
41
45
|
export function parsePolicyAuditArguments(raw: string, retentionDays: number): PolicyAuditArguments {
|
|
42
46
|
const result: PolicyAuditArguments = { days: Math.min(30, retentionDays), top: 20, minCount: 5, scope: "current" };
|
|
43
47
|
const tokens = raw.trim() ? raw.trim().split(/\s+/u) : [];
|
|
@@ -76,9 +80,43 @@ export class PolicyAuditController {
|
|
|
76
80
|
private disabled = false;
|
|
77
81
|
private warned = false;
|
|
78
82
|
private queue = Promise.resolve();
|
|
83
|
+
/**
|
|
84
|
+
* Ask spelling captured at authorizer entry or from `permissions:ui_prompt`,
|
|
85
|
+
* keyed by request id. Automatic allow/deny decisions never open a UI prompt.
|
|
86
|
+
* The `permissions:decision` event still carries only `matchedPattern`, so
|
|
87
|
+
* the #910 spelling has to be joined by request id at record time. The map
|
|
88
|
+
* is bounded FIFO: spellings for asks whose decision never arrives are
|
|
89
|
+
* dropped oldest-first, and the spelling is display-only anyway.
|
|
90
|
+
*/
|
|
91
|
+
private readonly promptSpellings = new Map<string, string>();
|
|
79
92
|
|
|
80
93
|
constructor(private readonly options: PolicyAuditControllerOptions) {}
|
|
81
94
|
|
|
95
|
+
/** Capture spelling before an authorizer or human can decide the ask. */
|
|
96
|
+
noteRequest(requestId: unknown, rawRequest: unknown): void {
|
|
97
|
+
if (!this.options.config().enabled || this.disabled) return;
|
|
98
|
+
const request = rawRequest && typeof rawRequest === "object" && !Array.isArray(rawRequest)
|
|
99
|
+
? rawRequest as Record<string, unknown>
|
|
100
|
+
: undefined;
|
|
101
|
+
const spelling = request?.matchedSpelling;
|
|
102
|
+
if (typeof requestId !== "string" || !requestId || typeof spelling !== "string" || spelling.length === 0) return;
|
|
103
|
+
// A deferred authorizer and its eventual UI prompt describe the same ask.
|
|
104
|
+
// Updating that entry must not evict an unrelated outstanding request.
|
|
105
|
+
if (!this.promptSpellings.has(requestId) && this.promptSpellings.size >= PROMPT_SPELLING_CACHE_LIMIT) {
|
|
106
|
+
const oldest = this.promptSpellings.keys().next().value;
|
|
107
|
+
if (oldest !== undefined) this.promptSpellings.delete(oldest);
|
|
108
|
+
}
|
|
109
|
+
this.promptSpellings.set(requestId, spelling);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Capture human prompts, including asks that bypass our authorizer. */
|
|
113
|
+
notePrompt(raw: unknown): void {
|
|
114
|
+
const event = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
115
|
+
? raw as Record<string, unknown>
|
|
116
|
+
: undefined;
|
|
117
|
+
this.noteRequest(event?.requestId, event?.request);
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
warmup(): void {
|
|
83
121
|
if (this.options.config().enabled) void this.getStore().catch(() => undefined);
|
|
84
122
|
}
|
|
@@ -137,7 +175,13 @@ export class PolicyAuditController {
|
|
|
137
175
|
matchedPattern: typeof event.matchedPattern === "string"
|
|
138
176
|
? digest("rule", event.matchedPattern)
|
|
139
177
|
: undefined,
|
|
178
|
+
matchedSpelling: typeof event.matchedSpelling === "string" && event.matchedSpelling.length > 0
|
|
179
|
+
? digest("spelling", event.matchedSpelling)
|
|
180
|
+
: this.promptSpellings.has(event.requestId)
|
|
181
|
+
? digest("spelling", this.promptSpellings.get(event.requestId)!)
|
|
182
|
+
: undefined,
|
|
140
183
|
};
|
|
184
|
+
this.promptSpellings.delete(event.requestId);
|
|
141
185
|
this.queue = this.queue
|
|
142
186
|
.then(async () => (await this.getStore()).record(cwd, sanitized))
|
|
143
187
|
.then(() => undefined)
|
|
@@ -165,6 +209,7 @@ export class PolicyAuditController {
|
|
|
165
209
|
}
|
|
166
210
|
|
|
167
211
|
async close(): Promise<void> {
|
|
212
|
+
this.promptSpellings.clear();
|
|
168
213
|
await this.queue;
|
|
169
214
|
// Detach before awaiting so concurrent shutdowns cannot double-close.
|
|
170
215
|
const storePromise = this.storePromise;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PolicyAuditAggregateRow, PolicyAuditQueryResult } from "./store.ts";
|
|
2
2
|
|
|
3
|
-
export const POLICY_AUDIT_REPORT_VERSION =
|
|
3
|
+
export const POLICY_AUDIT_REPORT_VERSION = 3 as const;
|
|
4
4
|
|
|
5
5
|
export type PolicyAuditReportItem = { name: string; count: number; denied: number; denialRate: number };
|
|
6
6
|
export type SuggestedAllowRule = {
|
|
@@ -35,6 +35,7 @@ export type PolicyAuditReport = {
|
|
|
35
35
|
lowRiskReviewCandidates: PolicyAuditReportItem[];
|
|
36
36
|
keepAskRecommendations: PolicyAuditReportItem[];
|
|
37
37
|
ruleFingerprints: Array<{ fingerprint: string; count: number }>;
|
|
38
|
+
spellingFingerprints: Array<{ fingerprint: string; count: number }>;
|
|
38
39
|
warnings: string[];
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -213,6 +214,14 @@ export function buildPolicyAuditReport(
|
|
|
213
214
|
if (row.ruleFingerprint === "none") continue;
|
|
214
215
|
fingerprintCounts.set(row.ruleFingerprint, (fingerprintCounts.get(row.ruleFingerprint) ?? 0) + row.count);
|
|
215
216
|
}
|
|
217
|
+
// Anonymous spelling fingerprints (#910): how often the matched rule hit a
|
|
218
|
+
// bash spelling other than the command as typed, e.g. its absolute path.
|
|
219
|
+
// Rows without an alternate spelling carry the 'none' sentinel and are skipped.
|
|
220
|
+
const spellingCounts = new Map<string, number>();
|
|
221
|
+
for (const row of result.rows) {
|
|
222
|
+
if (row.spellingFingerprint === "none") continue;
|
|
223
|
+
spellingCounts.set(row.spellingFingerprint, (spellingCounts.get(row.spellingFingerprint) ?? 0) + row.count);
|
|
224
|
+
}
|
|
216
225
|
const warnings = [
|
|
217
226
|
"Counts begin at first successful enablement; no historical logs are imported.",
|
|
218
227
|
"Suggestions are evidence-based decision aids and never modify permission configuration.",
|
|
@@ -251,6 +260,11 @@ export function buildPolicyAuditReport(
|
|
|
251
260
|
.filter((item) => item.count >= options.minCount)
|
|
252
261
|
.sort((a, b) => b.count - a.count || a.fingerprint.localeCompare(b.fingerprint))
|
|
253
262
|
.slice(0, options.top),
|
|
263
|
+
spellingFingerprints: [...spellingCounts]
|
|
264
|
+
.map(([fingerprint, count]) => ({ fingerprint, count }))
|
|
265
|
+
.filter((item) => item.count >= options.minCount)
|
|
266
|
+
.sort((a, b) => b.count - a.count || a.fingerprint.localeCompare(b.fingerprint))
|
|
267
|
+
.slice(0, options.top),
|
|
254
268
|
warnings,
|
|
255
269
|
};
|
|
256
270
|
}
|
|
@@ -299,6 +313,10 @@ function renderHotspotSections(report: PolicyAuditReport): string[] {
|
|
|
299
313
|
groups.push({ title: "Anonymous rule-hit fingerprints",
|
|
300
314
|
items: report.ruleFingerprints.map((f) => ({ name: f.fingerprint, count: f.count, denied: 0, denialRate: 0 })) });
|
|
301
315
|
}
|
|
316
|
+
if (report.spellingFingerprints.length > 0) {
|
|
317
|
+
groups.push({ title: "Anonymous alternate-spelling fingerprints",
|
|
318
|
+
items: report.spellingFingerprints.map((f) => ({ name: f.fingerprint, count: f.count, denied: 0, denialRate: 0 })) });
|
|
319
|
+
}
|
|
302
320
|
return groups.flatMap((group) => ["", `## ${group.title}`, "", ...group.items.map(hotspotRow)]);
|
|
303
321
|
}
|
|
304
322
|
|
|
@@ -13,7 +13,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
13
13
|
import type { ClassifiedPermission } from "./classifier.ts";
|
|
14
14
|
import { loadSqlite, type SqliteDatabase, type SqliteModule } from "./sqlite.ts";
|
|
15
15
|
|
|
16
|
-
export const POLICY_AUDIT_SCHEMA_VERSION =
|
|
16
|
+
export const POLICY_AUDIT_SCHEMA_VERSION = 3;
|
|
17
17
|
export const POLICY_AUDIT_DATABASE_NAME = "policy-audit.sqlite";
|
|
18
18
|
export const POLICY_AUDIT_KEY_NAME = "policy-audit.key";
|
|
19
19
|
|
|
@@ -24,6 +24,11 @@ export type SanitizedPermissionDecision = ClassifiedPermission & {
|
|
|
24
24
|
origin: string;
|
|
25
25
|
forwarded: boolean;
|
|
26
26
|
matchedPattern?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Already-digested spelling of the rule-matched bash unit when it differed
|
|
29
|
+
* from the command as typed (#910); absent when the typed text decided.
|
|
30
|
+
*/
|
|
31
|
+
matchedSpelling?: string;
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export type PolicyAuditStoreOptions = {
|
|
@@ -53,6 +58,7 @@ export type PolicyAuditAggregateRow = {
|
|
|
53
58
|
forwarded: boolean;
|
|
54
59
|
features: string;
|
|
55
60
|
ruleFingerprint: string;
|
|
61
|
+
spellingFingerprint: string;
|
|
56
62
|
candidateSurface: string;
|
|
57
63
|
candidatePattern: string;
|
|
58
64
|
candidateSafetyClass: string;
|
|
@@ -146,18 +152,19 @@ export class PolicyAuditStore {
|
|
|
146
152
|
`);
|
|
147
153
|
const version = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value;
|
|
148
154
|
const now = this.now().toISOString();
|
|
149
|
-
if (version !== undefined && version !== "1" && version !== String(POLICY_AUDIT_SCHEMA_VERSION)) {
|
|
155
|
+
if (version !== undefined && version !== "1" && version !== "2" && version !== String(POLICY_AUDIT_SCHEMA_VERSION)) {
|
|
150
156
|
throw new Error("unsupported policy audit schema version");
|
|
151
157
|
}
|
|
152
158
|
if (version === "1") this.migrateV1(now);
|
|
153
|
-
else this.
|
|
159
|
+
else if (version === "2") this.migrateV2(now);
|
|
160
|
+
else this.createV3Table();
|
|
154
161
|
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('schema_version',?)").run(String(POLICY_AUDIT_SCHEMA_VERSION));
|
|
155
162
|
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('collecting_since',?)").run(now);
|
|
156
163
|
this.db.prepare("INSERT OR IGNORE INTO meta(key,value) VALUES('recommendations_since',?)").run(now);
|
|
157
164
|
this.secureSidecars();
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
private
|
|
167
|
+
private createV3Table(): void {
|
|
161
168
|
this.db.exec(`
|
|
162
169
|
CREATE TABLE IF NOT EXISTS daily_permission_stats (
|
|
163
170
|
day TEXT NOT NULL,
|
|
@@ -173,13 +180,14 @@ export class PolicyAuditStore {
|
|
|
173
180
|
forwarded INTEGER NOT NULL,
|
|
174
181
|
features TEXT NOT NULL,
|
|
175
182
|
rule_fingerprint TEXT NOT NULL,
|
|
183
|
+
spelling_fingerprint TEXT NOT NULL,
|
|
176
184
|
candidate_surface TEXT NOT NULL,
|
|
177
185
|
candidate_pattern TEXT NOT NULL,
|
|
178
186
|
candidate_safety_class TEXT NOT NULL,
|
|
179
187
|
candidate_eligible INTEGER NOT NULL,
|
|
180
188
|
candidate_blocker TEXT NOT NULL,
|
|
181
189
|
count INTEGER NOT NULL,
|
|
182
|
-
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
190
|
+
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, spelling_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
183
191
|
) STRICT;
|
|
184
192
|
CREATE INDEX IF NOT EXISTS daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
|
|
185
193
|
`);
|
|
@@ -189,20 +197,20 @@ export class PolicyAuditStore {
|
|
|
189
197
|
this.db.exec("BEGIN IMMEDIATE");
|
|
190
198
|
try {
|
|
191
199
|
this.db.exec(`
|
|
192
|
-
CREATE TABLE
|
|
200
|
+
CREATE TABLE daily_permission_stats_v3 (
|
|
193
201
|
day TEXT NOT NULL, project_hash TEXT NOT NULL, surface TEXT NOT NULL, signature TEXT NOT NULL,
|
|
194
202
|
bash_category TEXT NOT NULL, risk TEXT NOT NULL, path_class TEXT NOT NULL, result TEXT NOT NULL,
|
|
195
203
|
resolution TEXT NOT NULL, origin TEXT NOT NULL, forwarded INTEGER NOT NULL, features TEXT NOT NULL,
|
|
196
|
-
rule_fingerprint TEXT NOT NULL,
|
|
197
|
-
|
|
198
|
-
count INTEGER NOT NULL,
|
|
199
|
-
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
204
|
+
rule_fingerprint TEXT NOT NULL, spelling_fingerprint TEXT NOT NULL, candidate_surface TEXT NOT NULL,
|
|
205
|
+
candidate_pattern TEXT NOT NULL, candidate_safety_class TEXT NOT NULL, candidate_eligible INTEGER NOT NULL,
|
|
206
|
+
candidate_blocker TEXT NOT NULL, count INTEGER NOT NULL,
|
|
207
|
+
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, spelling_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
200
208
|
) STRICT;
|
|
201
|
-
INSERT INTO
|
|
202
|
-
SELECT day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,'','','',0,'',count
|
|
209
|
+
INSERT INTO daily_permission_stats_v3
|
|
210
|
+
SELECT day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,'none','','','',0,'',count
|
|
203
211
|
FROM daily_permission_stats;
|
|
204
212
|
DROP TABLE daily_permission_stats;
|
|
205
|
-
ALTER TABLE
|
|
213
|
+
ALTER TABLE daily_permission_stats_v3 RENAME TO daily_permission_stats;
|
|
206
214
|
CREATE INDEX daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
|
|
207
215
|
`);
|
|
208
216
|
this.db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(String(POLICY_AUDIT_SCHEMA_VERSION));
|
|
@@ -214,6 +222,39 @@ export class PolicyAuditStore {
|
|
|
214
222
|
}
|
|
215
223
|
}
|
|
216
224
|
|
|
225
|
+
/**
|
|
226
|
+
* v2 → v3: adds `spelling_fingerprint` (#910). Pre-v3 rows never carried a
|
|
227
|
+
* spelling, so they copy over with the same 'none' sentinel fresh no-spelling
|
|
228
|
+
* rows use, and keep contributing to every recommendation unchanged.
|
|
229
|
+
*/
|
|
230
|
+
private migrateV2(now: string): void {
|
|
231
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
232
|
+
try {
|
|
233
|
+
this.db.exec(`
|
|
234
|
+
CREATE TABLE daily_permission_stats_v3 (
|
|
235
|
+
day TEXT NOT NULL, project_hash TEXT NOT NULL, surface TEXT NOT NULL, signature TEXT NOT NULL,
|
|
236
|
+
bash_category TEXT NOT NULL, risk TEXT NOT NULL, path_class TEXT NOT NULL, result TEXT NOT NULL,
|
|
237
|
+
resolution TEXT NOT NULL, origin TEXT NOT NULL, forwarded INTEGER NOT NULL, features TEXT NOT NULL,
|
|
238
|
+
rule_fingerprint TEXT NOT NULL, spelling_fingerprint TEXT NOT NULL, candidate_surface TEXT NOT NULL,
|
|
239
|
+
candidate_pattern TEXT NOT NULL, candidate_safety_class TEXT NOT NULL, candidate_eligible INTEGER NOT NULL,
|
|
240
|
+
candidate_blocker TEXT NOT NULL, count INTEGER NOT NULL,
|
|
241
|
+
PRIMARY KEY (day, project_hash, surface, signature, bash_category, risk, path_class, result, resolution, origin, forwarded, features, rule_fingerprint, spelling_fingerprint, candidate_surface, candidate_pattern, candidate_safety_class, candidate_eligible, candidate_blocker)
|
|
242
|
+
) STRICT;
|
|
243
|
+
INSERT INTO daily_permission_stats_v3
|
|
244
|
+
SELECT day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,'none',candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,count
|
|
245
|
+
FROM daily_permission_stats;
|
|
246
|
+
DROP TABLE daily_permission_stats;
|
|
247
|
+
ALTER TABLE daily_permission_stats_v3 RENAME TO daily_permission_stats;
|
|
248
|
+
CREATE INDEX daily_permission_stats_project_day ON daily_permission_stats(project_hash, day);
|
|
249
|
+
`);
|
|
250
|
+
this.db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(String(POLICY_AUDIT_SCHEMA_VERSION));
|
|
251
|
+
this.db.exec("COMMIT");
|
|
252
|
+
} catch (error) {
|
|
253
|
+
try { this.db.exec("ROLLBACK"); } catch { /* preserve migration error */ }
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
217
258
|
private hash(kind: string, value: string): string {
|
|
218
259
|
return createHmac("sha256", this.key).update(`${kind}\0${value}`).digest("hex");
|
|
219
260
|
}
|
|
@@ -226,6 +267,10 @@ export class PolicyAuditStore {
|
|
|
226
267
|
return pattern ? this.hash("rule", pattern).slice(0, 16) : "none";
|
|
227
268
|
}
|
|
228
269
|
|
|
270
|
+
spellingFingerprint(spelling: string | undefined): string {
|
|
271
|
+
return spelling ? this.hash("spelling", spelling).slice(0, 16) : "none";
|
|
272
|
+
}
|
|
273
|
+
|
|
229
274
|
private retry<T>(operation: () => T): T {
|
|
230
275
|
let last: unknown;
|
|
231
276
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -255,9 +300,9 @@ export class PolicyAuditStore {
|
|
|
255
300
|
}
|
|
256
301
|
this.db.prepare(`
|
|
257
302
|
INSERT INTO daily_permission_stats(
|
|
258
|
-
day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
303
|
+
day,project_hash,surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,spelling_fingerprint,
|
|
259
304
|
candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,count
|
|
260
|
-
) VALUES(
|
|
305
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)
|
|
261
306
|
ON CONFLICT DO UPDATE SET count=count+1
|
|
262
307
|
`).run(
|
|
263
308
|
day,
|
|
@@ -273,6 +318,7 @@ export class PolicyAuditStore {
|
|
|
273
318
|
event.forwarded ? 1 : 0,
|
|
274
319
|
event.features.join(","),
|
|
275
320
|
this.ruleFingerprint(event.matchedPattern),
|
|
321
|
+
this.spellingFingerprint(event.matchedSpelling),
|
|
276
322
|
event.candidate?.surface ?? "",
|
|
277
323
|
event.candidate?.pattern ?? "",
|
|
278
324
|
event.candidate?.safetyClass ?? "",
|
|
@@ -306,17 +352,18 @@ export class PolicyAuditStore {
|
|
|
306
352
|
const params: unknown[] = [fromDay];
|
|
307
353
|
if (input.scope === "current") params.push(this.projectHash(input.projectPath));
|
|
308
354
|
const rows = this.db.prepare(`
|
|
309
|
-
SELECT surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
355
|
+
SELECT surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,spelling_fingerprint,
|
|
310
356
|
candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker,SUM(count) AS count
|
|
311
357
|
FROM daily_permission_stats
|
|
312
358
|
WHERE day >= ? ${projectClause}
|
|
313
|
-
GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,
|
|
359
|
+
GROUP BY surface,signature,bash_category,risk,path_class,result,resolution,origin,forwarded,features,rule_fingerprint,spelling_fingerprint,
|
|
314
360
|
candidate_surface,candidate_pattern,candidate_safety_class,candidate_eligible,candidate_blocker
|
|
315
361
|
`).all(...params).map((row) => ({
|
|
316
362
|
surface: String(row.surface), signature: String(row.signature), bashCategory: String(row.bash_category),
|
|
317
363
|
risk: String(row.risk), pathClass: String(row.path_class), result: String(row.result),
|
|
318
364
|
resolution: String(row.resolution), origin: String(row.origin), forwarded: Number(row.forwarded) === 1,
|
|
319
|
-
features: String(row.features), ruleFingerprint: String(row.rule_fingerprint),
|
|
365
|
+
features: String(row.features), ruleFingerprint: String(row.rule_fingerprint),
|
|
366
|
+
spellingFingerprint: String(row.spelling_fingerprint), count: Number(row.count),
|
|
320
367
|
candidateSurface: String(row.candidate_surface), candidatePattern: String(row.candidate_pattern),
|
|
321
368
|
candidateSafetyClass: String(row.candidate_safety_class), candidateEligible: Number(row.candidate_eligible) === 1,
|
|
322
369
|
candidateBlocker: String(row.candidate_blocker),
|
package/src/policy.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type PermissionDetailsLike = {
|
|
|
31
31
|
executedUnit?: unknown;
|
|
32
32
|
fullCommand?: unknown;
|
|
33
33
|
matchedPattern?: unknown;
|
|
34
|
+
matchedSpelling?: unknown;
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
export type NormalizedPermissionEvidence = {
|
|
@@ -40,6 +41,13 @@ export type NormalizedPermissionEvidence = {
|
|
|
40
41
|
executedUnit?: string;
|
|
41
42
|
fullCommand?: string;
|
|
42
43
|
matchedPattern?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The spelling of the bash unit the matched rule hit when it did not match
|
|
46
|
+
* the command as typed — the absolute spelling of a relative path argument,
|
|
47
|
+
* say (permission-system #910). Display-only: absent whenever the typed text
|
|
48
|
+
* decided.
|
|
49
|
+
*/
|
|
50
|
+
matchedSpelling?: string;
|
|
43
51
|
path?: string;
|
|
44
52
|
resolvedPath?: string;
|
|
45
53
|
destination?: string;
|
|
@@ -171,6 +179,7 @@ export type RelevantBoundaryRequest = {
|
|
|
171
179
|
executedUnit?: string;
|
|
172
180
|
fullCommand?: string;
|
|
173
181
|
matchedPattern?: string;
|
|
182
|
+
matchedSpelling?: string;
|
|
174
183
|
path?: string;
|
|
175
184
|
resolvedPath?: string;
|
|
176
185
|
destination?: string;
|
|
@@ -390,6 +399,9 @@ export function normalizePermissionEvidence(
|
|
|
390
399
|
const matchedPattern =
|
|
391
400
|
nonEmptyString(payloadRequest?.matchedPattern) ??
|
|
392
401
|
nonEmptyString(details.matchedPattern);
|
|
402
|
+
const matchedSpelling =
|
|
403
|
+
nonEmptyString(payloadRequest?.matchedSpelling) ??
|
|
404
|
+
nonEmptyString(details.matchedSpelling);
|
|
393
405
|
const executedUnitCandidate =
|
|
394
406
|
nonEmptyString(payloadRequest?.executedUnit) ??
|
|
395
407
|
nonEmptyString(details.executedUnit);
|
|
@@ -412,6 +424,7 @@ export function normalizePermissionEvidence(
|
|
|
412
424
|
...(executedUnit ? { executedUnit } : {}),
|
|
413
425
|
...(fullCommand ? { fullCommand } : {}),
|
|
414
426
|
...(matchedPattern ? { matchedPattern } : {}),
|
|
427
|
+
...(matchedSpelling ? { matchedSpelling } : {}),
|
|
415
428
|
path,
|
|
416
429
|
resolvedPath: accessIntent?.boundaryValue,
|
|
417
430
|
destination,
|
package/src/review/input.ts
CHANGED
|
@@ -119,6 +119,9 @@ export function boundaryRequest(
|
|
|
119
119
|
...(evidence.matchedPattern
|
|
120
120
|
? { matchedPattern: evidence.matchedPattern }
|
|
121
121
|
: {}),
|
|
122
|
+
...(evidence.matchedSpelling
|
|
123
|
+
? { matchedSpelling: evidence.matchedSpelling }
|
|
124
|
+
: {}),
|
|
122
125
|
path: evidence.path,
|
|
123
126
|
resolvedPath: evidence.resolvedPath,
|
|
124
127
|
destination: evidence.destination,
|
package/src/review/prompts.ts
CHANGED
|
@@ -22,7 +22,8 @@ Policy:
|
|
|
22
22
|
- Request facts: command is the rule-matched unit; executedUnit is what that
|
|
23
23
|
unit runs after unwrapping sudo/env/xargs/inline shells; fullCommand is the
|
|
24
24
|
whole program; matchedPattern names the rule or a sentinel such as
|
|
25
|
-
<opaque-bash-wrapper>, <indirection-bash-wrapper>, or <unparsed-bash-subtree
|
|
25
|
+
<opaque-bash-wrapper>, <indirection-bash-wrapper>, or <unparsed-bash-subtree>;
|
|
26
|
+
matchedSpelling, when present, is the alternate spelling the rule hit.
|
|
26
27
|
When fullCommandTruncated is true or a wrapper payload is unresolved, defer;
|
|
27
28
|
never assume unseen text is safe.
|
|
28
29
|
|