@hank-warren/pi-auto-permissions 0.16.2 → 0.17.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/config.ts CHANGED
@@ -34,14 +34,6 @@ export interface AutoPermissionsConfig {
34
34
  model: string;
35
35
  reasoningEffort: ReasoningEffort;
36
36
  timeoutMs: number;
37
- /**
38
- * Two-stage review: a stateless single-token SAFE/REVIEW pass at minimal
39
- * reasoning before the full lineage review. SAFE approves; REVIEW and
40
- * every parse or infrastructure failure fall through to the full review
41
- * (fail closed). Opt-in until evaluation-log data justifies default-on;
42
- * recommended together with `reviewAllShell`.
43
- */
44
- prefilter: boolean;
45
37
  };
46
38
  systemPrompt: string;
47
39
  systemPromptSource: SystemPromptSource;
@@ -58,25 +50,12 @@ export interface AutoPermissionsConfig {
58
50
  * collector never saw it. The model read the text as the user's; the
59
51
  * reviewer read nothing at all.
60
52
  *
61
- * pi-loop's objective is the case that matters: user-typed or
62
- * user-approved, frozen at start, and the whole reason the session is
63
- * doing what it is doing. Reviewing a looping session without it means
64
- * refusing work the user explicitly asked for — observed live, on a
65
- * `git worktree add` whose objective said "in a worktree branched off
66
- * origin/main".
67
- *
68
53
  * An allowlist rather than "project every custom message", for the same
69
54
  * reason `userAnswerTools` is one: any installed extension can append a
70
55
  * custom message, and a blanket rule would let any of them mint user
71
56
  * authorization. Naming the types keeps that an explicit choice.
72
57
  */
73
58
  userMessageTypes: string[];
74
- /** Per-record caps at evidence creation; 0 disables. User records are never truncated. */
75
- toolRecordMaxChars: number;
76
- assistantRecordMaxChars: number;
77
- compactionRecordMaxChars: number;
78
- /** On full envelope rebuilds, collapse all but the newest N tool records; 0 disables. */
79
- fullRebuildKeepToolRecords: number;
80
59
  };
