@erichll/pi-auto-review 0.24.0 → 0.26.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,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0 - 2026-10-08
4
+
5
+ - Upgrade Pi development dependencies and peer floors to `^1.1.0`, require
6
+ permission-system `>=40.0.2`, and align the Node engine with Pi
7
+ (`>=22.19.0`). Node types (`^26.6.4`) and TypeScript (`^7.0.2`) already
8
+ match the latest stable registry versions.
9
+ - Retry `server_busy` and server-busy provider details within the existing
10
+ two-attempt review budget, aligning with Pi 1.1.0
11
+ ([#10543](https://github.com/earendil-works/pi/issues/10543)).
12
+ - Recognize the exact Mistral `finish_reason: "error"` message from pi-ai
13
+ 1.0.x as a transient server failure. pi-ai 1.1.0 adds a server-error suffix
14
+ that the existing classifier already recognizes
15
+ ([#10487](https://github.com/earendil-works/pi/issues/10487)); keep unknown,
16
+ authentication, and configuration failures non-retryable.
17
+ - Clear staged UI auto-confirmations and one-shot interceptors when Pi 1.1.0
18
+ emits `agent_settled.aborted: true`
19
+ ([#10607](https://github.com/earendil-works/pi/issues/10607)). Preserve
20
+ existing behavior when the field is absent, false, or malformed.
21
+
22
+ ## 0.25.0 - 2026-10-07
23
+
24
+ - Pass permission-system's matched bash spelling (#910) through the review and
25
+ audit pipeline. The ask payload's `request.matchedSpelling` — the alternate
26
+ spelling the matched rule hit when it did not match the command as typed,
27
+ e.g. the absolute spelling of a relative path argument — is now normalized
28
+ into the reviewer evidence and `BoundaryRequest`, described to the model
29
+ reviewer, carried through hard-deny audits, and persisted in the policy
30
+ audit as an anonymous `spelling_fingerprint` (schema v3; v1 and v2
31
+ databases migrate transactionally). Because `permissions:decision` still
32
+ carries only `matchedPattern`, the spelling is captured from
33
+ authorizer entry (including automatic allow/deny) and
34
+ `permissions:ui_prompt` by request id and joined at record time with a
35
+ bounded FIFO cache; decisions that carry the field directly are used
36
+ as-is. The report grows a "spelling fingerprints" section (v3); the
37
+ spelling stays out of the boundary request hash, which is display-only
38
+ input. On permission-system 39.x the field is simply absent and the audit
39
+ behaves exactly as before. The development baseline moves to ^40.0.2,
40
+ including the /dev/null core-reader exemption fix and bash-chain session
41
+ grant isolation security fix; the peer floor stays `>=39.0.0`. Real
42
+ permission-system integration tests cover alternate spellings, redirect
43
+ exemptions, automatic spelling audits, and sibling-unit isolation.
44
+ - Isolate authorizer tests from the real user audit database: ordinary tests
45
+ disable audit collection, while audit integration uses a temporary HOME.
46
+ Schema v3 is a forward migration; upgrade and restart all audit collectors
47
+ rather than leaving pre-v3 sessions writing to the upgraded database.
48
+
3
49
  ## 0.24.0 - 2026-10-03
4
50
 
5
51
  - Raise the `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent`
package/README.md CHANGED
@@ -31,11 +31,11 @@ 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 1.0.0 or later and supports
35
- > permission-system 36.0.0 and later (development baseline validated against
36
- > permission-system 38.0.2):
34
+ > extension. This release line requires Pi 1.1.0 or later and
35
+ > permission-system 40.0.2 or later, including the bash-chain session-grant
36
+ > security fix:
37
37
 
38
- Node-based Pi requires Node.js 22.13.0 or newer. Permission auditing uses the
38
+ Node-based Pi requires Node.js 22.19.0 or newer. Permission auditing uses the
39
39
  host runtime's built-in SQLite: `node:sqlite` on Node and `bun:sqlite` on Bun,
40
40
  including Bun-compiled Pi (verified with Bun 1.3.14). It does not require a
41
41
  SQLite CLI, system SQLite library, or npm SQLite package.
@@ -109,6 +109,13 @@ credential rules also keep scanning raw text. When a command fact exceeds the
109
109
  reviewer bound (10 KiB), the ask defers to a human instead of being reviewed from
110
110
  a truncated program.
111
111
 
112
+ Permission-system 40.x also reports the alternate command spelling a rule
113
+ matched, for example `cat /repo/file` when the command was `cat ./file`.
114
+ The reviewer sees this as explanatory evidence; it does not replace the actual
115
+ command or enter the grant/retry hash. Absolute-path Bash rules now also reach
116
+ relative spellings of that path, so review existing allow/ask/deny rules when
117
+ upgrading from 39.x.
118
+
112
119
  Nested interpreter expansion is limited to 32 payloads and 32 KiB of cumulative
113
120
  payload text per command fact, across all recursion levels. Exceeding either
114
121
  budget keeps the raw-command hard-deny checks and otherwise defers to a human
@@ -409,7 +416,10 @@ assignments, quoted values, and option values are replaced with `*`; safe bare
409
416
  command words remain in plaintext so the report can emit a matching rule.
410
417
  Paths used for path-surface statistics become only
411
418
  `workspace`, `temp`, `home`, `external`, `sensitive`, or `unknown`. Request IDs,
412
- project locations, and matched-rule patterns are HMACed. Syntactically valid
419
+ project locations, matched-rule patterns, and alternate command spellings are
420
+ HMACed. Spelling facts are captured at authorizer entry as well as human prompts,
421
+ so automatic allow/deny decisions are included; 39.x requests simply lack the
422
+ field. Reports expose only anonymous spelling fingerprints. Syntactically valid
413
423
  custom tool names are also retained in plaintext; invalid names stay anonymous
414
424
  as `<custom-tool>`. The database never stores raw commands, raw paths,
415
425
  URLs, credentials, or non-Bash tool arguments and inputs. Because safe bare Bash
@@ -418,7 +428,11 @@ project-specific labels; inspect suggestions before copying them.
418
428
 
419
429
  Data lives at
420
430
  `~/.pi/agent/extensions/pi-auto-review/policy-audit.sqlite`, beside an
421
- owner-only HMAC key. Node and Bun use the same database schema and key, so
431
+ owner-only HMAC key. Version 0.25.0 migrates the database to schema v3; older
432
+ auto-review collectors cannot use that schema. After upgrading, restart or
433
+ reload all sessions using the old extension. Do not delete the database to
434
+ resolve a mixed-version collector warning. Node and Bun use the same database
435
+ schema and key, so
422
436
  switching runtimes preserves statistics and request deduplication.
423
437
  The directory is mode `0700`; the key, database, WAL, and
424
438
  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.24.0",
3
+ "version": "0.26.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.13.0"
27
+ "node": ">=22.19.0"
28
28
  },
29
29
  "files": [
30
30
  "src",
@@ -47,15 +47,15 @@
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": "^1.0.0",
51
- "@earendil-works/pi-coding-agent": "^1.0.0",
52
- "@gotgenes/pi-permission-system": ">=36.0.0"
50
+ "@earendil-works/pi-ai": "^1.1.0",
51
+ "@earendil-works/pi-coding-agent": "^1.1.0",
52
+ "@gotgenes/pi-permission-system": ">=40.0.2"
53
53
  },
54
54
  "devDependencies": {
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": "^38.0.2",
55
+ "@earendil-works/pi-ai": "^1.1.0",
56
+ "@earendil-works/pi-coding-agent": "^1.1.0",
57
+ "@earendil-works/pi-server": "^1.1.0",
58
+ "@gotgenes/pi-permission-system": "^40.0.2",
59
59
  "@types/node": "^26.6.4",
60
60
  "typescript": "^7.0.2"
61
61
  }
@@ -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,
@@ -668,8 +669,17 @@ export function createPiAutoReviewExtension(
668
669
  }
669
670
  });
670
671
 
672
+ pi.on("agent_settled", (event) => {
673
+ // Pi 1.0.x omits aborted. Only an explicit cancellation revokes staged
674
+ // allows and any one-shot UI interceptor left by that run.
675
+ if ((event as { aborted?: unknown }).aborted === true) {
676
+ uiAutoConfirmer.clear();
677
+ }
678
+ });
679
+
671
680
  pi.events.on("permissions:ui_prompt", (event) => {
672
681
  if (context) uiAutoConfirmer.handlePrompt(event, context);
682
+ policyAudit.notePrompt(event);
673
683
  });
674
684
 
675
685
  pi.events.on("permissions:decision", (event) => {
@@ -716,6 +726,7 @@ export function createPiAutoReviewExtension(
716
726
  EXTENSION_NAME,
717
727
  async (details, query, log: AuthorizerLog) => {
718
728
  const evidence = normalizePermissionEvidence(details);
729
+ policyAudit.noteRequest(details.requestId, evidence);
719
730
  const surface = evidence.surface;
720
731
  if (!context || !broker) {
721
732
  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
 
@@ -128,6 +128,7 @@ export function classifyProviderFailure(
128
128
  }
129
129
 
130
130
  const code = errorCode(error);
131
+ if (code === "server_busy") return "transient_server";
131
132
  if (
132
133
  code &&
133
134
  new Set([
@@ -157,7 +158,13 @@ export function classifyProviderFailure(
157
158
  if (/\b429\b|rate.?limit|too many requests/i.test(detail)) {
158
159
  return "rate_limit";
159
160
  }
160
- if (/\b5(?:00|02|03|04)\b|service.?unavailable|server.?error|internal.?error|overloaded/i.test(detail)) {
161
+ if (/\b5(?:00|02|03|04)\b|service.?unavailable|server.?error|internal.?error|overloaded|\bservers?[ _-]busy\b|\bservers? (?:are|is) currently busy\b/i.test(detail)) {
162
+ return "transient_server";
163
+ }
164
+ // pi-ai 1.0.x maps Mistral finish_reason "error" to this exact message.
165
+ // 1.1.0 (#10487) adds "(server error)", covered above. Do not make all
166
+ // stopReason="error" messages retryable: auth/config failures use it too.
167
+ if (/^Provider stopped with: error$/i.test(detail.trim())) {
161
168
  return "transient_server";
162
169
  }
163
170
  if (/unknown model|model not found|invalid model|unsupported model/i.test(detail)) {