@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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
@@ -16,6 +16,8 @@ export const DEFAULT_REVIEWER_REASONING_EFFORT: ReasoningEffort = "low";
16
16
  export const DEFAULT_REVIEWER_TIMEOUT_MS = 30_000;
17
17
  export const MIN_REVIEWER_TIMEOUT_MS = 1_000;
18
18
  export const MAX_REVIEWER_TIMEOUT_MS = 300_000;
19
+ export const DEFAULT_REVIEW_CONCURRENCY = 4;
20
+ export const MAX_REVIEW_CONCURRENCY = 16;
19
21
 
20
22
  /**
21
23
  * Where the active reviewer system prompt came from. The settings UI shows this
@@ -34,14 +36,6 @@ export interface AutoPermissionsConfig {
34
36
  model: string;
35
37
  reasoningEffort: ReasoningEffort;
36
38
  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
39
  };
46
40
  systemPrompt: string;
47
41
  systemPromptSource: SystemPromptSource;
@@ -58,25 +52,12 @@ export interface AutoPermissionsConfig {
58
52
  * collector never saw it. The model read the text as the user's; the
59
53
  * reviewer read nothing at all.
60
54
  *
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
55
  * An allowlist rather than "project every custom message", for the same
69
56
  * reason `userAnswerTools` is one: any installed extension can append a
70
57
  * custom message, and a blanket rule would let any of them mint user
71
58
  * authorization. Naming the types keeps that an explicit choice.
72
59
  */
73
60
  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
61
  };
