@diegopetrucci/pi-code-reviewer 0.1.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/index.ts ADDED
@@ -0,0 +1,1280 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ import type { AgentSession, ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ DefaultResourceLoader,
7
+ SessionManager,
8
+ SettingsManager,
9
+ createAgentSession,
10
+ getAgentDir,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+
14
+ const MAX_TOOL_CALLS_TO_KEEP = 80;
15
+ const MAX_TURNS = 8;
16
+ const MAX_RUN_MS = 8 * 60 * 1000;
17
+ const DEFAULT_BASH_TIMEOUT_SECONDS = 30;
18
+ const DEFAULT_THINKING_LEVEL = "high";
19
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
20
+
21
+ type ThinkingLevel = (typeof THINKING_LEVELS)[number];
22
+ type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
23
+ const CODE_REVIEWER_MODEL_PREFERENCES = [
24
+ "gpt-5.5",
25
+ "claude-opus-4-8",
26
+ "claude-opus-4.8",
27
+ "claude-sonnet-5-0",
28
+ "claude-sonnet-5.0",
29
+ "claude-sonnet-5",
30
+ "claude-sonnet-4-6",
31
+ "claude-sonnet-4.6",
32
+ "claude-sonnet-4-5",
33
+ "claude-sonnet-4.5",
34
+ "claude-sonnet-4-0",
35
+ "claude-sonnet-4",
36
+ ];
37
+ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
38
+ "amazon-bedrock": [
39
+ "claude-fable-5",
40
+ "claude-opus-4-8",
41
+ "claude-opus-4.8",
42
+ "claude-sonnet-5",
43
+ "claude-sonnet-4-6",
44
+ "claude-sonnet-4.6",
45
+ "claude-sonnet-4-5",
46
+ "claude-sonnet-4.5",
47
+ "claude-sonnet-4",
48
+ ],
49
+ anthropic: [
50
+ "claude-fable-5",
51
+ "claude-opus-4-8",
52
+ "claude-opus-4.8",
53
+ "claude-sonnet-5",
54
+ "claude-sonnet-4-6",
55
+ "claude-sonnet-4.6",
56
+ "claude-sonnet-4-5",
57
+ "claude-sonnet-4.5",
58
+ "claude-sonnet-4",
59
+ ],
60
+ openai: ["gpt-5.5", "gpt-5", "gpt-4.1", "o3", "o4-mini", "o4"],
61
+ "vercel-ai-gateway": [
62
+ "gpt-5.5",
63
+ "anthropic/claude-fable-5",
64
+ "anthropic/claude-opus-4.1",
65
+ "anthropic/claude-opus-4",
66
+ "anthropic/claude-sonnet-5",
67
+ "anthropic/claude-sonnet-4",
68
+ ],
69
+ };
70
+
71
+ const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
72
+ const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
73
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
74
+ "blame",
75
+ "cat-file",
76
+ "describe",
77
+ "diff",
78
+ "for-each-ref",
79
+ "log",
80
+ "ls-files",
81
+ "ls-tree",
82
+ "merge-base",
83
+ "name-rev",
84
+ "remote",
85
+ "rev-parse",
86
+ "shortlog",
87
+ "show",
88
+ "show-ref",
89
+ "status",
90
+ "whatchanged",
91
+ ]);
92
+ const SAFE_GIT_BRANCH_FLAGS = new Set([
93
+ "-a",
94
+ "--all",
95
+ "-r",
96
+ "--remotes",
97
+ "-v",
98
+ "-vv",
99
+ "--show-current",
100
+ "--list",
101
+ "--contains",
102
+ "--merged",
103
+ "--no-merged",
104
+ ]);
105
+ const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
106
+ const SAFE_GIT_CONFIG_OVERRIDES = ["core.pager=cat", "core.fsmonitor=false", "diff.external="];
107
+ const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
108
+ blame: ["--no-textconv"],
109
+ diff: ["--no-ext-diff", "--no-textconv"],
110
+ log: ["--no-ext-diff", "--no-textconv"],
111
+ show: ["--no-ext-diff", "--no-textconv"],
112
+ whatchanged: ["--no-ext-diff", "--no-textconv"],
113
+ };
114
+ const GIT_OPTIONS_REQUIRING_LOCAL_FILE = [
115
+ {
116
+ flag: "--contents",
117
+ blockedPrefixes: ["--con"],
118
+ reason: "Code Reviewer bash blocks git --contents because it can read local files outside built-in path guards.",
119
+ },
120
+ {
121
+ flag: "--pathspec-from-file",
122
+ blockedPrefixes: ["--pathspec"],
123
+ reason: "Code Reviewer bash blocks git --pathspec-from-file because it can read local files outside built-in path guards.",
124
+ },
125
+ {
126
+ flag: "--ignore-revs-file",
127
+ blockedPrefixes: ["--ignore-rev"],
128
+ reason: "Code Reviewer bash blocks git --ignore-revs-file because it can read local files outside built-in path guards.",
129
+ },
130
+ ] as const;
131
+ const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>> = {
132
+ blame: [
133
+ {
134
+ reason: "Code Reviewer bash blocks git blame -S because it can read local revs files outside built-in path guards.",
135
+ matches: (token) => token === "-S" || (token.startsWith("-S") && token.length > 2),
136
+ },
137
+ ],
138
+ "ls-files": [
139
+ {
140
+ reason: "Code Reviewer bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
141
+ matches: (token) => token === "-X" || (token.startsWith("-X") && token.length > 2),
142
+ },
143
+ {
144
+ reason: "Code Reviewer bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
145
+ matches: (token) => {
146
+ if (!token.startsWith("--")) return false;
147
+ const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
148
+ return option === "--exclude-from" || option.startsWith("--exclude-f");
149
+ },
150
+ },
151
+ ],
152
+ };
153
+ const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set(["--repo", "-R", "--hostname", "--jq", "-q", "--template"]);
154
+ const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
155
+
156
+ const CodeReviewerParams = Type.Object({
157
+ task: Type.String({
158
+ description: "What change to review, what ticket/story it should satisfy, and any specific review focus.",
159
+ }),
160
+ diff: Type.Optional(
161
+ Type.String({
162
+ description: "Optional diff, patch, or change summary to treat as the primary review target.",
163
+ }),
164
+ ),
165
+ context: Type.Optional(
166
+ Type.String({
167
+ description: "Optional caller context such as constraints, known risks, or expected behavior.",
168
+ }),
169
+ ),
170
+ });
171
+
172
+ type ToolCall = {
173
+ id: string;
174
+ name: string;
175
+ args: unknown;
176
+ startedAt: number;
177
+ endedAt?: number;
178
+ isError?: boolean;
179
+ };
180
+
181
+ type ReviewStatus = "running" | "done" | "error" | "aborted";
182
+
183
+ type ReviewDetails = {
184
+ status: ReviewStatus;
185
+ cwd: string;
186
+ task: string;
187
+ turns: number;
188
+ toolCalls: ToolCall[];
189
+ startedAt: number;
190
+ endedAt?: number;
191
+ error?: string;
192
+ modelRef?: string;
193
+ modelName?: string;
194
+ requestedThinkingLevel?: ThinkingLevel;
195
+ effectiveThinkingLevel?: ThinkingLevel;
196
+ thinkingLevelClamped?: boolean;
197
+ thinkingLevelNote?: string;
198
+ };
199
+
200
+ type PiModel = {
201
+ provider: string;
202
+ id: string;
203
+ name?: string;
204
+ reasoning?: boolean;
205
+ contextWindow?: number;
206
+ maxTokens?: number;
207
+ thinkingLevelMap?: ThinkingLevelMap;
208
+ };
209
+
210
+ type ModelRegistryContext = {
211
+ model?: PiModel;
212
+ modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> };
213
+ };
214
+
215
+ type CreateAgentSessionModel = NonNullable<Parameters<typeof createAgentSession>[0]>["model"];
216
+
217
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
218
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
219
+
220
+ function isModelAvailabilityError(message: string | undefined): boolean {
221
+ if (!message) return false;
222
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
223
+ }
224
+
225
+ function parseVersionScore(text: string): number {
226
+ const matches = text.match(/\d+(?:[._-]\d+)*/g) ?? [];
227
+ let best = 0;
228
+ for (const rawMatch of matches) {
229
+ const match = rawMatch.replace(/[_-]/g, ".");
230
+ const [major = "0", minor = "0", patch = "0"] = match.split(".");
231
+ const score = Number(major) * 1_000_000 + Number(minor) * 1_000 + Number(patch);
232
+ if (score > best) best = score;
233
+ }
234
+ return best;
235
+ }
236
+
237
+ function rankModel(model: PiModel): number {
238
+ const text = `${model.id} ${model.name ?? ""}`.toLowerCase();
239
+ const has = (regex: RegExp): boolean => regex.test(text);
240
+ let score = 0;
241
+
242
+ if (model.reasoning) score += 10_000_000;
243
+ score += parseVersionScore(text) * 1_000;
244
+ score += Math.min(model.maxTokens ?? 0, 200_000);
245
+ score += Math.floor(Math.min(model.contextWindow ?? 0, 1_000_000) / 100);
246
+
247
+ if (has(/\bopus\b/)) score += 350_000;
248
+ if (has(/\bpro\b/)) score += 180_000;
249
+ if (has(/\bmax\b/)) score += 60_000;
250
+ if (has(/\bultra\b/)) score += 150_000;
251
+ if (has(/\bsonnet\b/)) score += 120_000;
252
+ if (has(/\bcodex\b|\bcoder\b|\bcode\b/)) score += 40_000;
253
+ if (has(/\breasoning\b|\bthink(?:ing)?\b/)) score += 60_000;
254
+
255
+ if (has(/\bhaiku\b/)) score -= 420_000;
256
+ if (has(/\bmini\b/)) score -= 520_000;
257
+ if (has(/\bnano\b/)) score -= 700_000;
258
+ if (has(/\bflash\b/)) score -= 520_000;
259
+ if (has(/\bspark\b/)) score -= 650_000;
260
+ if (has(/\blite\b|\bsmall\b|\bfast\b|\binstant\b/)) score -= 300_000;
261
+
262
+ return score;
263
+ }
264
+
265
+ function modelText(model: PiModel): string {
266
+ return `${model.provider} ${model.id} ${model.name ?? ""}`.toLowerCase();
267
+ }
268
+
269
+ function isAnthropicFamily(model: PiModel): boolean {
270
+ return /\b(anthropic|claude|opus|sonnet|haiku|fable)\b/.test(modelText(model));
271
+ }
272
+
273
+ function isOpenAiFamily(model: PiModel): boolean {
274
+ return /\b(openai|codex|chatgpt|gpt[-_. ]?\d|o[1345](?:\b|-|_))\b/.test(modelText(model));
275
+ }
276
+
277
+ function getProviderPreferenceList(provider: string | undefined): string[] | undefined {
278
+ if (!provider) return undefined;
279
+ return PROVIDER_MODEL_PREFERENCES[provider.toLowerCase()];
280
+ }
281
+
282
+ function selectPreferredModel(models: PiModel[], provider: string | undefined): PiModel | undefined {
283
+ const preferences = getProviderPreferenceList(provider);
284
+ if (!preferences || preferences.length === 0) return undefined;
285
+ const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
286
+ for (const pattern of preferences) {
287
+ const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
288
+ if (match) return match.model;
289
+ }
290
+ return undefined;
291
+ }
292
+
293
+ function selectPreferredAcrossProviders(models: PiModel[]): PiModel | undefined {
294
+ const lowered = models.map((model) => ({ model, haystack: `${model.id} ${model.name ?? ""}`.toLowerCase() }));
295
+ for (const pattern of CODE_REVIEWER_MODEL_PREFERENCES) {
296
+ const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
297
+ if (match) return match.model;
298
+ }
299
+ for (const preferences of Object.values(PROVIDER_MODEL_PREFERENCES)) {
300
+ for (const pattern of preferences) {
301
+ const match = lowered.find((entry) => entry.haystack.includes(pattern.toLowerCase()));
302
+ if (match) return match.model;
303
+ }
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ function orderPreferredModels(candidates: PiModel[], providerForPreferences?: string): PiModel[] {
309
+ const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
310
+ const preferred = providerForPreferences
311
+ ? selectPreferredModel(candidates, providerForPreferences)
312
+ : selectPreferredAcrossProviders(candidates);
313
+ return preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
314
+ }
315
+
316
+ function getModelKey(model: PiModel): string {
317
+ return `${model.provider}::${model.id}`;
318
+ }
319
+
320
+ function appendOrderedCandidates(
321
+ ordered: PiModel[],
322
+ seen: Set<string>,
323
+ candidates: PiModel[],
324
+ providerForPreferences?: string,
325
+ ): void {
326
+ for (const model of orderPreferredModels(candidates, providerForPreferences)) {
327
+ const key = getModelKey(model);
328
+ if (seen.has(key)) continue;
329
+ seen.add(key);
330
+ ordered.push(model);
331
+ }
332
+ }
333
+
334
+ async function selectCodeReviewerModel(
335
+ ctx: ModelRegistryContext,
336
+ ): Promise<{ ok: true; selection: PiModel; ordered: PiModel[] } | { ok: false; error: string }> {
337
+ const available = await ctx.modelRegistry.getAvailable();
338
+ if (available.length === 0) {
339
+ return {
340
+ ok: false,
341
+ error: "No authenticated models are available. Log in or configure an API key first.",
342
+ };
343
+ }
344
+
345
+ const currentModel = ctx.model;
346
+ const currentProvider = currentModel?.provider;
347
+ const sameProvider = currentProvider ? available.filter((model) => model.provider === currentProvider) : [];
348
+ const sameProviderReasoning = sameProvider.filter((model) => model.reasoning);
349
+ const allReasoning = available.filter((model) => model.reasoning);
350
+ const oppositeFamily = currentModel
351
+ ? isAnthropicFamily(currentModel)
352
+ ? available.filter(isOpenAiFamily)
353
+ : isOpenAiFamily(currentModel)
354
+ ? available.filter(isAnthropicFamily)
355
+ : []
356
+ : [];
357
+ const oppositeProvider = currentProvider ? available.filter((model) => model.provider !== currentProvider) : [];
358
+ const oppositeProviderFamily = currentProvider
359
+ ? oppositeFamily.filter((model) => model.provider !== currentProvider)
360
+ : oppositeFamily;
361
+ const sameProviderOppositeFamily = currentProvider
362
+ ? oppositeFamily.filter((model) => model.provider === currentProvider)
363
+ : [];
364
+ const oppositeProviderFamilyReasoning = oppositeProviderFamily.filter((model) => model.reasoning);
365
+ const sameProviderOppositeFamilyReasoning = sameProviderOppositeFamily.filter((model) => model.reasoning);
366
+ const oppositeFamilyReasoning = oppositeFamily.filter((model) => model.reasoning);
367
+ const oppositeProviderReasoning = oppositeProvider.filter((model) => model.reasoning);
368
+
369
+ const ordered: PiModel[] = [];
370
+ const seen = new Set<string>();
371
+ appendOrderedCandidates(ordered, seen, oppositeProviderFamilyReasoning);
372
+ appendOrderedCandidates(ordered, seen, oppositeProviderFamily);
373
+ appendOrderedCandidates(ordered, seen, oppositeProviderReasoning);
374
+ appendOrderedCandidates(ordered, seen, oppositeProvider);
375
+ appendOrderedCandidates(ordered, seen, sameProviderOppositeFamilyReasoning);
376
+ appendOrderedCandidates(ordered, seen, sameProviderOppositeFamily);
377
+ appendOrderedCandidates(ordered, seen, oppositeFamilyReasoning);
378
+ appendOrderedCandidates(ordered, seen, sameProviderReasoning, currentProvider);
379
+ appendOrderedCandidates(ordered, seen, sameProvider, currentProvider);
380
+ appendOrderedCandidates(ordered, seen, allReasoning);
381
+ appendOrderedCandidates(ordered, seen, available);
382
+
383
+ return { ok: true, selection: ordered[0], ordered };
384
+ }
385
+
386
+ function normalizeThinkingLevel(value: unknown): ThinkingLevel | undefined {
387
+ return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel) ? (value as ThinkingLevel) : undefined;
388
+ }
389
+
390
+ // Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
391
+ function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
392
+ if (!model.reasoning) return level === "off";
393
+
394
+ const map = model.thinkingLevelMap;
395
+ if (level === "xhigh") {
396
+ return !!map && Object.prototype.hasOwnProperty.call(map, "xhigh") && map.xhigh != null;
397
+ }
398
+ return map?.[level] !== null;
399
+ }
400
+
401
+ function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
402
+ if (isThinkingLevelSupported(model, requested)) return requested;
403
+
404
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
405
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
406
+ const level = THINKING_LEVELS[index];
407
+ if (isThinkingLevelSupported(model, level)) return level;
408
+ }
409
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
410
+ const level = THINKING_LEVELS[index];
411
+ if (isThinkingLevelSupported(model, level)) return level;
412
+ }
413
+
414
+ return "off";
415
+ }
416
+
417
+ function resolveThinkingLevel(
418
+ model: PiModel | undefined,
419
+ requestedThinkingLevel: ThinkingLevel | undefined,
420
+ ): { requested: ThinkingLevel; effective: ThinkingLevel; clamped: boolean; note: string } {
421
+ const requested = requestedThinkingLevel ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
422
+ const effective = model ? clampThinkingLevel(model, requested) : requested;
423
+ const clamped = effective !== requested;
424
+ if (clamped) {
425
+ return {
426
+ requested,
427
+ effective,
428
+ clamped,
429
+ note: `requested ${requested}; clamped to ${effective}`,
430
+ };
431
+ }
432
+ if (requestedThinkingLevel) {
433
+ return { requested, effective, clamped, note: `requested ${requested}` };
434
+ }
435
+ return {
436
+ requested,
437
+ effective,
438
+ clamped,
439
+ note: model?.reasoning ? `defaulted to ${effective}` : `defaulted to off for non-reasoning model`,
440
+ };
441
+ }
442
+
443
+ function applyModelAttemptDetails(details: ReviewDetails, model: PiModel, thinking: ReturnType<typeof resolveThinkingLevel>): void {
444
+ details.modelRef = `${model.provider}/${model.id}`;
445
+ details.modelName = model.name;
446
+ details.requestedThinkingLevel = thinking.requested;
447
+ details.effectiveThinkingLevel = thinking.effective;
448
+ details.thinkingLevelClamped = thinking.clamped || undefined;
449
+ details.thinkingLevelNote = thinking.note;
450
+ }
451
+
452
+ function extractLastAssistantText(messages: unknown[]): string {
453
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
454
+ const message = messages[i] as { role?: string; content?: unknown };
455
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
456
+ const parts: string[] = [];
457
+ for (const part of message.content) {
458
+ if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
459
+ const text = (part as { text?: unknown }).text;
460
+ if (typeof text === "string") parts.push(text);
461
+ }
462
+ }
463
+ if (parts.length > 0) return parts.join("").trim();
464
+ }
465
+ return "";
466
+ }
467
+
468
+ function isAbortLikeError(error: unknown): boolean {
469
+ if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError") return true;
470
+ const message = error instanceof Error ? error.message : String(error);
471
+ return /aborted|cancelled|canceled/i.test(message);
472
+ }
473
+
474
+ function isInside(parent: string, child: string): boolean {
475
+ const parentResolved = path.resolve(parent);
476
+ const childResolved = path.resolve(child);
477
+ return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
478
+ }
479
+
480
+ function resolveToolPath(cwd: string, rawPath: string | undefined): string {
481
+ const input = rawPath?.trim() || ".";
482
+ const normalized = input.startsWith("@") ? input.slice(1) : input;
483
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
484
+ }
485
+
486
+ async function assertToolPathInsideCwd(cwd: string, rawPath: unknown, toolName: string): Promise<string | undefined> {
487
+ if (rawPath !== undefined && typeof rawPath !== "string") return `${toolName} path must be a string.`;
488
+ if (typeof rawPath === "string") {
489
+ const normalizedInput = rawPath.trim().startsWith("@") ? rawPath.trim().slice(1) : rawPath.trim();
490
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalizedInput)) {
491
+ return `${toolName} path must not traverse outside the local checkout.`;
492
+ }
493
+ }
494
+ const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
495
+ const resolved = resolveToolPath(cwd, rawPath);
496
+ const realPath = await fs.realpath(resolved).catch(() => resolved);
497
+ if (!isInside(root, realPath)) return `${toolName} is limited to the local checkout: ${realPath}`;
498
+ return undefined;
499
+ }
500
+
501
+ function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
502
+ if (value === undefined) return undefined;
503
+ if (typeof value !== "string") return `${label} must be a string.`;
504
+ if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
505
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value)) return `${label} must not traverse outside the local checkout.`;
506
+ return undefined;
507
+ }
508
+
509
+ function stripTokenQuotes(token: string): string {
510
+ if ((token.startsWith("'") && token.endsWith("'")) || (token.startsWith('"') && token.endsWith('"'))) {
511
+ return token.slice(1, -1);
512
+ }
513
+ return token;
514
+ }
515
+
516
+ type ShellTokenizeResult = {
517
+ tokens: string[];
518
+ reason?: string;
519
+ };
520
+
521
+ function tokenizeShellCommand(command: string): ShellTokenizeResult {
522
+ const tokens: string[] = [];
523
+ let token = "";
524
+ let tokenStarted = false;
525
+ let inSingleQuote = false;
526
+ let inDoubleQuote = false;
527
+
528
+ const pushToken = () => {
529
+ if (!tokenStarted) return;
530
+ tokens.push(token);
531
+ token = "";
532
+ tokenStarted = false;
533
+ };
534
+
535
+ for (let index = 0; index < command.length; index += 1) {
536
+ const char = command[index];
537
+ const next = command[index + 1] ?? "";
538
+
539
+ if (inSingleQuote) {
540
+ if (char === "'") {
541
+ inSingleQuote = false;
542
+ continue;
543
+ }
544
+ token += char;
545
+ tokenStarted = true;
546
+ continue;
547
+ }
548
+
549
+ if (char === "\\") {
550
+ return {
551
+ tokens,
552
+ reason: "Code Reviewer bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
553
+ };
554
+ }
555
+
556
+ if (inDoubleQuote) {
557
+ if (char === '"') {
558
+ inDoubleQuote = false;
559
+ continue;
560
+ }
561
+ if (char === "`" || char === "$") {
562
+ return {
563
+ tokens,
564
+ reason:
565
+ char === "`" || next === "("
566
+ ? "Code Reviewer bash blocks command substitution."
567
+ : "Code Reviewer bash blocks shell expansion in double quotes.",
568
+ };
569
+ }
570
+ token += char;
571
+ tokenStarted = true;
572
+ continue;
573
+ }
574
+
575
+ if (char === "\n" || char === "\r") {
576
+ return {
577
+ tokens,
578
+ reason:
579
+ "Code Reviewer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
580
+ };
581
+ }
582
+ if (/\s/.test(char)) {
583
+ pushToken();
584
+ continue;
585
+ }
586
+ if (char === "'") {
587
+ inSingleQuote = true;
588
+ tokenStarted = true;
589
+ continue;
590
+ }
591
+ if (char === '"') {
592
+ inDoubleQuote = true;
593
+ tokenStarted = true;
594
+ continue;
595
+ }
596
+ if (char === "`") return { tokens, reason: "Code Reviewer bash blocks command substitution." };
597
+ if (char === "$") {
598
+ return {
599
+ tokens,
600
+ reason:
601
+ next === "'" || next === '"'
602
+ ? "Code Reviewer bash blocks ANSI-C and localized shell quotes."
603
+ : next === "("
604
+ ? "Code Reviewer bash blocks command substitution."
605
+ : "Code Reviewer bash blocks shell expansion.",
606
+ };
607
+ }
608
+ if (/[;&|()]/.test(char)) {
609
+ return {
610
+ tokens,
611
+ reason:
612
+ "Code Reviewer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
613
+ };
614
+ }
615
+ if (char === ">" || char === "<") {
616
+ return { tokens, reason: "Code Reviewer bash blocks shell redirection to keep inspection read-only." };
617
+ }
618
+ if (char === "{" || char === "}") return { tokens, reason: "Code Reviewer bash blocks shell brace expansion." };
619
+ if (char === "*" || char === "?" || char === "[" || char === "]") {
620
+ return { tokens, reason: "Code Reviewer bash blocks shell glob expansion." };
621
+ }
622
+ if (char === "~" && !tokenStarted) return { tokens, reason: "Code Reviewer bash blocks shell home-directory expansion." };
623
+
624
+ token += char;
625
+ tokenStarted = true;
626
+ }
627
+
628
+ if (inSingleQuote || inDoubleQuote) return { tokens, reason: "Code Reviewer bash blocks unterminated or malformed shell quoting." };
629
+ pushToken();
630
+ return { tokens };
631
+ }
632
+
633
+ function tokenizeSegment(segment: string): string[] {
634
+ return tokenizeShellCommand(segment).tokens;
635
+ }
636
+
637
+ function shellQuoteToken(token: string): string {
638
+ return `'${token.replace(/'/g, `'"'"'`)}'`;
639
+ }
640
+
641
+ function getExecutableName(token: string): string {
642
+ return path.basename(token).toLowerCase();
643
+ }
644
+
645
+ function getShellSyntaxReason(command: string): string | undefined {
646
+ return tokenizeShellCommand(command).reason;
647
+ }
648
+
649
+ function getUnsafeGitTokenPathReason(token: string): string | undefined {
650
+ const valueParts = [token];
651
+ const equalsIndex = token.indexOf("=");
652
+ if (equalsIndex >= 0 && equalsIndex < token.length - 1) valueParts.push(token.slice(equalsIndex + 1));
653
+ for (const value of valueParts) {
654
+ const normalized = value.startsWith("@") ? value.slice(1) : value;
655
+ if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
656
+ return "Code Reviewer bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
657
+ }
658
+ if (path.isAbsolute(normalized)) {
659
+ return "Code Reviewer bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
660
+ }
661
+ if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
662
+ return "Code Reviewer bash git arguments must not traverse outside the local checkout.";
663
+ }
664
+ }
665
+ return undefined;
666
+ }
667
+
668
+ function getBlockedPwdReason(tokens: string[]): string | undefined {
669
+ const args = tokens.slice(1);
670
+ if (args.length === 0) return undefined;
671
+ if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
672
+ return "Code Reviewer bash allows pwd only with no arguments or -P/-L.";
673
+ }
674
+
675
+ function getGitSubcommand(tokens: string[]): { subcommand?: string; index: number; reason?: string } {
676
+ let index = 1;
677
+ while (index < tokens.length) {
678
+ const token = tokens[index];
679
+ if (!token.startsWith("-")) break;
680
+ if (token === "--git-dir" || token.startsWith("--git-dir=")) {
681
+ return { index, reason: "Code Reviewer bash blocks git --git-dir because it can inspect or mutate outside the checkout." };
682
+ }
683
+ if (token === "--work-tree" || token.startsWith("--work-tree=")) {
684
+ return { index, reason: "Code Reviewer bash blocks git --work-tree because it can inspect or mutate outside the checkout." };
685
+ }
686
+ if (token === "-C") {
687
+ const gitCwd = tokens[index + 1];
688
+ if (gitCwd !== ".") return { index, reason: "Code Reviewer bash only allows git -C .; run git from the local checkout." };
689
+ index += 2;
690
+ continue;
691
+ }
692
+ if (token.startsWith("-C")) {
693
+ if (token !== "-C.") return { index, reason: "Code Reviewer bash only allows git -C .; run git from the local checkout." };
694
+ index += 1;
695
+ continue;
696
+ }
697
+ if (token === "--paginate" || token === "-p") {
698
+ return { index, reason: "Code Reviewer bash blocks git pagination because pagers can execute local utilities." };
699
+ }
700
+ if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
701
+ index += 1;
702
+ continue;
703
+ }
704
+ return { index, reason: `Code Reviewer bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.` };
705
+ }
706
+ return { subcommand: tokens[index]?.toLowerCase(), index };
707
+ }
708
+
709
+ function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
710
+ const args = tokens.slice(subcommandIndex + 1);
711
+ if (args.length === 0) return true;
712
+ if (args.some((arg) => /^(?:-(?:d|D|m|M|c|C|f)|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))$/.test(arg))) {
713
+ return false;
714
+ }
715
+ return args.some((arg) => SAFE_GIT_BRANCH_FLAGS.has(arg) || arg.startsWith("--contains=") || arg.startsWith("--merged=") || arg.startsWith("--no-merged="));
716
+ }
717
+
718
+ function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
719
+ const args = tokens.slice(subcommandIndex + 1);
720
+ if (args.length === 0) return true;
721
+ if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
722
+ const remoteAction = args.find((arg) => !arg.startsWith("-"));
723
+ if (remoteAction !== "get-url") return false;
724
+ return true;
725
+ }
726
+
727
+ function isBlockedGitLocalFileOption(token: string, flag: string, blockedPrefixes: readonly string[]): boolean {
728
+ if (!token.startsWith("--")) return false;
729
+ const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
730
+ if (option === flag) return true;
731
+ return blockedPrefixes.some((prefix) => option.startsWith(prefix));
732
+ }
733
+
734
+ function getUnsafeGitArgumentReason(tokens: string[], parsed: { subcommand?: string; index: number }): string | undefined {
735
+ for (let index = 1; index < tokens.length; index += 1) {
736
+ const token = tokens[index];
737
+ const lowerToken = token.toLowerCase();
738
+ if (token === "--no-index" || token.startsWith("--no-index=")) {
739
+ return "Code Reviewer bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
740
+ }
741
+ if (token === "--paginate" || token.startsWith("--paginate=")) {
742
+ return "Code Reviewer bash blocks git --paginate because pagers can execute local utilities.";
743
+ }
744
+ if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
745
+ return "Code Reviewer bash blocks git --open-files-in-pager because it can execute local utilities.";
746
+ }
747
+ if (token === "-O" || token.startsWith("-O")) {
748
+ return "Code Reviewer bash blocks git -O because it can execute local utilities for some subcommands.";
749
+ }
750
+ if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
751
+ return "Code Reviewer bash blocks git --ext-diff because it can execute external diff helpers.";
752
+ }
753
+ if (token === "--textconv" || token.startsWith("--textconv=")) {
754
+ return "Code Reviewer bash blocks git --textconv because it can execute external text conversion filters.";
755
+ }
756
+ if (token === "--filters" || token.startsWith("--filters=")) {
757
+ return "Code Reviewer bash blocks git --filters because it can execute clean/smudge filters from local config.";
758
+ }
759
+ if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
760
+ return "Code Reviewer bash blocks git signature verification flags because they can execute configured GPG helpers.";
761
+ }
762
+ if (token === "--help" || token.startsWith("--help=")) {
763
+ return "Code Reviewer bash blocks git help output because it can execute configured help, man, or browser helpers.";
764
+ }
765
+ if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
766
+ return "Code Reviewer bash blocks git signature format atoms because they can execute configured GPG helpers.";
767
+ }
768
+ if (token === "--output" || token.startsWith("--output=")) {
769
+ return "Code Reviewer bash blocks git output-writing flags.";
770
+ }
771
+ for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
772
+ if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
773
+ }
774
+ if (index > parsed.index) {
775
+ for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ?? []) {
776
+ if (option.matches(token)) return option.reason;
777
+ }
778
+ }
779
+ const pathReason = getUnsafeGitTokenPathReason(token);
780
+ if (pathReason) return pathReason;
781
+ }
782
+ return undefined;
783
+ }
784
+
785
+ function buildSafeGitCommand(tokens: string[]): string {
786
+ const parsed = getGitSubcommand(tokens);
787
+ const prefix = ["git", "--no-pager", "--no-optional-locks", ...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value])];
788
+ const subcommand = parsed.subcommand;
789
+ if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
790
+ const args = tokens.slice(parsed.index + 1);
791
+ const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter((flag) => !args.includes(flag));
792
+ return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
793
+ }
794
+
795
+ function getBlockedGitReason(tokens: string[]): string | undefined {
796
+ if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
797
+ const parsed = getGitSubcommand(tokens);
798
+ if (parsed.reason) return parsed.reason;
799
+ if (parsed.subcommand === "help") {
800
+ return "Code Reviewer bash blocks git help because it can execute configured help, man, or browser helpers.";
801
+ }
802
+ const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
803
+ if (argumentReason) return argumentReason;
804
+ if (!parsed.subcommand) return undefined;
805
+ if (parsed.subcommand === "branch") {
806
+ if (!isSafeGitBranchCommand(tokens, parsed.index)) {
807
+ return "Code Reviewer bash allows git branch only for read-only listing/show-current/contains/merged queries.";
808
+ }
809
+ return undefined;
810
+ }
811
+ if (parsed.subcommand === "remote") {
812
+ if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
813
+ return "Code Reviewer bash allows git remote only for read-only list or get-url queries.";
814
+ }
815
+ return undefined;
816
+ }
817
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
818
+ return `Code Reviewer bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
819
+ }
820
+ return undefined;
821
+ }
822
+
823
+ function getGhCommand(tokens: string[]): { command?: string; subcommand?: string } {
824
+ let index = 1;
825
+ while (index < tokens.length) {
826
+ const token = tokens[index];
827
+ if (!token.startsWith("-")) break;
828
+ if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
829
+ index += 2;
830
+ continue;
831
+ }
832
+ index += 1;
833
+ }
834
+ return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
835
+ }
836
+
837
+ function normalizeFlagValue(value: string | undefined): string | undefined {
838
+ return value ? stripTokenQuotes(value).trim() : undefined;
839
+ }
840
+
841
+ function getBlockedGhApiReason(tokens: string[]): string | undefined {
842
+ const parsed = getGhCommand(tokens);
843
+ if (parsed.command !== "api") return undefined;
844
+ if (tokens.find((token) => token.toLowerCase() === "graphql")) {
845
+ return "Code Reviewer bash blocks gh api graphql because it uses POST/body fields.";
846
+ }
847
+ for (let index = 1; index < tokens.length; index += 1) {
848
+ const token = tokens[index];
849
+ const lowerToken = token.toLowerCase();
850
+ if (
851
+ token === "-f" ||
852
+ token === "-F" ||
853
+ token === "--field" ||
854
+ token === "--raw-field" ||
855
+ token === "--input" ||
856
+ lowerToken.startsWith("-f") ||
857
+ lowerToken.startsWith("--field=") ||
858
+ lowerToken.startsWith("--raw-field=") ||
859
+ lowerToken.startsWith("--input=")
860
+ ) {
861
+ return "Code Reviewer bash allows read-only gh api calls only; request fields and input files are blocked.";
862
+ }
863
+ if (token === "--cache" || lowerToken.startsWith("--cache=")) {
864
+ return "Code Reviewer bash blocks gh api --cache because it writes local cache files.";
865
+ }
866
+
867
+ let method: string | undefined;
868
+ if (lowerToken === "-x" || lowerToken === "--method") method = normalizeFlagValue(tokens[index + 1]);
869
+ else if (lowerToken.startsWith("-x") && token.length > 2) method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
870
+ else if (lowerToken.startsWith("--method=")) method = normalizeFlagValue(token.slice("--method=".length));
871
+ if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
872
+ return "Code Reviewer bash allows read-only gh api calls only; mutating methods are blocked.";
873
+ }
874
+ }
875
+ return undefined;
876
+ }
877
+
878
+ function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
879
+ if (!command) return true;
880
+ if (command === "api") return true;
881
+ if (command === "search") return true;
882
+ if (command === "repo") return subcommand === "view" || subcommand === "list";
883
+ if (command === "pr") return subcommand === "view" || subcommand === "list" || subcommand === "diff" || subcommand === "status" || subcommand === "checks";
884
+ if (command === "issue") return subcommand === "view" || subcommand === "list" || subcommand === "status";
885
+ if (command === "release") return subcommand === "view" || subcommand === "list";
886
+ if (command === "run") return subcommand === "view" || subcommand === "list";
887
+ if (command === "workflow") return subcommand === "view" || subcommand === "list";
888
+ if (command === "label") return subcommand === "list";
889
+ if (command === "milestone") return subcommand === "list";
890
+ return false;
891
+ }
892
+
893
+ function getBlockedGhReason(command: string): string | undefined {
894
+ const tokens = tokenizeSegment(command);
895
+ if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
896
+ if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
897
+ return "Code Reviewer bash blocks gh auth commands and token inspection.";
898
+ }
899
+ if (
900
+ tokens.some((token) => {
901
+ const lowerToken = token.toLowerCase();
902
+ return lowerToken === "--web" || lowerToken === "-w" || lowerToken.startsWith("--web=") || lowerToken.startsWith("-w=");
903
+ })
904
+ ) {
905
+ return "Code Reviewer bash blocks gh --web because it can launch external browser helpers.";
906
+ }
907
+ const apiReason = getBlockedGhApiReason(tokens);
908
+ if (apiReason) return apiReason;
909
+ if (/\bgh\s+(?:repo\s+(?:archive|clone|create|delete|edit|fork|rename|sync)|pr\s+(?:checkout|close|comment|create|edit|lock|merge|ready|reopen|review|unlock)|issue\s+(?:close|comment|create|delete|edit|lock|reopen|transfer|unlock)|release\s+(?:create|delete|edit|upload)|workflow\s+run|run\s+(?:cancel|delete|rerun)|gist\s+(?:create|delete|edit))\b/i.test(command)) {
910
+ return "Code Reviewer bash blocks mutating gh commands.";
911
+ }
912
+ const parsed = getGhCommand(tokens);
913
+ if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
914
+ return `Code Reviewer bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
915
+ }
916
+ return undefined;
917
+ }
918
+
919
+ function getBlockedBashReason(command: string): string | undefined {
920
+ const trimmed = command.trim();
921
+ if (!trimmed) return "Code Reviewer bash requires a non-empty command.";
922
+ if (/`|\$\(/.test(trimmed)) return "Code Reviewer bash blocks command substitution.";
923
+ const syntaxReason = getShellSyntaxReason(trimmed);
924
+ if (syntaxReason) return syntaxReason;
925
+
926
+ const tokens = tokenizeSegment(trimmed);
927
+ if (tokens.length === 0) return "Code Reviewer bash requires a non-empty command.";
928
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) return "Code Reviewer bash blocks inline environment assignment.";
929
+ if (/[\\/]/.test(tokens[0])) {
930
+ return "Code Reviewer bash requires direct git, gh, or pwd invocation without path-qualified executables.";
931
+ }
932
+
933
+ const executable = getExecutableName(tokens[0]);
934
+ if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
935
+ return `Code Reviewer bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
936
+ }
937
+ if (executable === "pwd") return getBlockedPwdReason(tokens);
938
+ if (executable === "git") return getBlockedGitReason(tokens);
939
+ if (executable === "gh") return getBlockedGhReason(trimmed);
940
+ return undefined;
941
+ }
942
+
943
+ function createCodeReviewerRuntimeGuardExtension(options: { cwd: string; maxTurns: number }): ExtensionFactory {
944
+ return (pi) => {
945
+ let currentTurn = 0;
946
+
947
+ pi.on("turn_start", async (event) => {
948
+ currentTurn = event.turnIndex;
949
+ });
950
+
951
+ pi.on("tool_call", async (event) => {
952
+ if (!READ_ONLY_TOOL_NAMES.has(event.toolName)) {
953
+ return { block: true, reason: `code_reviewer exposes read-only tools only; ${event.toolName} is not allowed.` };
954
+ }
955
+
956
+ if (currentTurn >= options.maxTurns - 1) {
957
+ return {
958
+ block: true,
959
+ reason: `Tool use is disabled on final code_reviewer turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
960
+ };
961
+ }
962
+
963
+ if (event.toolName === "read") {
964
+ const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, "read");
965
+ if (reason) return { block: true, reason };
966
+ }
967
+
968
+ if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
969
+ const reason = await assertToolPathInsideCwd(options.cwd, (event.input as { path?: unknown }).path, event.toolName);
970
+ if (reason) return { block: true, reason };
971
+ if (event.toolName === "grep") {
972
+ const globReason = getUnsafePathPatternReason((event.input as { glob?: unknown }).glob, "grep glob");
973
+ if (globReason) return { block: true, reason: globReason };
974
+ }
975
+ if (event.toolName === "find") {
976
+ const patternReason = getUnsafePathPatternReason((event.input as { pattern?: unknown }).pattern, "find pattern");
977
+ if (patternReason) return { block: true, reason: patternReason };
978
+ }
979
+ }
980
+
981
+ if (event.toolName === "bash") {
982
+ const input = event.input as { command?: unknown; timeout?: unknown };
983
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
984
+ const command = typeof input.command === "string" ? input.command : "";
985
+ const reason = getBlockedBashReason(command);
986
+ if (reason) return { block: true, reason };
987
+ const tokens = tokenizeSegment(command.trim());
988
+ if (getExecutableName(tokens[0] ?? "") === "git") input.command = buildSafeGitCommand(tokens);
989
+ }
990
+
991
+ return undefined;
992
+ });
993
+
994
+ pi.on("tool_result", async (event) => ({
995
+ content: [
996
+ ...(event.content ?? []),
997
+ {
998
+ type: "text",
999
+ text: `\n\n[code_reviewer turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1000
+ },
1001
+ ],
1002
+ }));
1003
+ };
1004
+ }
1005
+
1006
+ function buildSystemPrompt(options: { cwd: string; maxTurns: number; maxRunSeconds: number }): string {
1007
+ return `You are Code Reviewer, an isolated read-only review agent running inside The Last Harness. Review the proposed change against the stated task and the local checkout, then return a concise evidence-based review report.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, redirection, mutating git/gh commands, npm/publish commands, path traversal outside the checkout, and other filesystem mutation.\n\nReview priorities, in order:\n1. Ticket fit: does the change actually satisfy the requested task and stay in scope?\n2. Diff accuracy: do the files and edits match what the task claims changed?\n3. Correctness: could the change fail, regress behavior, mishandle errors, or break edge cases?\n4. Security and safety: look for unsafe command use, path handling issues, secret exposure, injection risks, destructive behavior, and policy violations.\n5. Simplicity and maintainability: call out unnecessary complexity, unclear behavior, or avoidable duplication.\n6. Tests and validation: check whether meaningful verification exists and whether gaps matter for this task.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, install, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Treat the supplied task and diff/context as claims to verify, not as facts. Cite file paths, line ranges, git diff/status output, or supplied diff excerpts as evidence.\n- Prefer the built-in file tools over bash when local file inspection is enough.\n- If evidence is missing, stale, or contradictory, say so plainly.\n- Keep the final review concise and actionable. Report the most important findings first; do not pad with non-findings.\n\nOutput format, exact order:\n## Verdict\nOne short paragraph with the overall review outcome.\n\n## Findings\nUse bullets ordered by severity. For each finding include: severity ('blocker', 'major', or 'minor'), a short title, evidence, and why it matters. If there are no findings, write '- none'.\n\n## Validation\nList the read-only checks you performed, or '- none'.\n\n## Scope check\nState whether the change appears to match the requested task and note any missing or extra scope.\n\n## Run details\nList concise run details such as key files inspected, notable git/gh commands, and any uncertainty that limited the review.`;
1008
+ }
1009
+
1010
+ function buildUserPrompt(input: { task: string; diff?: string; context?: string }, cwd: string): string {
1011
+ return `Task to review:\n${input.task}\n\nLocal checkout: ${cwd}\n\nOptional diff or patch:\n${input.diff?.trim() ? input.diff : "(not provided)"}\n\nOptional caller context:\n${input.context?.trim() ? input.context : "(not provided)"}\n\nInspect only as much as needed to review the change against the task. Do not implement anything. Respond using the required review format.`;
1012
+ }
1013
+
1014
+ function formatToolCall(call: ToolCall): string {
1015
+ const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1016
+ if (call.name === "read") {
1017
+ const readPath = typeof args.path === "string" ? args.path : "";
1018
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
1019
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
1020
+ const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1021
+ return `read ${readPath}${range}`.trim();
1022
+ }
1023
+ if (call.name === "grep") {
1024
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
1025
+ const grepPath = typeof args.path === "string" ? args.path : ".";
1026
+ return `grep ${pattern.slice(0, 50)} ${grepPath}`.trim();
1027
+ }
1028
+ if (call.name === "find") {
1029
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
1030
+ const findPath = typeof args.path === "string" ? args.path : ".";
1031
+ return `find ${pattern.slice(0, 50)} ${findPath}`.trim();
1032
+ }
1033
+ if (call.name === "ls") return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
1034
+ if (call.name === "bash") {
1035
+ const command = typeof args.command === "string" ? args.command : "";
1036
+ return `bash ${command.slice(0, 120)}`.trim();
1037
+ }
1038
+ return call.name;
1039
+ }
1040
+
1041
+ function formatDuration(ms: number): string {
1042
+ if (ms < 1000) return `${ms}ms`;
1043
+ const seconds = Math.round(ms / 100) / 10;
1044
+ if (seconds < 60) return `${seconds}s`;
1045
+ const minutes = Math.floor(seconds / 60);
1046
+ const remainingSeconds = Math.round((seconds % 60) * 10) / 10;
1047
+ return `${minutes}m ${remainingSeconds}s`;
1048
+ }
1049
+
1050
+ function appendRunDetails(report: string, details: ReviewDetails): string {
1051
+ const trimmed = report.trim();
1052
+ const toolSummary = details.toolCalls.length > 0 ? details.toolCalls.map(formatToolCall).join("; ") : "none";
1053
+ const duration = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
1054
+ const modelSummary = details.modelRef ? `model ${details.modelRef}` : "model unknown";
1055
+ const thinkingSummary = details.effectiveThinkingLevel
1056
+ ? `thinking ${details.effectiveThinkingLevel}${details.thinkingLevelNote ? ` (${details.thinkingLevelNote})` : ""}`
1057
+ : "thinking unknown";
1058
+ const suffix = `\n\n---\nRun details: ${modelSummary}; ${thinkingSummary}; ${details.turns} turn(s); ${details.toolCalls.length} tool call(s); duration ${duration}; cwd ${details.cwd}; tools ${toolSummary}.`;
1059
+ if (!trimmed) return `## Verdict\nNo review output was produced.\n\n## Findings\n- major — Missing review output. Evidence: the isolated code_reviewer session returned no assistant text. Why it matters: the review could not be completed.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- ${suffix.trim()}`;
1060
+ return `${trimmed}${suffix}`;
1061
+ }
1062
+
1063
+ export const __test__ = {
1064
+ assertToolPathInsideCwd,
1065
+ buildSystemPrompt,
1066
+ buildUserPrompt,
1067
+ createCodeReviewerRuntimeGuardExtension,
1068
+ buildSafeGitCommand,
1069
+ formatToolCall,
1070
+ getBlockedBashReason,
1071
+ normalizeThinkingLevel,
1072
+ resolveThinkingLevel,
1073
+ selectCodeReviewerModel,
1074
+ };
1075
+
1076
+ export default function codeReviewerExtension(pi: ExtensionAPI) {
1077
+ pi.registerTool({
1078
+ name: "code_reviewer",
1079
+ label: "Code reviewer",
1080
+ description:
1081
+ "Read-only isolated reviewer that checks a proposed change for ticket fit, diff accuracy, correctness, security, simplicity, and validation gaps without implementing anything.",
1082
+ promptSnippet:
1083
+ "Run an isolated read-only code review against the local checkout and optional diff; returns concise findings with evidence and run details.",
1084
+ promptGuidelines: [
1085
+ "Use code_reviewer when you want an independent read-only review of a proposed change or task implementation.",
1086
+ "Provide the requested task and any relevant diff/context. Do not use code_reviewer to implement or edit files.",
1087
+ ],
1088
+ parameters: CodeReviewerParams,
1089
+
1090
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1091
+ const task = typeof params.task === "string" ? params.task.trim() : "";
1092
+ if (!task) throw new Error("Invalid parameters: expected task to be a non-empty string.");
1093
+
1094
+ const input = {
1095
+ task,
1096
+ diff: typeof params.diff === "string" ? params.diff : undefined,
1097
+ context: typeof params.context === "string" ? params.context : undefined,
1098
+ };
1099
+ const cwd = path.resolve(ctx.cwd);
1100
+ const details: ReviewDetails = {
1101
+ status: "running",
1102
+ cwd,
1103
+ task,
1104
+ turns: 0,
1105
+ toolCalls: [],
1106
+ startedAt: Date.now(),
1107
+ };
1108
+
1109
+ let lastContent = "(reviewing change...)";
1110
+ let session: AgentSession | undefined;
1111
+ let unsubscribe: (() => void) | undefined;
1112
+ let runTimeout: NodeJS.Timeout | undefined;
1113
+ let abortListenerAdded = false;
1114
+ let aborted = Boolean(signal?.aborted);
1115
+
1116
+ const emit = () => {
1117
+ onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1118
+ };
1119
+
1120
+ const abort = () => {
1121
+ aborted = true;
1122
+ details.status = "aborted";
1123
+ details.endedAt = Date.now();
1124
+ lastContent = "Aborted";
1125
+ emit();
1126
+ void session?.abort();
1127
+ };
1128
+
1129
+ if (signal?.aborted) abort();
1130
+ if (signal && !signal.aborted) {
1131
+ signal.addEventListener("abort", abort);
1132
+ abortListenerAdded = true;
1133
+ }
1134
+
1135
+ try {
1136
+ emit();
1137
+
1138
+ const isolatedSettingsManager = SettingsManager.inMemory({});
1139
+ const resourceLoader = new DefaultResourceLoader({
1140
+ cwd,
1141
+ agentDir: getAgentDir(),
1142
+ settingsManager: isolatedSettingsManager,
1143
+ noExtensions: true,
1144
+ noSkills: true,
1145
+ noPromptTemplates: true,
1146
+ noThemes: true,
1147
+ noContextFiles: true,
1148
+ extensionFactories: [createCodeReviewerRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS })],
1149
+ systemPromptOverride: () => buildSystemPrompt({ cwd, maxTurns: MAX_TURNS, maxRunSeconds: Math.round(MAX_RUN_MS / 1000) }),
1150
+ appendSystemPromptOverride: () => [],
1151
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
1152
+ promptsOverride: () => ({ prompts: [], diagnostics: [] }),
1153
+ themesOverride: () => ({ themes: [], diagnostics: [] }),
1154
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
1155
+ });
1156
+
1157
+ await resourceLoader.reload();
1158
+
1159
+ const modelSelection = await selectCodeReviewerModel(ctx);
1160
+ if (!modelSelection.ok) throw new Error(modelSelection.error);
1161
+
1162
+ let answer = "";
1163
+ let lastAttemptError: string | undefined;
1164
+ const activeThinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
1165
+ for (let index = 0; index < modelSelection.ordered.length; index += 1) {
1166
+ const candidate = modelSelection.ordered[index];
1167
+ const thinking = resolveThinkingLevel(candidate, activeThinkingLevel);
1168
+ applyModelAttemptDetails(details, candidate, thinking);
1169
+ let attemptSession: AgentSession | undefined;
1170
+ let attemptUnsubscribe: (() => void) | undefined;
1171
+ let succeeded = false;
1172
+ lastContent = "(reviewing change...)";
1173
+ try {
1174
+ const created = await createAgentSession({
1175
+ cwd,
1176
+ modelRegistry: ctx.modelRegistry,
1177
+ resourceLoader,
1178
+ settingsManager: isolatedSettingsManager,
1179
+ sessionManager: SessionManager.inMemory(cwd),
1180
+ model: candidate as CreateAgentSessionModel,
1181
+ thinkingLevel: thinking.effective,
1182
+ tools: ["read", "grep", "find", "ls", "bash"],
1183
+ });
1184
+ attemptSession = created.session;
1185
+ session = attemptSession;
1186
+ attemptUnsubscribe = attemptSession.subscribe((event) => {
1187
+ switch (event.type) {
1188
+ case "turn_end":
1189
+ details.turns += 1;
1190
+ emit();
1191
+ break;
1192
+ case "tool_execution_start":
1193
+ details.toolCalls.push({
1194
+ id: event.toolCallId,
1195
+ name: event.toolName,
1196
+ args: event.args,
1197
+ startedAt: Date.now(),
1198
+ });
1199
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1200
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1201
+ }
1202
+ emit();
1203
+ break;
1204
+ case "tool_execution_end": {
1205
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1206
+ if (call) {
1207
+ call.endedAt = Date.now();
1208
+ call.isError = event.isError;
1209
+ }
1210
+ emit();
1211
+ break;
1212
+ }
1213
+ case "message_update":
1214
+ if (event.assistantMessageEvent?.type === "text_delta") {
1215
+ lastContent += event.assistantMessageEvent.delta ?? "";
1216
+ emit();
1217
+ }
1218
+ break;
1219
+ }
1220
+ });
1221
+ unsubscribe = attemptUnsubscribe;
1222
+
1223
+ if (!aborted) {
1224
+ const promptPromise = attemptSession.prompt(buildUserPrompt(input, cwd), { expandPromptTemplates: false });
1225
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1226
+ runTimeout = setTimeout(() => {
1227
+ abort();
1228
+ reject(new Error(`code_reviewer timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1229
+ }, MAX_RUN_MS);
1230
+ });
1231
+ await Promise.race([promptPromise, timeoutPromise]);
1232
+ }
1233
+
1234
+ answer = extractLastAssistantText(attemptSession.state.messages);
1235
+ succeeded = true;
1236
+ break;
1237
+ } catch (error) {
1238
+ const message = error instanceof Error ? error.message : String(error);
1239
+ lastAttemptError = message;
1240
+ const canFallBack = index < modelSelection.ordered.length - 1 && isModelAvailabilityError(message);
1241
+ if (!canFallBack) throw error;
1242
+ } finally {
1243
+ if (runTimeout) {
1244
+ clearTimeout(runTimeout);
1245
+ runTimeout = undefined;
1246
+ }
1247
+ if (attemptSession && !succeeded) {
1248
+ attemptUnsubscribe?.();
1249
+ if (unsubscribe === attemptUnsubscribe) unsubscribe = undefined;
1250
+ attemptSession.dispose();
1251
+ if (session === attemptSession) session = undefined;
1252
+ }
1253
+ }
1254
+ }
1255
+ if (!answer && !session && lastAttemptError) throw new Error(lastAttemptError);
1256
+ lastContent = answer || (aborted ? "Aborted" : "(no output)");
1257
+ details.status = aborted ? "aborted" : "done";
1258
+ details.endedAt = Date.now();
1259
+ const report = appendRunDetails(lastContent, details);
1260
+ lastContent = report;
1261
+ emit();
1262
+ return { content: [{ type: "text", text: report }], details };
1263
+ } catch (error) {
1264
+ const wasAbort = aborted || isAbortLikeError(error);
1265
+ const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
1266
+ details.status = wasAbort ? "aborted" : "error";
1267
+ details.error = wasAbort ? undefined : message;
1268
+ details.endedAt = Date.now();
1269
+ lastContent = appendRunDetails(`## Verdict\nReview failed.\n\n## Findings\n- major — Review execution failed. Evidence: ${message}. Why it matters: the requested read-only review did not complete.\n\n## Validation\n- none\n\n## Scope check\nReview could not be completed.\n\n## Run details\n- failure: ${message}`, details);
1270
+ emit();
1271
+ return { content: [{ type: "text", text: lastContent }], details };
1272
+ } finally {
1273
+ if (runTimeout) clearTimeout(runTimeout);
1274
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abort);
1275
+ unsubscribe?.();
1276
+ session?.dispose();
1277
+ }
1278
+ },
1279
+ });
1280
+ }