@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 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.99.1 or later and supports
35
- > permission-system 36.0.0 and later:
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, an MCP discovery policy, and a
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
- MCP rules are evaluated last-match-wins across the candidates derived for one
164
- call: the tool name, its bare server, and `mcp_call`. Keep `"*"` first in the
165
- `mcp` block and narrower server or tool entries after it, as the example does,
166
- so a specific rule cannot be shadowed by the catch-all. A bare server entry
167
- such as `"atlassian": "allow"` also matches the `mcp connect <server>`
168
- candidate list, so it auto-allows starting that server — prefer the `server_*`
169
- and `server:*` forms for tool calls. Any entry meant to narrow a broad allow
170
- (such as a destructive-tool pattern) must be written after it, or
171
- last-match-wins shadows it.
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, and matched-rule patterns are HMACed. Syntactically valid
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. Node and Bun use the same database schema and key, so
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.23.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.99.1",
51
- "@earendil-works/pi-coding-agent": "^0.99.1",
52
- "@gotgenes/pi-permission-system": ">=36.0.0"
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.99.1",
56
- "@earendil-works/pi-coding-agent": "^0.99.1",
57
- "@earendil-works/pi-server": "^0.99.1",
58
- "@gotgenes/pi-permission-system": "^36.0.0",
59
- "@types/node": "^26.6.2",
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
  }
@@ -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,
@@ -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 = 2 as const;
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 = 2;
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.createV2Table();
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 createV2Table(): void {
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 daily_permission_stats_v2 (
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, candidate_surface TEXT NOT NULL, candidate_pattern TEXT NOT NULL,
197
- candidate_safety_class TEXT NOT NULL, candidate_eligible INTEGER NOT NULL, candidate_blocker TEXT NOT NULL,
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 daily_permission_stats_v2
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 daily_permission_stats_v2 RENAME TO daily_permission_stats;
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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)
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), count: Number(row.count),
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,
@@ -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,
@@ -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