81
62
  evaluationLog: {
82
63
  enabled: boolean;
@@ -91,11 +72,6 @@ export interface AutoPermissionsConfig {
91
72
  enabled: boolean;
92
73
  path: string;
93
74
  };
94
- /** User-granted comparable-command approvals, shared across projects. Default on. */
95
- standingApprovals: {
96
- enabled: boolean;
97
- path: string;
98
- };
99
75
  rules: Gate[];
100
76
  /**
101
77
  * Review every bash command that matches no rule under the generic
@@ -104,6 +80,13 @@ export interface AutoPermissionsConfig {
104
80
  * `classifyAllShell` — full coverage for one guardian call per command.
105
81
  */
106
82
  reviewAllShell: boolean;
83
+ /**
84
+ * How many guardian reviews may run at once when several guarded commands
85
+ * are pending together (parallel tool calls, codemode scripts). Verdicts are
86
+ * still applied, and the user still asked, one command at a time. 1 restores
87
+ * fully serial reviews.
88
+ */
89
+ reviewConcurrency: number;
107
90
  /**
108
91
  * Prose trust configuration, appended to the reviewer policy prompt as a
109
92
  * labeled section (outside `systemPrompt`, so customized prompt files still
@@ -125,10 +108,11 @@ export interface AutoPermissionsConfig {
125
108
  ui: {
126
109
  enabled: boolean;
127
110
  resultDisplayMs: number;
128
- placement: "widget" | "toolRow";
129
111
  };
130
112
  }
131
113
 
114
+ export type ReviewerConfig = NonNullable<AutoPermissionsConfig["reviewer"]>;
115
+
132
116
  interface RuleInput {
133
117
  pattern?: unknown;
134
118
  flags?: unknown;
@@ -164,6 +148,33 @@ function optionalString(value: unknown, name: string): string | undefined {
164
148
  return value.trim();
165
149
  }
166
150
 
151
+ function objectBlock(value: unknown, name: string): Record<string, unknown> | undefined {
152
+ if (value === undefined) return undefined;
153
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
154
+ return value as Record<string, unknown>;
155
+ }
156
+
157
+ function stringList(value: unknown, name: string): string[] {
158
+ if (value === undefined) return [];
159
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) {
160
+ throw new Error(`${name} must be an array of non-empty strings`);
161
+ }
162
+ return [...new Set((value as string[]).map((entry) => entry.trim()))];
163
+ }
164
+
165
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number, name: string): number {
166
+ const v = value === undefined ? fallback : value;
167
+ if (!Number.isInteger(v) || Number(v) < min || Number(v) > max) {
168
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
169
+ }
170
+ return Number(v);
171
+ }
172
+
173
+ function optionalBoolean(value: unknown, name: string): boolean | undefined {
174
+ if (value !== undefined && typeof value !== "boolean") throw new Error(`${name} must be boolean`);
175
+ return value as boolean | undefined;
176
+ }
177
+
167
178
  function compileRule(value: unknown, index: number): Gate {
168
179
  if (!value || typeof value !== "object" || Array.isArray(value)) {
169
180
  throw new Error(`rules[${index}] must be an object`);
@@ -175,13 +186,15 @@ function compileRule(value: unknown, index: number): Gate {
175
186
  if (!pattern || !group || !label) throw new Error(`rules[${index}] requires pattern, group, and label`);
176
187
  const flags = input.flags === undefined ? "i" : input.flags;
177
188
  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`);
189
+ const rawLevel = input.level === undefined ? "guarded" : input.level;
190
+ if (rawLevel !== "guarded" && rawLevel !== "deny" && rawLevel !== "convention") {
191
+ throw new Error(`rules[${index}].level must be guarded or deny`);
181
192
  }
193
+ // The retired convention level blocked without review; deny keeps that.
194
+ const level: GateLevel = rawLevel === "convention" ? "deny" : rawLevel;
182
195
  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`);
196
+ if (level === "deny" && !message) {
197
+ throw new Error(`rules[${index}].message is required for deny rules`);
185
198
  }
186
199
 
187
200
  return {
@@ -223,6 +236,11 @@ export function expandRules(rawRules: readonly unknown[], defaults: readonly Gat
223
236
  return rules;
224
237
  }
225
238
 
239
+ function resolveConfigRelativePath(value: string, configFilePath: string): string {
240
+ const expanded = value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
241
+ return isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded);
242
+ }
243
+
226
244
  function resolvePrompt(
227
245
  raw: Record<string, unknown>,
228
246
  path: string,
@@ -233,146 +251,54 @@ function resolvePrompt(
233
251
  if (inline) return { prompt: inline, source: { kind: "inline" } };
234
252
  if (!file) return { prompt: AUTO_PERMISSIONS_SYSTEM_PROMPT, source: { kind: "builtin" } };
235
253
 
236
- const expanded = file.startsWith("~/") ? join(homedir(), file.slice(2)) : file;
237
- const resolved = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded);
254
+ const resolved = resolveConfigRelativePath(file, path);
238
255
  const prompt = readFileSync(resolved, "utf8").trim();
239
256
  if (!prompt) throw new Error("systemPromptFile is empty");
240
257
  return { prompt, source: { kind: "file", path: resolved } };
241
258
  }
242
259
 
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
260
  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
- }
261
+ const evidence = objectBlock(raw.reviewEvidence, "reviewEvidence") ?? {};
262
+ const projectInstructions = optionalBoolean(evidence.projectInstructions, "reviewEvidence.projectInstructions");
263
+ const userAnswerTools = stringList(evidence.userAnswerTools, "reviewEvidence.userAnswerTools");
264
+ const userMessageTypes = stringList(evidence.userMessageTypes, "reviewEvidence.userMessageTypes");
301
265
  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"),
266
+ projectInstructions: projectInstructions === true,
267
+ userAnswerTools,
268
+ userMessageTypes,
309
269
  };
310
270
  }
311
271
 
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
- }
272
+ type SidecarKey = "evaluationLog" | "usageLog" | "denialLog";
333
273
 
334
- function resolveUsageLog(
274
+ function resolveSidecar(
335
275
  raw: Record<string, unknown>,
276
+ key: SidecarKey,
277
+ fileName: string,
278
+ defaultEnabled: boolean,
336
279
  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) };
280
+ ): { enabled: boolean; path: string } {
281
+ const defaultPath = resolve(dirname(configFilePath), fileName);
282
+ const block = objectBlock(raw[key], key);
283
+ if (!block) return { enabled: defaultEnabled, path: defaultPath };
284
+ const enabled = optionalBoolean(block.enabled, `${key}.enabled`) ?? defaultEnabled;
285
+ const configured = optionalString(block.path, `${key}.path`);
286
+ return { enabled, path: configured ? resolveConfigRelativePath(configured, configFilePath) : defaultPath };
352
287
  }
353
288
 
354
289
  const GUARDIAN_POLICY_KEYS = ["environment", "allow", "softDeny", "hardDeny"] as const;
355
290
 
356
291
  function resolveGuardianPolicy(raw: Record<string, unknown>): AutoPermissionsConfig["guardianPolicy"] {
357
292
  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>;
293
+ const policy = objectBlock(raw.guardianPolicy, "guardianPolicy");
294
+ if (!policy) return empty;
363
295
  for (const key of Object.keys(policy)) {
364
296
  if (!(GUARDIAN_POLICY_KEYS as readonly string[]).includes(key)) {
365
297
  throw new Error(`guardianPolicy.${key} is not a recognized list (use environment, allow, softDeny, hardDeny)`);
366
298
  }
367
299
  }
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
- };
300
+ const resolveList = (key: typeof GUARDIAN_POLICY_KEYS[number]): string[] =>
301
+ stringList(policy[key], `guardianPolicy.${key}`);
376
302
  return {
377
303
  environment: resolveList("environment"),
378
304
  allow: resolveList("allow"),
@@ -381,72 +307,16 @@ function resolveGuardianPolicy(raw: Record<string, unknown>): AutoPermissionsCon
381
307
  };
382
308
  }
383
309
 
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
310
  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 };
311
+ const ui = objectBlock(raw.ui, "ui") ?? {};
312
+ const enabled = optionalBoolean(ui.enabled, "ui.enabled");
313
+ const resultDisplayMs = boundedInteger(ui.resultDisplayMs, 2500, 0, 30_000, "ui.resultDisplayMs");
314
+ return { enabled: enabled !== false, resultDisplayMs };
442
315
  }
443
316
 
444
317
  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>;
318
+ const reviewer = objectBlock(raw.reviewer, "reviewer");
319
+ if (!reviewer) return undefined;
450
320
  const provider = optionalString(reviewer.provider, "reviewer.provider");
451
321
  const model = optionalString(reviewer.model, "reviewer.model");
452
322
  if (!provider || !model) throw new Error("reviewer requires both provider and model");
@@ -455,32 +325,20 @@ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["r
455
325
  if (!REASONING_EFFORTS.includes(reasoningEffort)) {
456
326
  throw new Error("reviewer.reasoningEffort is invalid");
457
327
  }
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
- };
328
+ const timeoutMs = boundedInteger(
329
+ reviewer.timeoutMs,
330
+ DEFAULT_REVIEWER_TIMEOUT_MS,
331
+ MIN_REVIEWER_TIMEOUT_MS,
332
+ MAX_REVIEWER_TIMEOUT_MS,
333
+ "reviewer.timeoutMs",
334
+ );
335
+ return { provider, model, reasoningEffort, timeoutMs };
476
336
  }
477
337
 
478
338
  export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): AutoPermissionsConfig {
479
339
  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
- }
340
+ optionalBoolean(raw.enabled, "enabled");
341
+ optionalBoolean(raw.reviewAllShell, "reviewAllShell");
484
342
  if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
485
343
  // Absent means the built-in ruleset is active; an authored array replaces it
486
344
  // entirely unless it splices "$defaults" back in; an explicit [] gates
@@ -494,12 +352,18 @@ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): A
494
352
  systemPrompt: prompt.prompt,
495
353
  systemPromptSource: prompt.source,
496
354
  reviewEvidence: resolveReviewEvidence(raw),
497
- evaluationLog: resolveEvaluationLog(raw, path),
498
- usageLog: resolveUsageLog(raw, path),
499
- denialLog: resolveDenialLog(raw, path),
500
- standingApprovals: resolveStandingApprovals(raw, path),
355
+ evaluationLog: resolveSidecar(raw, "evaluationLog", "review-evals.jsonl", false, path),
356
+ usageLog: resolveSidecar(raw, "usageLog", "usage.jsonl", true, path),
357
+ denialLog: resolveSidecar(raw, "denialLog", "denials.jsonl", true, path),
501
358
  rules,
502
359
  reviewAllShell: raw.reviewAllShell === true,
360
+ reviewConcurrency: boundedInteger(
361
+ raw.reviewConcurrency,
362
+ DEFAULT_REVIEW_CONCURRENCY,
363
+ 1,
364
+ MAX_REVIEW_CONCURRENCY,
365
+ "reviewConcurrency",
366
+ ),
503
367
  guardianPolicy: resolveGuardianPolicy(raw),
504
368
  ui: resolveUi(raw),
505
369
  };
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
- }