@expo/code-review-cli 0.5.2 → 0.7.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/README.md +58 -22
- package/build/commands/ci.js +156 -39
- package/build/commands/doctor.js +167 -33
- package/build/commands/review.js +5 -3
- package/build/commands/setup-auth.js +83 -11
- package/build/config/load.js +15 -0
- package/build/config/schema.js +7 -3
- package/build/core/auth.js +122 -9
- package/build/core/claude-code.js +680 -0
- package/build/core/exec.js +278 -9
- package/build/core/opencode.js +95 -15
- package/build/core/prompts.js +19 -2
- package/build/core/render.js +21 -2
- package/build/core/review.js +212 -37
- package/build/core/schema.js +6 -1
- package/build/core/scrub.js +120 -0
- package/build/core/throttle.js +10 -0
- package/build/core/util.js +17 -0
- package/build/core/verify.js +13 -1
- package/build/reporters/github.js +79 -13
- package/build/sources/github-pr.js +110 -22
- package/build/sources/local-git.js +3 -2
- package/package.json +3 -3
- package/templates/command.yml +7 -1
- package/templates/config.jsonc +21 -3
- package/templates/dismiss.yml +3 -0
- package/templates/shared.md +28 -0
- package/templates/workflow.yml +17 -5
package/build/core/review.js
CHANGED
|
@@ -3,7 +3,8 @@ import { prepareAuth } from "./auth.js";
|
|
|
3
3
|
import { coordinate } from "./coordinator.js";
|
|
4
4
|
import { writeRunLog } from "./log.js";
|
|
5
5
|
import { filterNoise, writePatchWorkspace } from "./noise.js";
|
|
6
|
-
import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
|
|
6
|
+
import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
|
|
7
|
+
import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
|
|
7
8
|
import { routeAgents } from "./router.js";
|
|
8
9
|
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
|
|
9
10
|
import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
|
|
@@ -33,25 +34,42 @@ function makeRunId() {
|
|
|
33
34
|
* thin wrappers that supply a Source and render the result.
|
|
34
35
|
*/
|
|
35
36
|
/**
|
|
36
|
-
* Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when the
|
|
38
|
+
* run leans on a subscription credential, else 6. One subscription account handles
|
|
39
|
+
* six parallel streams poorly — requests get parked server-side (the stall signature
|
|
40
|
+
* seen on eas-cli#4084), and several PRs may be reviewing on the same credential at
|
|
41
|
+
* once — so subscription runs trade a little wall-clock for a lot of reliability.
|
|
42
|
+
*
|
|
43
|
+
* A subscription run is any of: an oauth (ChatGPT/Codex) entry; OR the Claude Code
|
|
44
|
+
* engine being in use (an `anthropic/…` model) on an OAUTH credential — a subscription
|
|
45
|
+
* token OR the local `claude` login fallback (no forwardable token). An anthropic
|
|
46
|
+
* credential that classifies as an API KEY is metered per-request and does NOT force
|
|
47
|
+
* the cap. Exported for tests.
|
|
42
48
|
*/
|
|
43
|
-
export function effectiveConcurrency(config) {
|
|
49
|
+
export function effectiveConcurrency(config, env = process.env) {
|
|
44
50
|
if (config.chunk.concurrency) {
|
|
45
51
|
return config.chunk.concurrency;
|
|
46
52
|
}
|
|
47
|
-
|
|
53
|
+
if (config.auth.some((entry) => entry.mode === "oauth")) {
|
|
54
|
+
return 3;
|
|
55
|
+
}
|
|
56
|
+
if (buildEngineMap(config).usesClaude) {
|
|
57
|
+
const entry = config.auth.find((auth) => auth.provider === "anthropic");
|
|
58
|
+
const credential = claudeTokenCredential(entry, env);
|
|
59
|
+
// No forwardable token ⇒ the `claude` login (a subscription) covers the run; an
|
|
60
|
+
// "sk-ant-oat…" token is a subscription too. Either caps; an API key does not.
|
|
61
|
+
if (!credential || credential.kind === "oauth") {
|
|
62
|
+
return 3;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return 6;
|
|
48
66
|
}
|
|
49
67
|
export async function runReview(source, options) {
|
|
50
68
|
const { config } = options;
|
|
51
69
|
const started = Date.now();
|
|
52
70
|
const runId = makeRunId();
|
|
53
71
|
const progress = options.onProgress ?? (() => { });
|
|
54
|
-
const runsRoot = path.join(config.configDir, ".runs");
|
|
72
|
+
const runsRoot = options.runsDir ?? path.join(config.configDir, ".runs");
|
|
55
73
|
const runDir = path.join(runsRoot, runId);
|
|
56
74
|
const logPath = path.join(runsRoot, "reviews.jsonl");
|
|
57
75
|
// Fail fast on an invalid explicit selection before doing any work. Routing
|
|
@@ -97,18 +115,39 @@ export async function runReview(source, options) {
|
|
|
97
115
|
});
|
|
98
116
|
return output;
|
|
99
117
|
}
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
118
|
+
// Materialize the PR-head tree (not the current checkout) when the source can, so
|
|
119
|
+
// the agents' surrounding-source reads and the verifier's re-reads see the versions
|
|
120
|
+
// that match the diff. Config is already fully loaded in memory, so the chdir below
|
|
121
|
+
// doesn't affect it; run-log/patch paths are absolute; gh/git calls already ran
|
|
122
|
+
// above. Failure policy is MODE-DEPENDENT (see resolveReadRoot): CI fails closed —
|
|
123
|
+
// with a base-SHA checkout the fallback tree is pre-PR content, and silently
|
|
124
|
+
// reviewing/verifying that drops real findings — while a local run falls back to
|
|
125
|
+
// the user's own checkout with a warning.
|
|
126
|
+
// Resolve each agent's engine BEFORE prepareAuth/readRoot, so the per-engine
|
|
127
|
+
// startup below can't leak the readRoot worktree or a temp auth dir holding a
|
|
128
|
+
// live credential (buildEngineMap only inspects config, so nothing needs cleanup
|
|
129
|
+
// yet at this point). Nothing throws here anymore — one run may drive BOTH the
|
|
130
|
+
// Claude Code CLI engine and OpenCode at once, inferred per agent from its model.
|
|
131
|
+
//
|
|
132
|
+
// Scope the engine set to the SELECTED agents so a run whose passes never touch
|
|
133
|
+
// Claude doesn't start (and fail on a missing CLI/token for) the Claude Code
|
|
134
|
+
// engine. An explicit `--agents` subset is known here; routing picks from the full
|
|
135
|
+
// roster later (its router needs an engine up first), so a routed/all run keeps
|
|
136
|
+
// the full roster and can drive either engine.
|
|
137
|
+
const { engineOf, modelOf, usesOpencode, usesClaude } = buildEngineMap(config, explicitAgents ?? config.agents);
|
|
110
138
|
const originalCwd = process.cwd();
|
|
111
|
-
const readRoot =
|
|
139
|
+
const readRoot = await resolveReadRoot(source, options.mode, progress);
|
|
140
|
+
// Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
|
|
141
|
+
// and doing it after readRoot but before chdir means a prepareAuth failure can't
|
|
142
|
+
// leave cwd pointing at the worktree — it only has the worktree itself to release.
|
|
143
|
+
let auth;
|
|
144
|
+
try {
|
|
145
|
+
auth = await prepareAuth(config);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
await readRoot?.cleanup();
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
112
151
|
const restoreCwd = async () => {
|
|
113
152
|
if (readRoot) {
|
|
114
153
|
process.chdir(originalCwd);
|
|
@@ -119,10 +158,21 @@ export async function runReview(source, options) {
|
|
|
119
158
|
progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
|
|
120
159
|
process.chdir(readRoot.dir);
|
|
121
160
|
}
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
const starting = [
|
|
162
|
+
usesClaude ? "Claude Code engine" : null,
|
|
163
|
+
usesOpencode ? "OpenCode server" : null,
|
|
164
|
+
]
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.join(" + ");
|
|
167
|
+
progress(`Starting ${starting}…`);
|
|
168
|
+
// Start each engine the run actually uses. OpenCode first so a claude failure can
|
|
169
|
+
// close it. Two separate try blocks keep the precise per-engine error messages.
|
|
170
|
+
let opencodeHandle = null;
|
|
171
|
+
let claudeHandle = null;
|
|
124
172
|
try {
|
|
125
|
-
|
|
173
|
+
if (usesOpencode) {
|
|
174
|
+
opencodeHandle = await startOpencode(buildOpencodeConfig(config));
|
|
175
|
+
}
|
|
126
176
|
}
|
|
127
177
|
catch (error) {
|
|
128
178
|
await auth.cleanup();
|
|
@@ -130,11 +180,71 @@ export async function runReview(source, options) {
|
|
|
130
180
|
throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
|
|
131
181
|
`model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
|
|
132
182
|
}
|
|
133
|
-
// Preflight: a model id the server can't resolve would otherwise fail EVERY pass
|
|
134
|
-
// identically — N indistinguishable coverage gaps, after spending the run's budget
|
|
135
|
-
// discovering the same fixable thing N times. Throw once, up front, naming the fix.
|
|
136
183
|
try {
|
|
137
|
-
|
|
184
|
+
if (usesClaude) {
|
|
185
|
+
claudeHandle = await startClaudeCode(config);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
opencodeHandle?.close();
|
|
190
|
+
await auth.cleanup();
|
|
191
|
+
await restoreCwd();
|
|
192
|
+
throw new Error(`Failed to start the Claude Code engine. Ensure the \`claude\` CLI is installed and ` +
|
|
193
|
+
`logged into a Max/Team subscription (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
|
|
194
|
+
}
|
|
195
|
+
// Build the single carrier handle so every downstream `handle` call is unchanged;
|
|
196
|
+
// per-agent dispatch happens inside via engineOf. When OpenCode is in use the
|
|
197
|
+
// carrier is the opencode handle (claude reached via `.claude`); a claude-only run
|
|
198
|
+
// uses the claude handle itself as the carrier (engineOf maps every id → claude).
|
|
199
|
+
const engineFn = (agent) => engineOf[agent] ?? "opencode";
|
|
200
|
+
let handle;
|
|
201
|
+
if (opencodeHandle) {
|
|
202
|
+
handle = opencodeHandle;
|
|
203
|
+
handle.claude = claudeHandle ?? undefined;
|
|
204
|
+
handle.engineOf = engineFn;
|
|
205
|
+
const closeOpencode = opencodeHandle.close;
|
|
206
|
+
handle.close = () => {
|
|
207
|
+
closeOpencode();
|
|
208
|
+
claudeHandle?.close(); // claude close is a noop, but keep the composition explicit
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
handle = claudeHandle; // claude-only: the carrier is the claude handle itself
|
|
213
|
+
handle.engineOf = engineFn;
|
|
214
|
+
}
|
|
215
|
+
// The claude-code engine has no temperature control (the CLI exposes no flag), so
|
|
216
|
+
// surface a tuned-but-ignored temperature once, here, instead of letting the config
|
|
217
|
+
// divergence pass silently. Applies whenever any pass is claude-routed.
|
|
218
|
+
if (usesClaude) {
|
|
219
|
+
const note = claudeTemperatureNote(config, engineOf);
|
|
220
|
+
if (note) {
|
|
221
|
+
progress(`Note: ${note}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Preflight: a model id an engine can't resolve would otherwise fail EVERY pass
|
|
225
|
+
// routed to it identically — N indistinguishable coverage gaps, after spending the
|
|
226
|
+
// run's budget rediscovering the same fixable thing. Throw once, up front, naming
|
|
227
|
+
// the fix. Split per engine so each engine only ever sees its own model subset: the
|
|
228
|
+
// OpenCode server never receives an anthropic-claude id (unknown-provider error) and
|
|
229
|
+
// assertClaudeModels never receives a non-anthropic id (its foreign-id throw).
|
|
230
|
+
const modelsFor = (eng) => [
|
|
231
|
+
...new Set(Object.keys(engineOf)
|
|
232
|
+
.filter((id) => engineOf[id] === eng)
|
|
233
|
+
.map((id) => modelOf[id])),
|
|
234
|
+
];
|
|
235
|
+
try {
|
|
236
|
+
if (opencodeHandle) {
|
|
237
|
+
const models = modelsFor("opencode");
|
|
238
|
+
if (models.length > 0) {
|
|
239
|
+
await assertModelsResolvable(opencodeHandle, models, config.auth);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (claudeHandle) {
|
|
243
|
+
const models = modelsFor("claude-code");
|
|
244
|
+
if (models.length > 0) {
|
|
245
|
+
await assertModelsResolvable(claudeHandle, models, config.auth);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
138
248
|
}
|
|
139
249
|
catch (error) {
|
|
140
250
|
handle.close();
|
|
@@ -163,6 +273,10 @@ export async function runReview(source, options) {
|
|
|
163
273
|
// log, reported in the log line, and surfaced as a coverage note when it happens.
|
|
164
274
|
const agentModels = {};
|
|
165
275
|
const substituted = new Set();
|
|
276
|
+
// The buckets whose model was substituted, so the coverage note can name each
|
|
277
|
+
// one's OWN engine (a mixed run may substitute on either side, for different
|
|
278
|
+
// reasons — a CLI usage-limit downgrade vs. OpenCode's silent default fallback).
|
|
279
|
+
const substitutedBuckets = new Set();
|
|
166
280
|
const trackModel = (bucket, configured, actual) => {
|
|
167
281
|
if (!actual) {
|
|
168
282
|
return;
|
|
@@ -170,6 +284,7 @@ export async function runReview(source, options) {
|
|
|
170
284
|
agentModels[bucket] = actual;
|
|
171
285
|
if (configured && actual !== configured) {
|
|
172
286
|
substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
|
|
287
|
+
substitutedBuckets.add(bucket);
|
|
173
288
|
}
|
|
174
289
|
};
|
|
175
290
|
try {
|
|
@@ -439,10 +554,20 @@ export async function runReview(source, options) {
|
|
|
439
554
|
for (const line of substituted) {
|
|
440
555
|
progress(` ⚠ model substituted — ${line}`);
|
|
441
556
|
}
|
|
557
|
+
const subEngines = new Set([...substitutedBuckets].map((bucket) => engineOf[bucket] ?? "opencode"));
|
|
558
|
+
const why = [];
|
|
559
|
+
if (subEngines.has(CLAUDE_CODE_ENGINE)) {
|
|
560
|
+
why.push("The Claude Code CLI answered with a different model than configured (usage-limit " +
|
|
561
|
+
"downgrades do this on a subscription), so these findings may come from a weaker " +
|
|
562
|
+
"model than intended — check the configured model ids and the subscription's limits.");
|
|
563
|
+
}
|
|
564
|
+
if (subEngines.has("opencode")) {
|
|
565
|
+
why.push("OpenCode silently falls back to a default model when the configured id is empty or " +
|
|
566
|
+
"unusable, so these findings may come from a different (possibly much weaker) model " +
|
|
567
|
+
"than intended — check the agents' `model`, `coordinator.model`, REVIEWER_MODEL, and the provider credential.");
|
|
568
|
+
}
|
|
442
569
|
incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
|
|
443
|
-
|
|
444
|
-
`unusable, so these findings may come from a different (possibly much weaker) model ` +
|
|
445
|
-
`than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
|
|
570
|
+
why.join(" "));
|
|
446
571
|
}
|
|
447
572
|
// Note: routine noise filtering (lockfiles, generated, binary) is expected and
|
|
448
573
|
// NOT a coverage gap — it stays in the run log (filteredFiles), not the
|
|
@@ -536,10 +661,22 @@ export async function runReview(source, options) {
|
|
|
536
661
|
// Surface provider throttling as a fact about the run: passes already waited or
|
|
537
662
|
// backed off, but the operator should still SEE that it happened (a run that
|
|
538
663
|
// was rate-limited is slower and may carry partial passes — that's the cause).
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
664
|
+
// Sum both engines' watches; name a cause only for an engine whose watch fired
|
|
665
|
+
// (claude events arrive via note() in runClaudePrompt; opencode via its server log).
|
|
666
|
+
await opencodeHandle?.rateLimit.check();
|
|
667
|
+
const rlOpencode = opencodeHandle?.rateLimit.events ?? 0;
|
|
668
|
+
const rlClaude = claudeHandle?.rateLimit.events ?? 0;
|
|
669
|
+
const rlTotal = rlOpencode + rlClaude;
|
|
670
|
+
if (rlTotal > 0) {
|
|
671
|
+
const causes = [];
|
|
672
|
+
if (rlClaude > 0) {
|
|
673
|
+
causes.push("subscription rate/usage limits reported by the Claude Code CLI");
|
|
674
|
+
}
|
|
675
|
+
if (rlOpencode > 0) {
|
|
676
|
+
causes.push("429s in the OpenCode server log");
|
|
677
|
+
}
|
|
678
|
+
progress(` ⚠ provider rate-limited this run ${rlTotal} time(s) (${causes.join("; ")}) ` +
|
|
679
|
+
`— passes waited it out rather than failing`);
|
|
543
680
|
}
|
|
544
681
|
// Every pass says which model actually answered it — in the job log, the step
|
|
545
682
|
// summary table, and the run log — so a wrong or substituted model is always
|
|
@@ -561,7 +698,12 @@ export async function runReview(source, options) {
|
|
|
561
698
|
agentFindings,
|
|
562
699
|
coverageNotes,
|
|
563
700
|
verifierDropped,
|
|
564
|
-
...(
|
|
701
|
+
...(rlTotal > 0
|
|
702
|
+
? {
|
|
703
|
+
rateLimitEvents: rlTotal,
|
|
704
|
+
rateLimitByEngine: { opencode: rlOpencode, claudeCode: rlClaude },
|
|
705
|
+
}
|
|
706
|
+
: {}),
|
|
565
707
|
durationMs: Date.now() - started,
|
|
566
708
|
decision: output.decision,
|
|
567
709
|
findingCount: output.findings.length,
|
|
@@ -586,7 +728,7 @@ export async function runReview(source, options) {
|
|
|
586
728
|
throw error;
|
|
587
729
|
}
|
|
588
730
|
finally {
|
|
589
|
-
handle
|
|
731
|
+
handle.close();
|
|
590
732
|
await auth.cleanup();
|
|
591
733
|
await restoreCwd();
|
|
592
734
|
}
|
|
@@ -668,6 +810,39 @@ export function reconcileSummary(summary, remaining) {
|
|
|
668
810
|
"this summary was written, so it may mention issues no longer listed below._\n\n" +
|
|
669
811
|
summary);
|
|
670
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* Resolve the tree the review reads from, applying the mode's trust policy:
|
|
815
|
+
*
|
|
816
|
+
* - `null` from the source means "nothing to materialize" — reviewing the current
|
|
817
|
+
* checkout is intended (local diffs, or `--pr` without a repo). Never an error.
|
|
818
|
+
* - A materialization FAILURE (throw) is fatal in CI: the checkout there is the
|
|
819
|
+
* trusted BASE tree, and falling back to it would silently review and verify
|
|
820
|
+
* pre-PR file contents (dropping real findings with no trace in the output).
|
|
821
|
+
* The throw propagates to `ecr ci`'s catch, which posts the one terminal
|
|
822
|
+
* "not reviewed" comment.
|
|
823
|
+
* - The same failure in local mode degrades softly to the user's own checkout —
|
|
824
|
+
* the user is the trust principal there and sees the warning directly.
|
|
825
|
+
*
|
|
826
|
+
* Exported for tests.
|
|
827
|
+
*/
|
|
828
|
+
export async function resolveReadRoot(source, mode, progress) {
|
|
829
|
+
if (!source.prepareReadRootAsync) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
return await source.prepareReadRootAsync();
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
if (mode === "ci") {
|
|
837
|
+
throw new Error(`Could not materialize the PR-head tree to review (and the CI checkout is the ` +
|
|
838
|
+
`trusted base, so reviewing it instead would silently review the wrong ` +
|
|
839
|
+
`contents): ${errorMessage(error)}`);
|
|
840
|
+
}
|
|
841
|
+
progress(`Could not materialize the PR-head tree (${errorMessage(error)}); ` +
|
|
842
|
+
`reading the current checkout instead — file contents may not match the PR.`);
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
671
846
|
/** Capitalize the first letter (coverage notes read as sentences). */
|
|
672
847
|
function capitalize(text) {
|
|
673
848
|
return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
|
package/build/core/schema.js
CHANGED
|
@@ -112,7 +112,12 @@ export function extractJsonObject(text) {
|
|
|
112
112
|
lastError = error;
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
throw new Error(
|
|
115
|
+
throw new Error(lastError === undefined
|
|
116
|
+
? // No candidate was even tried: the response held no {...} block at all —
|
|
117
|
+
// empty output, or prose/pseudo-tool-call text with no JSON in it.
|
|
118
|
+
`Could not extract JSON from model response: no JSON object found in ` +
|
|
119
|
+
`${text.trim() === "" ? "an EMPTY response" : `a ${text.length}-char response with no {...}`}`
|
|
120
|
+
: `Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
116
121
|
}
|
|
117
122
|
/** The router's choice of which agent ids to run. */
|
|
118
123
|
export const RouteOutputSchema = z.object({
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readdir, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Ambient runtime configuration the OpenCode server (and the Claude-compatible
|
|
5
|
+
* loaders inside it) discovers from its project directory. The review core chdirs
|
|
6
|
+
* into a materialized PR-HEAD worktree before starting the server, so every one
|
|
7
|
+
* of these is attacker-writable in a PR: a plugin or MCP definition is arbitrary
|
|
8
|
+
* code execution in a process holding the model credential and a comment-capable
|
|
9
|
+
* GH_TOKEN; a `.env` can repoint a provider base URL; instruction files inject
|
|
10
|
+
* system-level prompts. OPENCODE_CONFIG_CONTENT (how ECR passes its own config)
|
|
11
|
+
* MERGES with project config rather than replacing it, so deleting these from the
|
|
12
|
+
* throwaway worktree is the only isolation that doesn't depend on OpenCode
|
|
13
|
+
* semantics.
|
|
14
|
+
*
|
|
15
|
+
* Exact-name entries match files or directories at any depth; `.env` is matched
|
|
16
|
+
* as a prefix (`.env`, `.env.local`, …). The PR's CHANGES to these files are
|
|
17
|
+
* still reviewed — their diffs are inlined in the task prompt — but the reviewer
|
|
18
|
+
* can no longer open their full head contents, and a finding citing one will
|
|
19
|
+
* fail verification (a documented tradeoff of the scrub approach).
|
|
20
|
+
*/
|
|
21
|
+
export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
|
|
22
|
+
"opencode.json",
|
|
23
|
+
"opencode.jsonc",
|
|
24
|
+
".opencode",
|
|
25
|
+
"AGENTS.md",
|
|
26
|
+
"CLAUDE.md",
|
|
27
|
+
".claude",
|
|
28
|
+
".mcp.json",
|
|
29
|
+
".cursor",
|
|
30
|
+
".cursorrules",
|
|
31
|
+
]);
|
|
32
|
+
/** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
|
|
33
|
+
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
34
|
+
/** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
|
|
35
|
+
export function isAmbientRuntimeConfig(name) {
|
|
36
|
+
return AMBIENT_RUNTIME_CONFIG_NAMES.has(name) || name === ".env" || name.startsWith(".env.");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove ambient runtime config from a THROWAWAY materialized tree, at every
|
|
40
|
+
* depth. Must only ever run on a tree ECR created and will delete (a worktree or
|
|
41
|
+
* extracted archive) — never on the user's checkout. Returns the repo-relative
|
|
42
|
+
* paths removed so callers can log them.
|
|
43
|
+
*/
|
|
44
|
+
export async function scrubAmbientRuntimeConfig(root) {
|
|
45
|
+
const removed = [];
|
|
46
|
+
const walk = async (dir) => {
|
|
47
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const full = path.join(dir, entry.name);
|
|
50
|
+
if (isAmbientRuntimeConfig(entry.name)) {
|
|
51
|
+
await rm(full, { recursive: true, force: true });
|
|
52
|
+
removed.push(path.relative(root, full));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
56
|
+
await walk(full);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
await walk(root);
|
|
61
|
+
return removed.sort();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Remove symlinks whose fully resolved target lies outside the materialized tree.
|
|
65
|
+
*
|
|
66
|
+
* The model runtime's read tools are path-scoped by the LITERAL path argument
|
|
67
|
+
* (buildClaudeArgs' permission rules, and equally OpenCode's project-root
|
|
68
|
+
* containment), but the fs layer underneath follows symlinks — so a PR-committed
|
|
69
|
+
* link (`docs/notes.md -> ~/.claude/.credentials.json`) passes the in-tree check
|
|
70
|
+
* and reads the out-of-tree target. Git can only materialize regular files,
|
|
71
|
+
* directories, and symlinks, so stripping escaping symlinks here closes the whole
|
|
72
|
+
* class.
|
|
73
|
+
*
|
|
74
|
+
* Fail closed: a link whose target cannot be resolved (broken, or a chain that
|
|
75
|
+
* leaves the tree at any hop) is removed too — the target could come into
|
|
76
|
+
* existence later, and a broken link has no legitimate review value. In-tree
|
|
77
|
+
* links survive (realpath resolves chains, so an in-tree alias of an in-tree
|
|
78
|
+
* file is provably contained). Unlike the config scrub, this walk descends into
|
|
79
|
+
* node_modules (a committed one is attacker content); `.git` stays skipped — in
|
|
80
|
+
* a worktree it is an ECR-created gitdir link, not PR content.
|
|
81
|
+
*
|
|
82
|
+
* Must only ever run on a tree ECR created and will delete. Returns the
|
|
83
|
+
* repo-relative paths removed so callers can log them.
|
|
84
|
+
*/
|
|
85
|
+
export async function removeEscapingSymlinks(root) {
|
|
86
|
+
// realpath the boundary itself: tmpdir-based roots are often behind symlinks
|
|
87
|
+
// (macOS /var -> /private/var), and containment must compare resolved paths.
|
|
88
|
+
const boundary = await realpath(root);
|
|
89
|
+
const removed = [];
|
|
90
|
+
const walk = async (dir) => {
|
|
91
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const full = path.join(dir, entry.name);
|
|
94
|
+
if (entry.isSymbolicLink()) {
|
|
95
|
+
let contained = false;
|
|
96
|
+
try {
|
|
97
|
+
const target = await realpath(full);
|
|
98
|
+
contained = target === boundary || target.startsWith(boundary + path.sep);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Unresolvable link: leave `contained` false (fail closed).
|
|
102
|
+
}
|
|
103
|
+
if (!contained) {
|
|
104
|
+
await rm(full, { force: true });
|
|
105
|
+
// Relative to the RESOLVED boundary — the walk runs there, and the
|
|
106
|
+
// caller's `root` may itself sit behind a symlink (macOS /var).
|
|
107
|
+
removed.push(path.relative(boundary, full));
|
|
108
|
+
}
|
|
109
|
+
// In-tree directory links are kept but never descended: their contents
|
|
110
|
+
// are walked once via the real path, and descending would loop on cycles.
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (entry.isDirectory() && entry.name !== ".git") {
|
|
114
|
+
await walk(full);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
await walk(boundary);
|
|
119
|
+
return removed.sort();
|
|
120
|
+
}
|
package/build/core/throttle.js
CHANGED
|
@@ -56,6 +56,16 @@ export class RateLimitWatch {
|
|
|
56
56
|
constructor(file = opencodeLogFile()) {
|
|
57
57
|
this.file = file;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Record rate-limit evidence directly, for an engine that has no log file to
|
|
61
|
+
* scan (the Claude Code CLI surfaces limits per-invocation, not in a log the
|
|
62
|
+
* way the OpenCode server does). Feeds the same `events`/`recentlyLimited`
|
|
63
|
+
* signals `check()` would.
|
|
64
|
+
*/
|
|
65
|
+
note(count = 1) {
|
|
66
|
+
this.events += count;
|
|
67
|
+
this.lastSeenAt = Date.now();
|
|
68
|
+
}
|
|
59
69
|
/** Scan newly-appended log lines for rate-limit evidence. */
|
|
60
70
|
async check() {
|
|
61
71
|
try {
|
package/build/core/util.js
CHANGED
|
@@ -5,6 +5,23 @@ export function sleep(ms) {
|
|
|
5
5
|
export function errorMessage(error) {
|
|
6
6
|
return error instanceof Error ? error.message : String(error);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A failure reason safe to post into a PUBLIC PR comment (see `ecr ci`'s failure
|
|
10
|
+
* notices). `run` (core/exec.ts) throws "Command failed: <cmd> <argv>\n<stderr>" (and
|
|
11
|
+
* "Command output exceeded …"), which embeds a subprocess's full command line, its
|
|
12
|
+
* stderr, and absolute runner paths — CI-internal detail of no use to a PR author and
|
|
13
|
+
* exactly the kind of thing that must not leak into an attacker-visible artifact. Those
|
|
14
|
+
* shapes collapse to a generic pointer at the workflow log; our own (argv/path-free)
|
|
15
|
+
* error messages pass through, since they are the actionable ones the fail-fast design
|
|
16
|
+
* means to surface. Every call site still writes the FULL reason to the job's stderr.
|
|
17
|
+
*/
|
|
18
|
+
export function publicFailureReason(error) {
|
|
19
|
+
const message = errorMessage(error);
|
|
20
|
+
if (/^Command (failed|output exceeded)\b/.test(message)) {
|
|
21
|
+
return "a subprocess (gh, git, or the model CLI) failed — see the workflow logs for details";
|
|
22
|
+
}
|
|
23
|
+
return message;
|
|
24
|
+
}
|
|
8
25
|
/** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
|
|
9
26
|
export function normalizeCode(text) {
|
|
10
27
|
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
package/build/core/verify.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { pathInside } from "./exec.js";
|
|
3
4
|
import { parseVerdict } from "./schema.js";
|
|
4
5
|
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
|
|
5
6
|
import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
|
|
@@ -50,9 +51,20 @@ export function matchEvidence(evidence, content) {
|
|
|
50
51
|
}
|
|
51
52
|
/** Read the cited file and grade the evidence against it (see matchEvidence). */
|
|
52
53
|
async function evidencePresence(finding, cwd) {
|
|
54
|
+
// finding.file is an unconstrained, LLM-authored string produced over untrusted PR
|
|
55
|
+
// content, so a prompt-injected finding could point it at a host secret. path.resolve
|
|
56
|
+
// IGNORES cwd when finding.file is already absolute (e.g. ~/.claude/.credentials.json),
|
|
57
|
+
// and `..` segments escape upward — either would make this raw readFile reach outside
|
|
58
|
+
// the reviewed tree with the host user's privileges, and the present/absent grading
|
|
59
|
+
// would leak a content-oracle back into the review. Confine the read to cwd (the
|
|
60
|
+
// materialized PR-head tree); anything outside is uncheckable, never read.
|
|
61
|
+
const resolved = path.resolve(cwd, finding.file);
|
|
62
|
+
if (!pathInside(resolved, cwd)) {
|
|
63
|
+
return "unknown";
|
|
64
|
+
}
|
|
53
65
|
let content;
|
|
54
66
|
try {
|
|
55
|
-
content = await readFile(
|
|
67
|
+
content = await readFile(resolved, "utf8");
|
|
56
68
|
}
|
|
57
69
|
catch {
|
|
58
70
|
return "unknown";
|