81
60
  evaluationLog: {
82
61
  enabled: boolean;
@@ -91,11 +70,6 @@ export interface AutoPermissionsConfig {
91
70
  enabled: boolean;
92
71
  path: string;
93
72
  };
94
- /** User-granted comparable-command approvals, shared across projects. Default on. */
95
- standingApprovals: {
96
- enabled: boolean;
97
- path: string;
98
- };
99
73
  rules: Gate[];
100
74
  /**
101
75
  * Review every bash command that matches no rule under the generic
@@ -125,10 +99,11 @@ export interface AutoPermissionsConfig {
125
99
  ui: {
126
100
  enabled: boolean;
127
101
  resultDisplayMs: number;
128
- placement: "widget" | "toolRow";
129
102
  };
130
103
  }
131
104
 
105
+ export type ReviewerConfig = NonNullable<AutoPermissionsConfig["reviewer"]>;
106
+
132
107
  interface RuleInput {
133
108
  pattern?: unknown;
134
109
  flags?: unknown;
@@ -164,6 +139,33 @@ function optionalString(value: unknown, name: string): string | undefined {
164
139
  return value.trim();
165
140
  }
166
141
 
142
+ function objectBlock(value: unknown, name: string): Record<string, unknown> | undefined {
143
+ if (value === undefined) return undefined;
144
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
145
+ return value as Record<string, unknown>;
146
+ }
147
+
148
+ function stringList(value: unknown, name: string): string[] {
149
+ if (value === undefined) return [];
150
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) {
151
+ throw new Error(`${name} must be an array of non-empty strings`);
152
+ }
153
+ return [...new Set((value as string[]).map((entry) => entry.trim()))];
154
+ }
155
+
156
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number, name: string): number {
157
+ const v = value === undefined ? fallback : value;
158
+ if (!Number.isInteger(v) || Number(v) < min || Number(v) > max) {
159
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
160
+ }
161
+ return Number(v);
162
+ }
163
+
164
+ function optionalBoolean(value: unknown, name: string): boolean | undefined {
165
+ if (value !== undefined && typeof value !== "boolean") throw new Error(`${name} must be boolean`);
166
+ return value as boolean | undefined;
167
+ }
168
+
167
169
  function compileRule(value: unknown, index: number): Gate {
168
170
  if (!value || typeof value !== "object" || Array.isArray(value)) {
169
171
  throw new Error(`rules[${index}] must be an object`);
@@ -175,13 +177,15 @@ function compileRule(value: unknown, index: number): Gate {
175
177
  if (!pattern || !group || !label) throw new Error(`rules[${index}] requires pattern, group, and label`);
176
178
  const flags = input.flags === undefined ? "i" : input.flags;
177
179
  if (typeof flags !== "string") throw new Error(`rules[${index}].flags must be a string`);
178
- const level: GateLevel = input.level === undefined ? "guarded" : input.level as GateLevel;
179
- if (level !== "guarded" && level !== "convention" && level !== "deny") {
180
- throw new Error(`rules[${index}].level must be guarded, convention, or deny`);
180
+ const rawLevel = input.level === undefined ? "guarded" : input.level;
181
+ if (rawLevel !== "guarded" && rawLevel !== "deny" && rawLevel !== "convention") {
182
+ throw new Error(`rules[${index}].level must be guarded or deny`);
181
183
  }
184
+ // The retired convention level blocked without review; deny keeps that.
185
+ const level: GateLevel = rawLevel === "convention" ? "deny" : rawLevel;
182
186
  const message = optionalString(input.message, `rules[${index}].message`);
183
- if ((level === "convention" || level === "deny") && !message) {
184
- throw new Error(`rules[${index}].message is required for ${level} rules`);
187
+ if (level === "deny" && !message) {
188
+ throw new Error(`rules[${index}].message is required for deny rules`);
185
189
  }
186
190
 
187
191
  return {
@@ -223,6 +227,11 @@ export function expandRules(rawRules: readonly unknown[], defaults: readonly Gat
223
227
  return rules;
224
228
  }
225
229
 
230
+ function resolveConfigRelativePath(value: string, configFilePath: string): string {
231
+ const expanded = value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
232
+ return isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded);
233
+ }
234
+
226
235
  function resolvePrompt(
227
236
  raw: Record<string, unknown>,
228
237
  path: string,
@@ -233,146 +242,54 @@ function resolvePrompt(
233
242
  if (inline) return { prompt: inline, source: { kind: "inline" } };
234
243
  if (!file) return { prompt: AUTO_PERMISSIONS_SYSTEM_PROMPT, source: { kind: "builtin" } };
235
244
 
236
- const expanded = file.startsWith("~/") ? join(homedir(), file.slice(2)) : file;
237
- const resolved = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded);
245
+ const resolved = resolveConfigRelativePath(file, path);
238
246
  const prompt = readFileSync(resolved, "utf8").trim();
239
247
  if (!prompt) throw new Error("systemPromptFile is empty");
240
248
  return { prompt, source: { kind: "file", path: resolved } };
241
249
  }
242
250
 
243
- /**
244
- * The one injected message type trusted out of the box.
245
- *
246
- * pi-loop's kickoff anchor (`LOOP_ANCHOR_MESSAGE_TYPE`), which carries the
247
- * objective the user typed or approved. Named as a plain string, the way
248
- * `loop-context.ts` names `PI_LOOP_ACTIVE`: no import, no dependency, and no
249
- * behaviour at all when pi-loop is not installed.
250
- */
251
- const DEFAULT_USER_MESSAGE_TYPES = ["loop-objective"] as const;
252
-
253
- const EVIDENCE_PRUNING_DEFAULTS = {
254
- toolRecordMaxChars: 500,
255
- assistantRecordMaxChars: 1000,
256
- compactionRecordMaxChars: 4000,
257
- fullRebuildKeepToolRecords: 60,
258
- } as const;
259
-
260
- function resolvePruningKnob(evidence: Record<string, unknown>, name: keyof typeof EVIDENCE_PRUNING_DEFAULTS): number {
261
- const value = evidence[name];
262
- if (value === undefined) return EVIDENCE_PRUNING_DEFAULTS[name];
263
- if (!Number.isInteger(value) || Number(value) < 0 || Number(value) > 1_000_000) {
264
- throw new Error(`reviewEvidence.${name} must be an integer between 0 and 1000000`);
265
- }
266
- return Number(value);
267
- }
268
-
269
251
  function resolveReviewEvidence(raw: Record<string, unknown>): AutoPermissionsConfig["reviewEvidence"] {
270
- if (raw.reviewEvidence === undefined) {
271
- return {
272
- projectInstructions: false,
273
- userAnswerTools: [],
274
- userMessageTypes: [...DEFAULT_USER_MESSAGE_TYPES],
275
- ...EVIDENCE_PRUNING_DEFAULTS,
276
- };
277
- }
278
- if (!raw.reviewEvidence || typeof raw.reviewEvidence !== "object" || Array.isArray(raw.reviewEvidence)) {
279
- throw new Error("reviewEvidence must be an object");
280
- }
281
- const evidence = raw.reviewEvidence as Record<string, unknown>;
282
- if (evidence.projectInstructions !== undefined && typeof evidence.projectInstructions !== "boolean") {
283
- throw new Error("reviewEvidence.projectInstructions must be boolean");
284
- }
285
- const rawTools = evidence.userAnswerTools === undefined ? [] : evidence.userAnswerTools;
286
- if (!Array.isArray(rawTools) || rawTools.some((tool) => typeof tool !== "string" || !tool.trim())) {
287
- throw new Error("reviewEvidence.userAnswerTools must be an array of non-empty strings");
288
- }
289
- // Absent means the default, and an explicit empty array means none: a user
290
- // who wants the loop objective out of the envelope must be able to say so,
291
- // and cannot if omission and `[]` are the same thing.
292
- const rawMessageTypes = evidence.userMessageTypes === undefined
293
- ? [...DEFAULT_USER_MESSAGE_TYPES]
294
- : evidence.userMessageTypes;
295
- if (
296
- !Array.isArray(rawMessageTypes)
297
- || rawMessageTypes.some((type) => typeof type !== "string" || !type.trim())
298
- ) {
299
- throw new Error("reviewEvidence.userMessageTypes must be an array of non-empty strings");
300
- }
252
+ const evidence = objectBlock(raw.reviewEvidence, "reviewEvidence") ?? {};
253
+ const projectInstructions = optionalBoolean(evidence.projectInstructions, "reviewEvidence.projectInstructions");
254
+ const userAnswerTools = stringList(evidence.userAnswerTools, "reviewEvidence.userAnswerTools");
255
+ const userMessageTypes = stringList(evidence.userMessageTypes, "reviewEvidence.userMessageTypes");
301
256
  return {
302
- projectInstructions: evidence.projectInstructions === true,
303
- userAnswerTools: [...new Set((rawTools as string[]).map((tool) => tool.trim()))],
304
- userMessageTypes: [...new Set((rawMessageTypes as string[]).map((type) => type.trim()))],
305
- toolRecordMaxChars: resolvePruningKnob(evidence, "toolRecordMaxChars"),
306
- assistantRecordMaxChars: resolvePruningKnob(evidence, "assistantRecordMaxChars"),
307
- compactionRecordMaxChars: resolvePruningKnob(evidence, "compactionRecordMaxChars"),
308
- fullRebuildKeepToolRecords: resolvePruningKnob(evidence, "fullRebuildKeepToolRecords"),
257
+ projectInstructions: projectInstructions === true,
258
+ userAnswerTools,
259
+ userMessageTypes,
309
260
  };
310
261
  }
311
262
 
312
- function resolveEvaluationLog(
313
- raw: Record<string, unknown>,
314
- configFilePath: string,
315
- ): AutoPermissionsConfig["evaluationLog"] {
316
- const defaultPath = resolve(dirname(configFilePath), "review-evals.jsonl");
317
- if (raw.evaluationLog === undefined) return { enabled: false, path: defaultPath };
318
- if (!raw.evaluationLog || typeof raw.evaluationLog !== "object" || Array.isArray(raw.evaluationLog)) {
319
- throw new Error("evaluationLog must be an object");
320
- }
321
- const evaluationLog = raw.evaluationLog as Record<string, unknown>;
322
- if (evaluationLog.enabled !== undefined && typeof evaluationLog.enabled !== "boolean") {
323
- throw new Error("evaluationLog.enabled must be boolean");
324
- }
325
- const configuredPath = optionalString(evaluationLog.path, "evaluationLog.path");
326
- if (!configuredPath) return { enabled: evaluationLog.enabled === true, path: defaultPath };
327
- const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
328
- return {
329
- enabled: evaluationLog.enabled === true,
330
- path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded),
331
- };
332
- }
263
+ type SidecarKey = "evaluationLog" | "usageLog" | "denialLog";
333
264
 
