@erichll/pi-auto-review 0.16.0 → 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.
@@ -0,0 +1,366 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ BOUNDED_SURFACES,
7
+ EXTENSION_NAME,
8
+ USER_CONFIG_RELATIVE_PATH,
9
+ } from "./consts.ts";
10
+ import type {
11
+ BoundedSurface,
12
+ Config,
13
+ } from "./types.ts";
14
+ import type { PolicyAuditConfig } from "../policy-audit/index.ts";
15
+
16
+ export const DEFAULT_CONFIG: Config = {
17
+ model: "codex-auto-review",
18
+ reasoning: "low",
19
+ timeoutMs: 90_000,
20
+ maxTokens: 256,
21
+ retries: 2,
22
+ maxUserTranscriptTokens: 1_200,
23
+ maxToolTranscriptTokens: 1_200,
24
+ maxRelevantResultTokens: 800,
25
+ maxReviewerInputTokens: 8_192,
26
+ breakGlassEnabled: true,
27
+ failureMode: "deny",
28
+ grantTtlMs: 60_000,
29
+ autoConfirmBoundedAllows: Object.freeze(["external_directory", "path"]),
30
+ policyAudit: Object.freeze({ enabled: true, retentionDays: 180 }),
31
+ };
32
+
33
+
34
+ export function validateConfig(value: unknown, source: string): Config {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
36
+ throw new Error(`${EXTENSION_NAME}: ${source} must be an object`);
37
+ }
38
+ const raw = value as Partial<Config> & Record<string, unknown>;
39
+ const allowedKeys = new Set([
40
+ "model",
41
+ "reasoning",
42
+ "timeoutMs",
43
+ "maxTokens",
44
+ "retries",
45
+ "maxUserTranscriptTokens",
46
+ "maxToolTranscriptTokens",
47
+ "maxRelevantResultTokens",
48
+ "maxReviewerInputTokens",
49
+ "breakGlassEnabled",
50
+ "failureMode",
51
+ "grantTtlMs",
52
+ "autoConfirmBoundedAllows",
53
+ "policyAudit",
54
+ ]);
55
+ const unknownKeys = Object.keys(raw).filter((key) => !allowedKeys.has(key));
56
+ if (unknownKeys.length > 0) {
57
+ throw new Error(
58
+ `${EXTENSION_NAME}: unknown config keys in ${source}: ${unknownKeys.join(", ")}`,
59
+ );
60
+ }
61
+ const config = { ...DEFAULT_CONFIG, ...raw };
62
+ config.policyAudit = {
63
+ ...DEFAULT_CONFIG.policyAudit,
64
+ ...(raw.policyAudit as Partial<PolicyAuditConfig> | undefined),
65
+ };
66
+ if (
67
+ typeof config.model !== "string" ||
68
+ !config.model.trim() ||
69
+ /\s/.test(config.model) ||
70
+ config.model.split("/").some((segment) => !segment.trim())
71
+ ) {
72
+ throw new Error(
73
+ `${EXTENSION_NAME}: model must be a model id or provider/model`,
74
+ );
75
+ }
76
+ if (
77
+ !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(
78
+ config.reasoning,
79
+ )
80
+ ) {
81
+ throw new Error(`${EXTENSION_NAME}: invalid reasoning level`);
82
+ }
83
+ if (
84
+ !Number.isInteger(config.timeoutMs) ||
85
+ config.timeoutMs < 1_000 ||
86
+ config.timeoutMs > 120_000
87
+ ) {
88
+ throw new Error(`${EXTENSION_NAME}: timeoutMs must be 1000..120000`);
89
+ }
90
+ if (
91
+ !Number.isInteger(config.maxTokens) ||
92
+ config.maxTokens < 256 ||
93
+ config.maxTokens > 4_096
94
+ ) {
95
+ throw new Error(`${EXTENSION_NAME}: maxTokens must be 256..4096`);
96
+ }
97
+ if (
98
+ !Number.isInteger(config.retries) ||
99
+ config.retries < 0 ||
100
+ config.retries > 2
101
+ ) {
102
+ throw new Error(`${EXTENSION_NAME}: retries must be 0..2`);
103
+ }
104
+ if (!["deny", "defer"].includes(config.failureMode)) {
105
+ throw new Error(`${EXTENSION_NAME}: failureMode must be deny or defer`);
106
+ }
107
+ if (typeof config.breakGlassEnabled !== "boolean") {
108
+ throw new Error(`${EXTENSION_NAME}: breakGlassEnabled must be boolean`);
109
+ }
110
+ if (
111
+ !Number.isInteger(config.grantTtlMs) ||
112
+ config.grantTtlMs < 1_000 ||
113
+ config.grantTtlMs > 300_000
114
+ ) {
115
+ throw new Error(`${EXTENSION_NAME}: grantTtlMs must be 1000..300000`);
116
+ }
117
+ if (
118
+ !Array.isArray(config.autoConfirmBoundedAllows) ||
119
+ config.autoConfirmBoundedAllows.some(
120
+ (surface) => !BOUNDED_SURFACES.has(surface),
121
+ ) ||
122
+ new Set(config.autoConfirmBoundedAllows).size !==
123
+ config.autoConfirmBoundedAllows.length
124
+ ) {
125
+ throw new Error(
126
+ `${EXTENSION_NAME}: autoConfirmBoundedAllows must contain unique external_directory/path entries`,
127
+ );
128
+ }
129
+ for (const [name, entry] of [
130
+ ["maxUserTranscriptTokens", config.maxUserTranscriptTokens],
131
+ ["maxToolTranscriptTokens", config.maxToolTranscriptTokens],
132
+ ["maxRelevantResultTokens", config.maxRelevantResultTokens],
133
+ ] as const) {
134
+ if (!Number.isInteger(entry) || entry < 32 || entry > 8_000) {
135
+ throw new Error(`${EXTENSION_NAME}: ${name} must be 32..8000`);
136
+ }
137
+ }
138
+ if (
139
+ !Number.isInteger(config.maxReviewerInputTokens) ||
140
+ config.maxReviewerInputTokens < 2_048 ||
141
+ config.maxReviewerInputTokens > 32_768
142
+ ) {
143
+ throw new Error(
144
+ `${EXTENSION_NAME}: maxReviewerInputTokens must be 2048..32768`,
145
+ );
146
+ }
147
+ if (raw.policyAudit !== undefined &&
148
+ (!raw.policyAudit || typeof raw.policyAudit !== "object" || Array.isArray(raw.policyAudit))) {
149
+ throw new Error(`${EXTENSION_NAME}: policyAudit must be an object`);
150
+ }
151
+ const policyAuditKeys = Object.keys((raw.policyAudit ?? {}) as Record<string, unknown>);
152
+ if (policyAuditKeys.some((key) => key !== "enabled" && key !== "retentionDays")) {
153
+ throw new Error(`${EXTENSION_NAME}: policyAudit only accepts enabled and retentionDays`);
154
+ }
155
+ if (typeof config.policyAudit.enabled !== "boolean" ||
156
+ !Number.isInteger(config.policyAudit.retentionDays) ||
157
+ config.policyAudit.retentionDays < 1 || config.policyAudit.retentionDays > 3_650) {
158
+ throw new Error(`${EXTENSION_NAME}: policyAudit requires enabled boolean and retentionDays 1..3650`);
159
+ }
160
+ return {
161
+ ...config,
162
+ autoConfirmBoundedAllows: Object.freeze([
163
+ ...config.autoConfirmBoundedAllows,
164
+ ]),
165
+ policyAudit: Object.freeze({ ...config.policyAudit }),
166
+ };
167
+ }
168
+
169
+ export function packageConfigPath(): string {
170
+ // src/review/config.ts → src/config.json (one level up).
171
+ return join(dirname(dirname(fileURLToPath(import.meta.url))), "config.json");
172
+ }
173
+
174
+ export function userConfigPath(home = homedir()): string {
175
+ return join(home, USER_CONFIG_RELATIVE_PATH);
176
+ }
177
+
178
+ function readJsonConfig(path: string): unknown {
179
+ try {
180
+ return JSON.parse(readFileSync(path, "utf8"));
181
+ } catch (error) {
182
+ throw new Error(
183
+ `${EXTENSION_NAME}: cannot load ${path}: ${
184
+ error instanceof Error ? error.message : String(error)
185
+ }`,
186
+ );
187
+ }
188
+ }
189
+
190
+ /** Package-shipped defaults from `src/config.json`. */
191
+ export function loadConfig(): Config {
192
+ const path = packageConfigPath();
193
+ return validateConfig(readJsonConfig(path), path);
194
+ }
195
+
196
+ /**
197
+ * User-global trusted overlay at
198
+ * `~/.pi/agent/extensions/pi-auto-review/config.json`.
199
+ * May set any legal config key, including model and autoConfirmBoundedAllows.
200
+ */
201
+ export function applyUserConfig(
202
+ packageConfig: Config,
203
+ value: unknown,
204
+ source = "user config",
205
+ ): Config {
206
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
207
+ throw new Error(`${EXTENSION_NAME}: ${source} must be an object`);
208
+ }
209
+ const overlay = value as Record<string, unknown>;
210
+ if (overlay.policyAudit !== undefined &&
211
+ (!overlay.policyAudit || typeof overlay.policyAudit !== "object" || Array.isArray(overlay.policyAudit))) {
212
+ throw new Error(`${EXTENSION_NAME}: ${source} policyAudit must be an object`);
213
+ }
214
+ return validateConfig(
215
+ {
216
+ ...packageConfig,
217
+ autoConfirmBoundedAllows: [...packageConfig.autoConfirmBoundedAllows],
218
+ ...overlay,
219
+ policyAudit: {
220
+ ...packageConfig.policyAudit,
221
+ ...(overlay.policyAudit as object | undefined),
222
+ },
223
+ },
224
+ source,
225
+ );
226
+ }
227
+
228
+ export type LoadTrustedConfigOptions = {
229
+ packageConfig?: Config;
230
+ userConfigPath?: string;
231
+ };
232
+
233
+ /**
234
+ * Trusted config = package defaults, optionally fully overlaid by the user
235
+ * global file. Project config is applied later and may only tighten.
236
+ */
237
+ export function loadTrustedConfig(
238
+ options: LoadTrustedConfigOptions = {},
239
+ ): Config {
240
+ const packageConfig = options.packageConfig ?? loadConfig();
241
+ const path = options.userConfigPath ?? userConfigPath();
242
+ let value: unknown;
243
+ try {
244
+ value = JSON.parse(readFileSync(path, "utf8"));
245
+ } catch (error) {
246
+ if (
247
+ error &&
248
+ typeof error === "object" &&
249
+ "code" in error &&
250
+ error.code === "ENOENT"
251
+ ) {
252
+ return packageConfig;
253
+ }
254
+ throw new Error(
255
+ `${EXTENSION_NAME}: cannot load ${path}: ${
256
+ error instanceof Error ? error.message : String(error)
257
+ }`,
258
+ );
259
+ }
260
+ return applyUserConfig(packageConfig, value, path);
261
+ }
262
+
263
+ const TIGHTENABLE_NUMBER_KEYS = [
264
+ "timeoutMs",
265
+ "maxTokens",
266
+ "retries",
267
+ "maxUserTranscriptTokens",
268
+ "maxToolTranscriptTokens",
269
+ "maxRelevantResultTokens",
270
+ "maxReviewerInputTokens",
271
+ "grantTtlMs",
272
+ ] as const;
273
+
274
+ export function applyProjectConfig(
275
+ trusted: Config,
276
+ value: unknown,
277
+ ): Readonly<Config> {
278
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
279
+ throw new Error(`${EXTENSION_NAME}: project config must be an object`);
280
+ }
281
+ const raw = value as Record<string, unknown>;
282
+ const allowed = new Set<string>([
283
+ ...TIGHTENABLE_NUMBER_KEYS,
284
+ "breakGlassEnabled",
285
+ "failureMode",
286
+ "autoConfirmBoundedAllows",
287
+ "policyAudit",
288
+ ]);
289
+ const forbidden = Object.keys(raw).filter((key) => !allowed.has(key));
290
+ if (forbidden.length > 0) {
291
+ throw new Error(
292
+ `${EXTENSION_NAME}: project config cannot set: ${forbidden.join(", ")}`,
293
+ );
294
+ }
295
+ const merged: Config = { ...trusted };
296
+ for (const key of TIGHTENABLE_NUMBER_KEYS) {
297
+ if (raw[key] === undefined) continue;
298
+ if (
299
+ typeof raw[key] !== "number" ||
300
+ !Number.isFinite(raw[key]) ||
301
+ raw[key] > trusted[key]
302
+ ) {
303
+ throw new Error(
304
+ `${EXTENSION_NAME}: project ${key} may only lower the trusted value`,
305
+ );
306
+ }
307
+ (merged[key] as number) = raw[key];
308
+ }
309
+ if (raw.failureMode !== undefined) {
310
+ if (raw.failureMode !== "deny") {
311
+ throw new Error(
312
+ `${EXTENSION_NAME}: project failureMode may only be deny`,
313
+ );
314
+ }
315
+ merged.failureMode = "deny";
316
+ }
317
+ if (raw.breakGlassEnabled !== undefined) {
318
+ if (raw.breakGlassEnabled !== false) {
319
+ throw new Error(
320
+ `${EXTENSION_NAME}: project breakGlassEnabled may only be false`,
321
+ );
322
+ }
323
+ merged.breakGlassEnabled = false;
324
+ }
325
+ if (raw.autoConfirmBoundedAllows !== undefined) {
326
+ if (
327
+ !Array.isArray(raw.autoConfirmBoundedAllows) ||
328
+ raw.autoConfirmBoundedAllows.some(
329
+ (surface) =>
330
+ typeof surface !== "string" ||
331
+ !trusted.autoConfirmBoundedAllows.includes(
332
+ surface as BoundedSurface,
333
+ ),
334
+ )
335
+ ) {
336
+ throw new Error(
337
+ `${EXTENSION_NAME}: project autoConfirmBoundedAllows may only remove trusted surfaces`,
338
+ );
339
+ }
340
+ merged.autoConfirmBoundedAllows = [
341
+ ...new Set(raw.autoConfirmBoundedAllows as BoundedSurface[]),
342
+ ];
343
+ }
344
+ if (raw.policyAudit !== undefined) {
345
+ if (!raw.policyAudit || typeof raw.policyAudit !== "object" || Array.isArray(raw.policyAudit)) {
346
+ throw new Error(`${EXTENSION_NAME}: project policyAudit must be an object`);
347
+ }
348
+ const audit = raw.policyAudit as Record<string, unknown>;
349
+ const forbiddenAuditKeys = Object.keys(audit).filter((key) => key !== "enabled" && key !== "retentionDays");
350
+ if (forbiddenAuditKeys.length > 0) {
351
+ throw new Error(`${EXTENSION_NAME}: project policyAudit cannot set: ${forbiddenAuditKeys.join(", ")}`);
352
+ }
353
+ if (audit.enabled !== undefined && audit.enabled !== false) {
354
+ throw new Error(`${EXTENSION_NAME}: project policyAudit may only disable collection`);
355
+ }
356
+ if (audit.retentionDays !== undefined &&
357
+ (!Number.isInteger(audit.retentionDays) || Number(audit.retentionDays) < 1 || Number(audit.retentionDays) > trusted.policyAudit.retentionDays)) {
358
+ throw new Error(`${EXTENSION_NAME}: project policyAudit.retentionDays may only shorten retention`);
359
+ }
360
+ merged.policyAudit = {
361
+ enabled: audit.enabled === false ? false : trusted.policyAudit.enabled,
362
+ retentionDays: audit.retentionDays === undefined ? trusted.policyAudit.retentionDays : Number(audit.retentionDays),
363
+ };
364
+ }
365
+ return Object.freeze(validateConfig(merged, "effective project config"));
366
+ }
@@ -0,0 +1,22 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ export const EXTENSION_NAME = "pi-auto-review";
5
+ // This module sits at <package root>/src/review/ — three levels up.
6
+ export const PACKAGE_ROOT = dirname(
7
+ dirname(dirname(fileURLToPath(import.meta.url))),
8
+ );
9
+ export const PROJECT_CONFIG_PATH = join(".pi", "pi-auto-review.json");
10
+ export const USER_CONFIG_RELATIVE_PATH = join(
11
+ ".pi",
12
+ "agent",
13
+ "extensions",
14
+ "pi-auto-review",
15
+ "config.json",
16
+ );
17
+ export const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
18
+ export const REVIEWER_FRAMING_RESERVE_TOKENS = 64;
19
+ export const REVIEWER_RETRY_DELAY_MS = 250;
20
+ export const REVIEWER_MAX_RETRY_AFTER_MS = 5_000;
21
+ export const FORMAT_RETRY_INSTRUCTION =
22
+ "Format correction only: return exactly one JSON object matching the required schema; do not change the authorization scope or evidence interpretation.";
@@ -0,0 +1,66 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { EXTENSION_NAME, PACKAGE_ROOT, PROJECT_CONFIG_PATH } from "./consts.ts";
5
+ import { userConfigPath } from "./config.ts";
6
+ import { pathSurfaceInfo } from "../path-surfaces.ts";
7
+ import type { BoundaryRequest } from "../broker/index.ts";
8
+
9
+ export function isWithin(parent: string, child: string): boolean {
10
+ const rel = relative(parent, child);
11
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
12
+ }
13
+
14
+ export function protectedWriteHardDeny(
15
+ request: BoundaryRequest,
16
+ ): { rule: string; reason: string } | undefined {
17
+ const isWrite =
18
+ request.surface === "filesystem-write" ||
19
+ pathSurfaceInfo(request.surface)?.effect === "write" ||
20
+ /\b(?:write|create|delete|rename|chmod|chown)\b/i.test(
21
+ request.operation,
22
+ );
23
+ if (!isWrite) return;
24
+ const target = request.resolvedPath || request.path;
25
+ if (!target) return;
26
+ const resolvedTarget = resolve(request.cwd, target);
27
+ const agentDir = join(homedir(), ".pi", "agent");
28
+ const protectedDirectories = [
29
+ PACKAGE_ROOT,
30
+ join(agentDir, "logs"),
31
+ join(agentDir, "extensions", "pi-auto-review"),
32
+ ];
33
+ const protectedFiles = [
34
+ join(request.cwd, ".pi", "settings.json"),
35
+ join(request.cwd, ".pi", "sandbox.json"),
36
+ join(request.cwd, PROJECT_CONFIG_PATH),
37
+ join(agentDir, "settings.json"),
38
+ join(agentDir, "permissions.json"),
39
+ join(agentDir, "sandbox.json"),
40
+ userConfigPath(),
41
+ ];
42
+ if (
43
+ protectedDirectories.some((path) => isWithin(path, resolvedTarget)) ||
44
+ protectedFiles.includes(resolvedTarget)
45
+ ) {
46
+ return {
47
+ rule: "security-control-tampering",
48
+ reason:
49
+ "writing security extension code, policy, configuration, or audit data is forbidden",
50
+ };
51
+ }
52
+ }
53
+
54
+ export function assertTrustedInstallation(
55
+ cwd: string,
56
+ packageRoot = PACKAGE_ROOT,
57
+ ): void {
58
+ const realCwd = realpathSync(cwd);
59
+ const realPackageRoot = realpathSync(packageRoot);
60
+ if (isWithin(realCwd, realPackageRoot)) {
61
+ throw new Error(
62
+ `${EXTENSION_NAME}: refusing security policy loaded from agent-writable workspace ${realPackageRoot}`,
63
+ );
64
+ }
65
+ }
66
+
@@ -0,0 +1,61 @@
1
+ // Internal barrel for the review pipeline. The package's public API surface
2
+ // stays re-exported from src/index.ts (package.json exports "."), mirroring
3
+ // the broker/ and policy-audit/ directory convention.
4
+ export {
5
+ applyUserConfig,
6
+ applyProjectConfig,
7
+ loadConfig,
8
+ loadTrustedConfig,
9
+ packageConfigPath,
10
+ userConfigPath,
11
+ validateConfig,
12
+ DEFAULT_CONFIG,
13
+ type LoadTrustedConfigOptions,
14
+ } from "./config.ts";
15
+ export { EXTENSION_NAME, PACKAGE_ROOT } from "./consts.ts";
16
+ export { writeOptionalAuditFile } from "./audit.ts";
17
+ export {
18
+ LOCAL_HARD_DENY_AGENT_INSTRUCTION,
19
+ REVIEWER_CRITICAL_DENY_AGENT_INSTRUCTION,
20
+ REVIEWER_NONCRITICAL_DENY_AGENT_INSTRUCTION,
21
+ REVIEWER_SYSTEM_PROMPT,
22
+ } from "./prompts.ts";
23
+ export {
24
+ assertTrustedInstallation,
25
+ isWithin,
26
+ protectedWriteHardDeny,
27
+ } from "./guards.ts";
28
+ export {
29
+ applyReviewerInputBudget,
30
+ boundaryRequest,
31
+ boundedRequest,
32
+ estimateReviewerTokens,
33
+ reviewPreflight,
34
+ sessionConfig,
35
+ sharedReviewContext,
36
+ textFromAssistant,
37
+ type PermissionsService,
38
+ } from "./input.ts";
39
+ export {
40
+ completeTelemetry,
41
+ noModelSummary,
42
+ resolveReviewerMeta,
43
+ userReviewMetaFromResult,
44
+ } from "./provider.ts";
45
+ export {
46
+ complete,
47
+ currentTurnScope,
48
+ denialLabel,
49
+ modelDecisionToBoundaryReview,
50
+ } from "./complete.ts";
51
+ export type {
52
+ BoundedSurface,
53
+ Config,
54
+ ReasoningLevel,
55
+ ReviewErrorClass,
56
+ ReviewExecutionSummary,
57
+ ReviewPreflight,
58
+ ReviewResult,
59
+ ReviewerTelemetryEvent,
60
+ } from "./types.ts";
61
+ export { ReviewExecutionError } from "./types.ts";