@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/CHANGELOG.md +39 -0
- package/README.md +52 -74
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +12 -51
- package/config.ts +100 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +190 -209
- package/index.ts +484 -306
- package/jsonl-sidecar.ts +22 -0
- package/override-evidence.ts +3 -17
- package/package.json +3 -8
- package/prompt-select.ts +4 -15
- package/review-display.ts +134 -220
- package/review-queue.ts +88 -21
- package/review-scope.ts +15 -4
- package/review.ts +136 -68
- package/session-overrides.ts +27 -145
- package/settings-command.ts +11 -48
- package/settings-menu.ts +34 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +45 -21
- package/approval-pulse.ts +0 -80
- package/loop-context.ts +0 -41
- package/loop-revise-budget.ts +0 -348
- package/openai-codex-transport.ts +0 -9
- package/standing-overrides.ts +0 -266
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
|
|
179
|
-
if (
|
|
180
|
-
throw new Error(`rules[${index}].level must be guarded
|
|
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 (
|
|
184
|
-
throw new Error(`rules[${index}].message is required for
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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:
|
|
303
|
-
userAnswerTools
|
|
304
|
-
userMessageTypes
|
|
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
|
-
|
|
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
|
|
274
|
+
function resolveSidecar(
|
|
335
275
|
raw: Record<string, unknown>,
|
|
276
|
+
key: SidecarKey,
|
|
277
|
+
fileName: string,
|
|
278
|
+
defaultEnabled: boolean,
|
|
336
279
|
configFilePath: string,
|
|
337
|
-
):
|
|
338
|
-
const defaultPath = resolve(dirname(configFilePath),
|
|
339
|
-
|
|
340
|
-
if (!
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
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
|
-
|
|
359
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
446
|
-
if (!
|
|
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 =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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:
|
|
498
|
-
usageLog:
|
|
499
|
-
denialLog:
|
|
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 {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
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`
|
|
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" | "
|
|
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
|
-
|
|
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 {
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 >
|
|
13
|
-
*
|
|
10
|
+
* severe across *all* matches (deny > guarded), never the first match in
|
|
11
|
+
* config order.
|
|
14
12
|
*/
|
|
15
|
-
export type GateLevel = "deny" | "guarded"
|
|
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
|
-
}
|