334
- function resolveUsageLog(
265
+ function resolveSidecar(
335
266
  raw: Record<string, unknown>,
267
+ key: SidecarKey,
268
+ fileName: string,
269
+ defaultEnabled: boolean,
336
270
  configFilePath: string,
337
- ): AutoPermissionsConfig["usageLog"] {
338
- const defaultPath = resolve(dirname(configFilePath), "usage.jsonl");
339
- if (raw.usageLog === undefined) return { enabled: true, path: defaultPath };
340
- if (!raw.usageLog || typeof raw.usageLog !== "object" || Array.isArray(raw.usageLog)) {
341
- throw new Error("usageLog must be an object");
342
- }
343
- const usageLog = raw.usageLog as Record<string, unknown>;
344
- if (usageLog.enabled !== undefined && typeof usageLog.enabled !== "boolean") {
345
- throw new Error("usageLog.enabled must be boolean");
346
- }
347
- const enabled = usageLog.enabled !== false;
348
- const configuredPath = optionalString(usageLog.path, "usageLog.path");
349
- if (!configuredPath) return { enabled, path: defaultPath };
350
- const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
351
- return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
271
+ ): { enabled: boolean; path: string } {
272
+ const defaultPath = resolve(dirname(configFilePath), fileName);
273
+ const block = objectBlock(raw[key], key);
274
+ if (!block) return { enabled: defaultEnabled, path: defaultPath };
275
+ const enabled = optionalBoolean(block.enabled, `${key}.enabled`) ?? defaultEnabled;
276
+ const configured = optionalString(block.path, `${key}.path`);
277
+ return { enabled, path: configured ? resolveConfigRelativePath(configured, configFilePath) : defaultPath };
352
278
  }
353
279
 
354
280
  const GUARDIAN_POLICY_KEYS = ["environment", "allow", "softDeny", "hardDeny"] as const;
355
281
 
356
282
  function resolveGuardianPolicy(raw: Record<string, unknown>): AutoPermissionsConfig["guardianPolicy"] {
357
283
  const empty = { environment: [], allow: [], softDeny: [], hardDeny: [] };
358
- if (raw.guardianPolicy === undefined) return empty;
359
- if (!raw.guardianPolicy || typeof raw.guardianPolicy !== "object" || Array.isArray(raw.guardianPolicy)) {
360
- throw new Error("guardianPolicy must be an object");
361
- }
362
- const policy = raw.guardianPolicy as Record<string, unknown>;
284
+ const policy = objectBlock(raw.guardianPolicy, "guardianPolicy");
285
+ if (!policy) return empty;
363
286
  for (const key of Object.keys(policy)) {
364
287
  if (!(GUARDIAN_POLICY_KEYS as readonly string[]).includes(key)) {
365
288
  throw new Error(`guardianPolicy.${key} is not a recognized list (use environment, allow, softDeny, hardDeny)`);
366
289
  }
367
290
  }
368
- const resolveList = (key: typeof GUARDIAN_POLICY_KEYS[number]): string[] => {
369
- const value = policy[key];
370
- if (value === undefined) return [];
371
- if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) {
372
- throw new Error(`guardianPolicy.${key} must be an array of non-empty strings`);
373
- }
374
- return [...new Set((value as string[]).map((entry) => entry.trim()))];
375
- };
291
+ const resolveList = (key: typeof GUARDIAN_POLICY_KEYS[number]): string[] =>
292
+ stringList(policy[key], `guardianPolicy.${key}`);
376
293
  return {
377
294
  environment: resolveList("environment"),
378
295
  allow: resolveList("allow"),
@@ -381,72 +298,16 @@ function resolveGuardianPolicy(raw: Record<string, unknown>): AutoPermissionsCon
381
298
  };
382
299
  }
