@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/CHANGELOG.md +24 -0
- package/README.md +24 -81
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +5 -50
- package/config.ts +84 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +68 -170
- package/index.ts +50 -254
- 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 +14 -175
- package/review-queue.ts +2 -12
- package/review-scope.ts +4 -4
- package/review.ts +35 -62
- package/session-overrides.ts +27 -145
- package/settings-command.ts +0 -45
- package/settings-menu.ts +5 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +4 -17
- 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
|
@@ -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
|
|
179
|
-
if (
|
|
180
|
-
throw new Error(`rules[${index}].level must be guarded
|
|
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 (
|
|
184
|
-
throw new Error(`rules[${index}].message is required for
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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:
|
|
303
|
-
userAnswerTools
|
|
304
|
-
userMessageTypes
|
|
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
|
-
|
|
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
|
|
265
|
+
function resolveSidecar(
|
|
335
266
|
raw: Record<string, unknown>,
|
|
267
|
+
key: SidecarKey,
|
|
268
|
+
fileName: string,
|
|
269
|
+
defaultEnabled: boolean,
|
|
336
270
|
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) };
|
|
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
|
-
|
|
359
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
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
|
-
|
|
446
|
-
if (!
|
|
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 =
|
|
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
|
-
};
|
|
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
|
-
|
|
481
|
-
|
|
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:
|
|
498
|
-
usageLog:
|
|
499
|
-
denialLog:
|
|
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 {
|
|
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
|
-
}
|