383
300
 
384
- function resolveDenialLog(
385
- raw: Record<string, unknown>,
386
- configFilePath: string,
387
- ): AutoPermissionsConfig["denialLog"] {
388
- const defaultPath = resolve(dirname(configFilePath), "denials.jsonl");
389
- if (raw.denialLog === undefined) return { enabled: true, path: defaultPath };
390
- if (!raw.denialLog || typeof raw.denialLog !== "object" || Array.isArray(raw.denialLog)) {
391
- throw new Error("denialLog must be an object");
392
- }
393
- const denialLog = raw.denialLog as Record<string, unknown>;
394
- if (denialLog.enabled !== undefined && typeof denialLog.enabled !== "boolean") {
395
- throw new Error("denialLog.enabled must be boolean");
396
- }
397
- const enabled = denialLog.enabled !== false;
398
- const configuredPath = optionalString(denialLog.path, "denialLog.path");
399
- if (!configuredPath) return { enabled, path: defaultPath };
400
- const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
401
- return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
402
- }
403
-
404
- function resolveStandingApprovals(
405
- raw: Record<string, unknown>,
406
- configFilePath: string,
407
- ): AutoPermissionsConfig["standingApprovals"] {
408
- const defaultPath = resolve(dirname(configFilePath), "standing-approvals.jsonl");
409
- if (raw.standingApprovals === undefined) return { enabled: true, path: defaultPath };
410
- if (!raw.standingApprovals || typeof raw.standingApprovals !== "object" || Array.isArray(raw.standingApprovals)) {
411
- throw new Error("standingApprovals must be an object");
412
- }
413
- const standingApprovals = raw.standingApprovals as Record<string, unknown>;
414
- if (standingApprovals.enabled !== undefined && typeof standingApprovals.enabled !== "boolean") {
415
- throw new Error("standingApprovals.enabled must be boolean");
416
- }
417
- const enabled = standingApprovals.enabled !== false;
418
- const configuredPath = optionalString(standingApprovals.path, "standingApprovals.path");
419
- if (!configuredPath) return { enabled, path: defaultPath };
420
- const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
421
- return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
422
- }
423
-
424
301
  function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
425
- if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
426
- if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
427
- throw new Error("ui must be an object");
428
- }
429
- const ui = raw.ui as Record<string, unknown>;
430
- if (ui.enabled !== undefined && typeof ui.enabled !== "boolean") {
431
- throw new Error("ui.enabled must be boolean");
432
- }
433
- const resultDisplayMs = ui.resultDisplayMs === undefined ? 2500 : ui.resultDisplayMs;
434
- if (!Number.isInteger(resultDisplayMs) || Number(resultDisplayMs) < 0 || Number(resultDisplayMs) > 30_000) {
435
- throw new Error("ui.resultDisplayMs must be an integer between 0 and 30000");
436
- }
437
- const placement = ui.placement ?? "widget";
438
- if (placement !== "widget" && placement !== "toolRow") {
439
- throw new Error("ui.placement must be widget or toolRow");
440
- }
441
- return { enabled: ui.enabled !== false, resultDisplayMs: Number(resultDisplayMs), placement };
302
+ const ui = objectBlock(raw.ui, "ui") ?? {};
303
+ const enabled = optionalBoolean(ui.enabled, "ui.enabled");
304
+ const resultDisplayMs = boundedInteger(ui.resultDisplayMs, 2500, 0, 30_000, "ui.resultDisplayMs");
305
+ return { enabled: enabled !== false, resultDisplayMs };
442
306
  }
443
307
 
444
308
  function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["reviewer"] {
445
- if (raw.reviewer === undefined) return undefined;
446
- if (!raw.reviewer || typeof raw.reviewer !== "object" || Array.isArray(raw.reviewer)) {
447
- throw new Error("reviewer must be an object");
448
- }
449
- const reviewer = raw.reviewer as Record<string, unknown>;
309
+ const reviewer = objectBlock(raw.reviewer, "reviewer");
310
+ if (!reviewer) return undefined;
450
311
  const provider = optionalString(reviewer.provider, "reviewer.provider");
451
312
  const model = optionalString(reviewer.model, "reviewer.model");
452
313
  if (!provider || !model) throw new Error("reviewer requires both provider and model");
@@ -455,32 +316,20 @@ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["r
455
316
  if (!REASONING_EFFORTS.includes(reasoningEffort)) {
456
317
  throw new Error("reviewer.reasoningEffort is invalid");
457
318
  }
458
- const timeoutMs = reviewer.timeoutMs === undefined ? DEFAULT_REVIEWER_TIMEOUT_MS : reviewer.timeoutMs;
459
- if (
460
- !Number.isInteger(timeoutMs)
461
- || Number(timeoutMs) < MIN_REVIEWER_TIMEOUT_MS
462
- || Number(timeoutMs) > MAX_REVIEWER_TIMEOUT_MS
463
- ) {
464
- throw new Error("reviewer.timeoutMs must be an integer between 1000 and 300000");
465
- }
466
- if (reviewer.prefilter !== undefined && typeof reviewer.prefilter !== "boolean") {
467
- throw new Error("reviewer.prefilter must be boolean");
468
- }
469
- return {
470
- provider,
471
- model,
472
- reasoningEffort,
473
- timeoutMs: Number(timeoutMs),
474
- prefilter: reviewer.prefilter === true,
475
- };
319
+ const timeoutMs = boundedInteger(
320
+ reviewer.timeoutMs,
321
+ DEFAULT_REVIEWER_TIMEOUT_MS,
322
+ MIN_REVIEWER_TIMEOUT_MS,
323
+ MAX_REVIEWER_TIMEOUT_MS,
324
+ "reviewer.timeoutMs",
325
+ );
326
+ return { provider, model, reasoningEffort, timeoutMs };
476
327
  }
477
328
 
478
329
  export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): AutoPermissionsConfig {
479
330
  const raw = readObject(path);
480
- if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") throw new Error("enabled must be boolean");
481
- if (raw.reviewAllShell !== undefined && typeof raw.reviewAllShell !== "boolean") {
482
- throw new Error("reviewAllShell must be boolean");
483
- }
331
+ optionalBoolean(raw.enabled, "enabled");
332
+ optionalBoolean(raw.reviewAllShell, "reviewAllShell");
484
333
  if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
485
334
  // Absent means the built-in ruleset is active; an authored array replaces it
486
335
  // entirely unless it splices "$defaults" back in; an explicit [] gates
@@ -494,10 +343,9 @@ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): A
494
343
  systemPrompt: prompt.prompt,
495
344
  systemPromptSource: prompt.source,
496
345
  reviewEvidence: resolveReviewEvidence(raw),
497
- evaluationLog: resolveEvaluationLog(raw, path),
498
- usageLog: resolveUsageLog(raw, path),
499
- denialLog: resolveDenialLog(raw, path),
500
- standingApprovals: resolveStandingApprovals(raw, path),
346
+ evaluationLog: resolveSidecar(raw, "evaluationLog", "review-evals.jsonl", false, path),
347
+ usageLog: resolveSidecar(raw, "usageLog", "usage.jsonl", true, path),
348
+ denialLog: resolveSidecar(raw, "denialLog", "denials.jsonl", true, path),
501
349
  rules,
502
350
  reviewAllShell: raw.reviewAllShell === true,
503
351
  guardianPolicy: resolveGuardianPolicy(raw),
package/denial-log.ts CHANGED
@@ -1,25 +1,20 @@
1
- import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
- import { dirname } from "node:path";
4
-
5
- /** Rotate once the sidecar grows past this size; one previous generation is kept. */
6
- const DENIAL_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
3
+ import { appendJsonlRecord, SIDECAR_ROTATE_BYTES } from "./jsonl-sidecar.js";
7
4
 
8
5
  /** The two ways a review can end without the command running. */
9
6
  export type DenialVerdict = "revise" | "block";
10
7
 
11
8
  /**
12
9
  * What produced the denial:
13
- * - `deny` / `convention` — a rule blocked mechanically, no reviewer involved
10
+ * - `deny` — a rule blocked mechanically, no reviewer involved
14
11
  * - `guardian` — the reviewer's own verdict (a revise, or an ask_user with no
15
12
  * interactive user to ask)
16
13
  * - `user` — the user chose Block at the approval prompt
17
- * - `loop` — a guardian verdict delivered as a bounded block because an
18
- * unattended loop cannot answer a prompt
19
14
  * - `review_failure` — review infrastructure failed and the command was
20
15
  * blocked rather than waved through
21
16
  */
22
- export type DenialSource = "deny" | "convention" | "guardian" | "user" | "loop" | "review_failure";
17
+ export type DenialSource = "deny" | "guardian" | "user" | "review_failure";
23
18
 
24
19
  export interface DenialRecord {
25
20
  v: 1;
@@ -60,20 +55,8 @@ export function buildDenialRecord(input: {
60
55
  };
61
56
  }
62
57
 
63
- function rotateIfLarge(path: string): void {
64
- try {
65
- if (statSync(path).size < DENIAL_LOG_ROTATE_BYTES) return;
66
- renameSync(path, `${path}.1`);
67
- } catch {
68
- // A missing or unrotatable sidecar simply keeps appending.
69
- }
70
- }
71
-
72
58
  export function appendDenialRecord(path: string, record: DenialRecord): void {
73
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
74
- rotateIfLarge(path);
75
- appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
76
- chmodSync(path, 0o600);
59
+ appendJsonlRecord(path, record, SIDECAR_ROTATE_BYTES);
77
60
  }
78
61
 
79
62
  /**
package/evaluation-log.ts CHANGED
@@ -1,12 +1,10 @@
1
- import { chmodSync, mkdirSync, appendFileSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { appendJsonlRecord } from "./jsonl-sidecar.js";
3
2
  import type { PermissionDecision, ReviewEvidenceRecord } from "./review.js";
4
3
 
5
4
  export const PROMPT_FEEDBACK_OPTIONS = {
6
5
  allowUnnecessary: "Allow — asking was unnecessary",
7
6
  allowAppropriate: "Allow — asking was appropriate",
8
7
  block: "Block — asking was appropriate",
9
- allowStanding: "Allow and stop asking about comparable commands",
10
8
  } as const;
11
9
 
12
10
  export type PromptEvaluationUserChoice = "allow_unnecessary" | "allow_appropriate" | "block";
@@ -14,29 +12,16 @@ export type PromptEvaluationUserChoice = "allow_unnecessary" | "allow_appropriat
14
12
  export interface PromptChoiceClassification {
15
13
  allowsExecution: boolean;
16
14
  userChoice?: PromptEvaluationUserChoice;
17
- standingApproval?: boolean;
18
15
  }
19
16
 
20
- export function shouldOfferStandingApproval(
21
- decisionSource: "guardian" | "review_failure",
22
- standingApprovalsEnabled: boolean,
23
- ): boolean {
24
- return decisionSource === "guardian" && standingApprovalsEnabled;
25
- }
26
-
27
- export function permissionPromptOptions(
28
- evaluationLoggingEnabled: boolean,
29
- offerStandingApproval = false,
30
- ): string[] {
31
- const options = evaluationLoggingEnabled
17
+ export function permissionPromptOptions(evaluationLoggingEnabled: boolean): string[] {
18
+ return evaluationLoggingEnabled
32
19
  ? [
33
20
  PROMPT_FEEDBACK_OPTIONS.allowUnnecessary,
34
21
  PROMPT_FEEDBACK_OPTIONS.block,
35
22
  PROMPT_FEEDBACK_OPTIONS.allowAppropriate,
36
23
  ]
37
24
  : ["Allow", "Block"];
38
- if (offerStandingApproval) options.push(PROMPT_FEEDBACK_OPTIONS.allowStanding);
39
- return options;
40
25
  }
41
26
 
42
27
  export interface PromptEvaluationRecord {
@@ -59,32 +44,6 @@ export interface PromptEvaluationRecord {
59
44
  expectedDecision: Extract<PermissionDecision, "approve" | "ask_user">;
60
45
  }
61
46
 
62
- /**
63
- * A prefilter SAFE approval, recorded so false negatives are measurable: no
64
- * prompt opened, so there is no user label yet — the record carries the
65
- * command and compact evidence for offline labelling against the same
66
- * criteria as prompted records.
67
- */
68
- interface PrefilterEvaluationRecord {
69
- version: 2;
70
- timestamp: string;
71
- sessionId: string;
72
- cwd: string;
73
- tool: string;
74
- gate: {
75
- label: string;
76
- group: string;
77
- };
78
- userRequest: string;
79
- command: string;
80
- relevantContext: ReviewEvidenceRecord[];
81
- actualDecision: "approve";
82
- actualReason: "prefilter";
83
- decisionSource: "prefilter";
84
- }
85
-
86
- type EvaluationRecord = PromptEvaluationRecord | PrefilterEvaluationRecord;
87
-
88
47
  export function classifyPromptChoice(choice: string | undefined): PromptChoiceClassification | undefined {
89
48
  if (choice === "Allow") return { allowsExecution: true };
90
49
  // Plain "Block" is still live: the logging-disabled prompt offers
@@ -97,9 +56,6 @@ export function classifyPromptChoice(choice: string | undefined): PromptChoiceCl
97
56
  if (choice === PROMPT_FEEDBACK_OPTIONS.allowAppropriate) {
98
57
  return { allowsExecution: true, userChoice: "allow_appropriate" };
99
58
  }
100
- if (choice === PROMPT_FEEDBACK_OPTIONS.allowStanding) {
101
- return { allowsExecution: true, userChoice: "allow_unnecessary", standingApproval: true };
102
- }
103
59
  if (choice === PROMPT_FEEDBACK_OPTIONS.block) {
104
60
  return { allowsExecution: false, userChoice: "block" };
105
61
  }
@@ -112,8 +68,9 @@ export function expectedDecisionForChoice(
112
68
  return choice === "allow_unnecessary" ? "approve" : "ask_user";
113
69
  }
114
70
 
115
- export function appendPromptEvaluation(path: string, record: EvaluationRecord): void {
116
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
117
- appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
118
- chmodSync(path, 0o600);
71
+ /** Labeled rows are large (~51 KB), so this sidecar gets a bigger cap than the others. */
72
+ export const EVALUATION_LOG_ROTATE_BYTES = 64 * 1024 * 1024;
73
+
74
+ export function appendPromptEvaluation(path: string, record: PromptEvaluationRecord): void {
75
+ appendJsonlRecord(path, record, EVALUATION_LOG_ROTATE_BYTES);
119
76
  }
package/gates.ts CHANGED
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * Rule severity levels, in decision order:
3
3
  * - `deny`: blocks immediately with the rule's required `message`. A hard
4
- * policy boundary — not clearable by `request_override`, not bypassed by
5
- * `.pi/trusted-ops` groups, never shown to the guardian. The mechanical
6
- * analogue of Claude Code's pre-classifier circuit breaker.
7
- * - `convention`: blocks directly with the rule's required `message`, but the
8
- * agent may ask the user for a one-session exception via `request_override`.
4
+ * policy boundary — not bypassed by `.pi/trusted-ops` groups, never shown
5
+ * to the guardian. The mechanical analogue of Claude Code's pre-classifier
6
+ * circuit breaker.
9
7
  * - `guarded`: sends the command to the guardian reviewer.
10
8
  *
11
9
  * When several rules match one command, the effective level is the most
12
- * severe across *all* matches (deny > convention > guarded), never the first
13
- * match in config order.
10
+ * severe across *all* matches (deny > guarded), never the first match in
11
+ * config order.
14
12
  */
15
- export type GateLevel = "deny" | "guarded" | "convention";
13
+ export type GateLevel = "deny" | "guarded";
16
14
 
17
15
  export interface Gate {
18
16
  pattern: RegExp;
@@ -20,7 +18,6 @@ export interface Gate {
20
18
  group: string;
21
19
  label: string;
22
20
  message?: string;
23
- suggest?: (command: string) => string;
24
21
  }
25
22
 
26
23
  /**
@@ -43,7 +40,3 @@ export function findGates(command: string, rules: readonly Gate[]): Gate[] {
43
40
  return gate.pattern.test(command);
44
41
  });
45
42
  }
46
-
47
- export function findGate(command: string, rules: readonly Gate[]): Gate | undefined {
48
- return findGates(command, rules)[0];
49
- }