@expo/code-review-cli 0.5.2 → 0.6.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 CHANGED
@@ -218,16 +218,31 @@ your-monorepo/
218
218
  (e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
219
219
  root roster — a scope defining a same-id agent gets the root one, so a team can't
220
220
  shadow the enforced reviewer with a weaker version on its own subtree.
221
- - **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
222
- auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
223
- and routing globs for its own advisory review that only steers what the
224
- comment-only reviewer says about that PR. What a PR can **never** do is touch
225
- credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
226
- `ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
227
- against whatever ref is checked out. A `/review`-command workflow that checks out
228
- only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
229
- resolving config from the base ref on the auto path as well is on the
230
- [roadmap](./ROADMAP.md).
221
+ - **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
222
+ policy and reviewer configuration `config.jsonc`, `routing.jsonc`, prompts,
223
+ models, and the auth mapping load from the PR's immutable **base** commit,
224
+ materialized via the GitHub API. The PR head is untrusted data: it is
225
+ materialized separately (pinned to its immutable OID) purely as source content
226
+ to read and verify against. A PR editing rosters, prompts, or routing is
227
+ reviewed under the **previous** config; its changes activate after merge. If
228
+ the base commit can't be materialized, the run fails closed (one terminal
229
+ comment) it never falls back to the checkout. A scope config that is new in
230
+ a PR is reviewed with the root config until it merges.
231
+ - **The model runtime never sees PR-owned ambient config.** The head worktree the
232
+ agents read from is scrubbed of runtime configuration before the OpenCode
233
+ server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
234
+ `CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
235
+ A PR can't install a plugin, MCP server, instruction file, or `.env` into the
236
+ process that holds the model credential and the comment token. (Changes to
237
+ those files are still reviewed — their diffs are inlined in the prompt — but a
238
+ finding citing one can't be re-read during verification; that's the tradeoff.)
239
+ - **The scaffolded workflows check out only the base commit** with
240
+ `persist-credentials: false`; the CLI's own git fetches authenticate through
241
+ `gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
242
+ CLI enforces the trust model itself, so a custom workflow that checks out the
243
+ PR head still gets base-commit configuration. The temporary escape hatch
244
+ `ecr ci --unsafe-config-from-head` restores the old behavior with a loud
245
+ security warning and will be removed on a minor boundary.
231
246
 
232
247
  Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
233
248
  (the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
2
+ import path from "node:path";
3
+ import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
3
4
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
4
5
  import { repoRoot, run } from "../core/exec.js";
5
6
  import { errorMessage } from "../core/util.js";
@@ -32,6 +33,15 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
32
33
  via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
33
34
  and non-blocking (a reviewer failure never fails the PR's checks).
34
35
 
36
+ Trust model: review policy and reviewer configuration (config.jsonc, routing,
37
+ prompts, models, auth mapping) load from the PR's immutable BASE commit,
38
+ materialized via the GitHub API — never from the PR head — so a PR cannot change
39
+ the reviewer that evaluates it; config changes activate after merge. The PR head
40
+ is materialized separately (pinned to its immutable OID, scrubbed of ambient
41
+ runtime config) purely as source content to read and verify against. If the
42
+ trusted base cannot be materialized, the run fails closed with one terminal
43
+ comment; it never falls back to the checkout.
44
+
35
45
  Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
36
46
  it assigns each changed file to exactly one scope (last-match-wins) and reviews
37
47
  each active scope over only its files, then renders one aggregated comment (or one
@@ -42,8 +52,17 @@ Options:
42
52
  --route Let the router pick relevant agents from the diff
43
53
  --scopes <a,b> Limit the fan-out to these named scopes (routing only)
44
54
  --config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
45
- instead of .expo-code-review/ (also ECR_CONFIG_DIR). Scope
46
- subtrees stay repo-root-relative.
55
+ instead of .expo-code-review/ (also ECR_CONFIG_DIR). A
56
+ RELATIVE dir resolves beneath the trusted base commit; an
57
+ ABSOLUTE dir is an explicit operator trust decision. Scope
58
+ subtrees always resolve beneath the trusted base commit.
59
+ --unsafe-config-from-head
60
+ COMPATIBILITY ESCAPE HATCH: load configuration from the
61
+ current checkout instead of the PR's trusted base commit.
62
+ This lets a same-repo PR change the reviewer (policy,
63
+ prompts, model, auth mapping) that evaluates itself.
64
+ Never scaffolded; prints a security warning; will be
65
+ removed on a scheduled minor boundary.
47
66
  --comment <mode> Override manifest comment mode: single | per-scope
48
67
  --force Manual override: review even if the trigger policy (label
49
68
  trigger / ai-review:skip) would skip. Break-glass and the
@@ -78,6 +97,7 @@ export async function ciCommand(argv = []) {
78
97
  // A maintainer's explicit `/review` (comment command or --force) is a manual
79
98
  // escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
80
99
  const bypassTriggerGate = shouldBypassTriggerGate(argv);
100
+ const unsafeConfigFromHead = argv.includes("--unsafe-config-from-head");
81
101
  const root = await repoRoot();
82
102
  if (root && root !== process.cwd()) {
83
103
  process.chdir(root);
@@ -89,35 +109,120 @@ export async function ciCommand(argv = []) {
89
109
  process.stderr.write("CI reviewer: could not determine repository or PR number from the environment. Skipping.\n");
90
110
  return;
91
111
  }
92
- // A malformed manifest is a loud, non-blocking error (never a silent fallback).
93
- let manifest;
94
- try {
95
- manifest = await loadRoutingManifest(cwd, { configDir });
112
+ // ONE source for the whole run: metadata (incl. immutable OIDs), the diff, and
113
+ // the PR-head read root are each fetched once and shared across scopes.
114
+ const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
115
+ const source = memoizeSource(ghSource);
116
+ // Trusted configuration root: review policy and reviewer config load from the
117
+ // PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
118
+ // when the base can't be materialized, post the one terminal comment and stop —
119
+ // silently reading the checkout would let a head checkout smuggle config in.
120
+ let trustedRoot = null;
121
+ let configRoot = cwd;
122
+ if (unsafeConfigFromHead) {
123
+ process.stderr.write("CI reviewer: ⚠ SECURITY — --unsafe-config-from-head is set: reviewer configuration " +
124
+ "(policy, prompts, models, auth mapping) is being loaded from the current checkout, so " +
125
+ "a same-repository PR can change the reviewer that evaluates it. This escape hatch will " +
126
+ "be removed in a future minor release.\n");
96
127
  }
97
- catch (error) {
98
- process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
99
- return;
128
+ else {
129
+ try {
130
+ trustedRoot = await ghSource.prepareTrustedConfigRootAsync();
131
+ configRoot = trustedRoot.dir;
132
+ }
133
+ catch (error) {
134
+ const reason = errorMessage(error);
135
+ process.stderr.write(`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
136
+ `(failing closed, not reviewing): ${reason}\n`);
137
+ await postTerminalFailureNote(repo, prNumber, cwd, `it could not load trusted configuration from the PR's base commit (${reason}). ` +
138
+ `This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`);
139
+ return;
140
+ }
100
141
  }
101
- if (manifest == null) {
102
- await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
103
- return;
142
+ try {
143
+ // A malformed manifest is a loud, non-blocking error (never a silent fallback).
144
+ let manifest;
145
+ try {
146
+ manifest = await loadRoutingManifest(configRoot, { configDir });
147
+ }
148
+ catch (error) {
149
+ process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
150
+ return;
151
+ }
152
+ if (manifest == null) {
153
+ await runLegacyCi(source, repo, prNumber, cwd, configRoot, {
154
+ agents,
155
+ route,
156
+ bypassTriggerGate,
157
+ configDir,
158
+ });
159
+ return;
160
+ }
161
+ try {
162
+ await runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, {
163
+ agents,
164
+ route,
165
+ scopesFilter,
166
+ commentOverride,
167
+ bypassTriggerGate,
168
+ configDir,
169
+ });
170
+ }
171
+ catch (error) {
172
+ // Fan-out failures stay non-blocking (single-writer property is the point).
173
+ process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
174
+ }
104
175
  }
176
+ finally {
177
+ await source.dispose();
178
+ await trustedRoot?.cleanup();
179
+ }
180
+ }
181
+ /**
182
+ * The one terminal "this PR was NOT reviewed" comment for failures that happen
183
+ * before any configuration is loaded (trusted-root materialization). Uses the
184
+ * DEFAULT comment tag/break-glass marker because the config that could customize
185
+ * them is exactly what failed to load; a repo with a custom tag gets a fresh
186
+ * comment rather than an upsert, which is the acceptable degraded case.
187
+ */
188
+ async function postTerminalFailureNote(repo, prNumber, cwd, reason) {
105
189
  try {
106
- await runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir);
190
+ const reporter = new GitHubReporter({
191
+ prNumber,
192
+ repo,
193
+ commentTag: "expo-ai-code-reviewer",
194
+ breakGlassMarker: "/skip-review",
195
+ cwd,
196
+ });
197
+ await reporter.report({
198
+ decision: "approve_with_comments",
199
+ findings: [],
200
+ summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed: ${reason}.`,
201
+ incomplete: [],
202
+ couldNotComplete: true,
203
+ });
107
204
  }
108
- catch (error) {
109
- // Fan-out failures stay non-blocking (single-writer property is the point).
110
- process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
205
+ catch (postError) {
206
+ process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
111
207
  }
112
208
  }
209
+ /**
210
+ * Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
211
+ * (temporary, removed-on-exit) trusted config root — the workflow uploads
212
+ * `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
213
+ */
214
+ function workspaceRunsDir(cwd) {
215
+ return path.join(cwd, CONFIG_DIRNAME, ".runs");
216
+ }
113
217
  /**
114
218
  * The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
115
219
  * routing.jsonc the CLI behaves exactly as before (backcompat invariant).
116
220
  */
117
- async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir) {
221
+ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
222
+ const { agents, route, bypassTriggerGate, configDir } = options;
118
223
  let config;
119
224
  try {
120
- config = await loadReviewConfig(cwd, { configDir });
225
+ config = await loadReviewConfig(configRoot, { configDir });
121
226
  }
122
227
  catch (error) {
123
228
  process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
@@ -158,11 +263,12 @@ async function runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate
158
263
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
159
264
  }
160
265
  try {
161
- const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
266
+ const review = await runReview(source, {
162
267
  config,
163
268
  mode: "ci",
164
269
  agents,
165
270
  route,
271
+ runsDir: workspaceRunsDir(cwd),
166
272
  onProgress: (message) => process.stderr.write(`${message}\n`),
167
273
  });
168
274
  await reporter.report(review);
@@ -198,10 +304,12 @@ function failureReview(scopeName, reason) {
198
304
  };
199
305
  }
200
306
  /** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
201
- async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir) {
307
+ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, options) {
308
+ const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
202
309
  // The root config + manifest follow the override; scope configs stay
203
- // repo-root-relative (loadScopeConfig reads <root>/<scope.config>/.expo-code-review).
204
- const rootConfig = await loadReviewConfig(cwd, { configDir });
310
+ // relative to the TRUSTED root (loadScopeConfig reads
311
+ // <configRoot>/<scope.config>/.expo-code-review).
312
+ const rootConfig = await loadReviewConfig(configRoot, { configDir });
205
313
  // The root/aggregate marker is the ACTUAL root-owned comment tag so the
206
314
  // pre-routing comment and its dismissal state upsert in place, not stranded
207
315
  // under a new marker (risk 8/9). manifest.defaults.commentTag is the
@@ -240,7 +348,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
240
348
  if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), rootConfig.review, bypassTriggerGate)) {
241
349
  return;
242
350
  }
243
- const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
244
351
  const changed = await source.getChangedFiles();
245
352
  const resolution = resolveScopes(manifest, changed.map((file) => file.path));
246
353
  process.stderr.write("CI reviewer: scope ownership —\n");
@@ -276,7 +383,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
276
383
  if (await bgReporter.checkBreakGlass()) {
277
384
  process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
278
385
  await bgReporter.postSkipNote();
279
- await source.dispose();
280
386
  return;
281
387
  }
282
388
  }
@@ -284,17 +390,17 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
284
390
  process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
285
391
  }
286
392
  // Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
287
- // already-fetched changed files, base SHA via a single `gh pr view`.
393
+ // already-fetched changed files, base OID from the memoized PR metadata (the same
394
+ // immutable OID the trusted config root was materialized from).
288
395
  const link = {
289
396
  repo,
290
397
  prNumber,
291
398
  diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
292
399
  };
293
400
  try {
294
- const { stdout } = await run("gh", ["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefOid"], { cwd });
295
- const oid = JSON.parse(stdout).baseRefOid;
296
- if (oid) {
297
- link.baseSha = oid;
401
+ const { baseOid } = await source.getMetadata();
402
+ if (baseOid) {
403
+ link.baseSha = baseOid;
298
404
  }
299
405
  }
300
406
  catch {
@@ -317,7 +423,18 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
317
423
  const isDefault = scope.configDir === ".";
318
424
  let review;
319
425
  try {
320
- const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
426
+ // A scope whose config dir doesn't exist at the TRUSTED base commit is a
427
+ // scope this PR introduces: review it with the root config rather than
428
+ // failing the run on exactly that PR. The scope's own config (PR-owned,
429
+ // untrusted for this run) activates once it merges.
430
+ let effectiveScopeDef = scopeDef;
431
+ if (!hasScopeConfig(configRoot, scopeDef)) {
432
+ process.stderr.write(`CI reviewer: [${scope.name}] no config at "${scopeDef.config}" in the PR's base ` +
433
+ `commit (new in this PR?); reviewing with the root config — the scope's config ` +
434
+ `takes effect after merge.\n`);
435
+ effectiveScopeDef = { ...scopeDef, config: "." };
436
+ }
437
+ const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
321
438
  review = await runReview(source, {
322
439
  config,
323
440
  mode: "ci",
@@ -325,6 +442,7 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
325
442
  route,
326
443
  includePaths: scope.files,
327
444
  passesBudgetMs: budget,
445
+ runsDir: workspaceRunsDir(cwd),
328
446
  onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
329
447
  });
330
448
  }
@@ -335,7 +453,6 @@ async function runRoutedCi(manifest, repo, prNumber, cwd, agents, route, scopesF
335
453
  }
336
454
  results.push({ scope: scope.name, isDefault, review });
337
455
  }
338
- await source.dispose();
339
456
  const reporterFor = (tag, withLink = false) => new GitHubReporter({
340
457
  prNumber,
341
458
  repo,
@@ -38,9 +38,11 @@ Options:
38
38
  --no-fail always exit 0, even on request-changes
39
39
  -h, --help show this help
40
40
 
41
- Note: agents read the local working tree for surrounding context, so --pr uses the
42
- PR's diff (authoritative) but your checked-out files for context. For full fidelity
43
- on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
41
+ Note: with --repo (or in CI), --pr materializes the PR-head tree (pinned to its
42
+ immutable commit, scrubbed of ambient runtime config) so reads match the PR; if
43
+ that isn't possible, it falls back to your checked-out files with a warning.
44
+ Config always loads from YOUR checkout in local runs — you are the trust
45
+ principal here. In \`ecr ci\`, config loads from the PR's trusted base commit.
44
46
 
45
47
  Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
46
48
  `;
@@ -177,6 +177,21 @@ export function loadAuthFromRoot(rootConfig, manifest) {
177
177
  }
178
178
  return rootConfig.auth;
179
179
  }
180
+ /**
181
+ * Whether a scope's own config dir exists under `root` — the same path
182
+ * `loadScopeConfig` reads (deliberately NOT via resolveConfigDir: ECR_CONFIG_DIR
183
+ * must never redirect scope subtrees). `ecr ci` uses this against the TRUSTED
184
+ * BASE root to give scopes that are new in a PR a defined miss behavior (review
185
+ * with the root config; the scope config activates after merge) instead of
186
+ * failing the run on exactly the PR that introduces the scope.
187
+ */
188
+ export function hasScopeConfig(root, scope) {
189
+ if (scope.config === ".") {
190
+ return true;
191
+ }
192
+ const dir = path.join(root, scope.config, CONFIG_DIRNAME);
193
+ return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
194
+ }
180
195
  /**
181
196
  * Load one scope's fully-resolved config. The default scope (config '.') reuses
182
197
  * the root config unchanged except auth; a nested scope reads its own
@@ -51,7 +51,7 @@ export async function runReview(source, options) {
51
51
  const started = Date.now();
52
52
  const runId = makeRunId();
53
53
  const progress = options.onProgress ?? (() => { });
54
- const runsRoot = path.join(config.configDir, ".runs");
54
+ const runsRoot = options.runsDir ?? path.join(config.configDir, ".runs");
55
55
  const runDir = path.join(runsRoot, runId);
56
56
  const logPath = path.join(runsRoot, "reviews.jsonl");
57
57
  // Fail fast on an invalid explicit selection before doing any work. Routing
@@ -97,18 +97,27 @@ export async function runReview(source, options) {
97
97
  });
98
98
  return output;
99
99
  }
100
- // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
101
- // and doing it first means nothing that can throw sits between the chdir and the
102
- // guarded blocks so a prepareAuth failure can't leak the worktree or leave cwd
103
- // pointing at it.
104
- const auth = await prepareAuth(config);
105
- // Read the PR-head tree (not the current checkout) when the source can materialize
106
- // it, so the agents' surrounding-source reads and the verifier's re-reads see the
107
- // versions that match the diff. Config is already fully loaded in memory, so the
108
- // chdir doesn't affect it; run-log/patch paths are absolute; gh/git calls already
109
- // ran above. Fails soft to the current directory.
100
+ // Materialize the PR-head tree (not the current checkout) when the source can, so
101
+ // the agents' surrounding-source reads and the verifier's re-reads see the versions
102
+ // that match the diff. Config is already fully loaded in memory, so the chdir below
103
+ // doesn't affect it; run-log/patch paths are absolute; gh/git calls already ran
104
+ // above. Failure policy is MODE-DEPENDENT (see resolveReadRoot): CI fails closed —
105
+ // with a base-SHA checkout the fallback tree is pre-PR content, and silently
106
+ // reviewing/verifying that drops real findings while a local run falls back to
107
+ // the user's own checkout with a warning.
110
108
  const originalCwd = process.cwd();
111
- const readRoot = (await source.prepareReadRootAsync?.()) ?? null;
109
+ const readRoot = await resolveReadRoot(source, options.mode, progress);
110
+ // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
111
+ // and doing it after readRoot but before chdir means a prepareAuth failure can't
112
+ // leave cwd pointing at the worktree — it only has the worktree itself to release.
113
+ let auth;
114
+ try {
115
+ auth = await prepareAuth(config);
116
+ }
117
+ catch (error) {
118
+ await readRoot?.cleanup();
119
+ throw error;
120
+ }
112
121
  const restoreCwd = async () => {
113
122
  if (readRoot) {
114
123
  process.chdir(originalCwd);
@@ -668,6 +677,39 @@ export function reconcileSummary(summary, remaining) {
668
677
  "this summary was written, so it may mention issues no longer listed below._\n\n" +
669
678
  summary);
670
679
  }
680
+ /**
681
+ * Resolve the tree the review reads from, applying the mode's trust policy:
682
+ *
683
+ * - `null` from the source means "nothing to materialize" — reviewing the current
684
+ * checkout is intended (local diffs, or `--pr` without a repo). Never an error.
685
+ * - A materialization FAILURE (throw) is fatal in CI: the checkout there is the
686
+ * trusted BASE tree, and falling back to it would silently review and verify
687
+ * pre-PR file contents (dropping real findings with no trace in the output).
688
+ * The throw propagates to `ecr ci`'s catch, which posts the one terminal
689
+ * "not reviewed" comment.
690
+ * - The same failure in local mode degrades softly to the user's own checkout —
691
+ * the user is the trust principal there and sees the warning directly.
692
+ *
693
+ * Exported for tests.
694
+ */
695
+ export async function resolveReadRoot(source, mode, progress) {
696
+ if (!source.prepareReadRootAsync) {
697
+ return null;
698
+ }
699
+ try {
700
+ return await source.prepareReadRootAsync();
701
+ }
702
+ catch (error) {
703
+ if (mode === "ci") {
704
+ throw new Error(`Could not materialize the PR-head tree to review (and the CI checkout is the ` +
705
+ `trusted base, so reviewing it instead would silently review the wrong ` +
706
+ `contents): ${errorMessage(error)}`);
707
+ }
708
+ progress(`Could not materialize the PR-head tree (${errorMessage(error)}); ` +
709
+ `reading the current checkout instead — file contents may not match the PR.`);
710
+ return null;
711
+ }
712
+ }
671
713
  /** Capitalize the first letter (coverage notes read as sentences). */
672
714
  function capitalize(text) {
673
715
  return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
@@ -0,0 +1,62 @@
1
+ import { readdir, 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
+ }
@@ -3,6 +3,19 @@ import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { run } from "../core/exec.js";
5
5
  import { parseUnifiedDiff } from "../core/diff.js";
6
+ import { scrubAmbientRuntimeConfig } from "../core/scrub.js";
7
+ /** A full 40-hex-char commit OID — the only ref form passed to security-sensitive git calls. */
8
+ export function isCommitOid(value) {
9
+ return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
10
+ }
11
+ /**
12
+ * Append gh as a git credential helper for a single command. The token comes from
13
+ * GH_TOKEN via the credential-helper protocol — never argv, never `.git/config` —
14
+ * so a base-SHA checkout with `persist-credentials: false` (no extraheader) can
15
+ * still fetch a private repo. Appending (not replacing) keeps a local user's own
16
+ * helpers first, so developer machines behave exactly as before.
17
+ */
18
+ const GH_CREDENTIAL_HELPER_ARGS = ["-c", "credential.helper=!gh auth git-credential"];
6
19
  /**
7
20
  * Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
8
21
  * authenticated on GitHub Actions runners via GH_TOKEN.
@@ -12,17 +25,29 @@ export class GitHubPRSource {
12
25
  constructor(options) {
13
26
  this.options = options;
14
27
  }
28
+ metadataPromise;
15
29
  repoArgs() {
16
30
  return this.options.repo ? ["--repo", this.options.repo] : [];
17
31
  }
18
- async getMetadata() {
32
+ /**
33
+ * Memoized internally (not just by memoizeSource) because the materialization
34
+ * paths below need the immutable OIDs and must not depend on the caller having
35
+ * called getMetadata() first.
36
+ */
37
+ getMetadata() {
38
+ return (this.metadataPromise ??= this.fetchMetadata());
39
+ }
40
+ async fetchMetadata() {
19
41
  const { stdout } = await run("gh", [
20
42
  "pr",
21
43
  "view",
22
44
  String(this.options.prNumber),
23
45
  ...this.repoArgs(),
24
46
  "--json",
25
- "title,body,baseRefName,headRefName",
47
+ // baseRefOid/headRefOid are the immutable commit OIDs backing this PR at
48
+ // this moment; every materialization below pins to them so a rename,
49
+ // force-push, or deleted head between API calls can't swap a tree.
50
+ "title,body,baseRefName,headRefName,baseRefOid,headRefOid",
26
51
  ], { cwd: this.options.cwd });
27
52
  const parsed = JSON.parse(stdout);
28
53
  return {
@@ -30,6 +55,8 @@ export class GitHubPRSource {
30
55
  body: parsed.body ?? "",
31
56
  baseRef: parsed.baseRefName ?? "",
32
57
  headRef: parsed.headRefName ?? "",
58
+ baseOid: isCommitOid(parsed.baseRefOid) ? parsed.baseRefOid.toLowerCase() : undefined,
59
+ headOid: isCommitOid(parsed.headRefOid) ? parsed.headRefOid.toLowerCase() : undefined,
33
60
  };
34
61
  }
35
62
  async getChangedFiles() {
@@ -37,27 +64,28 @@ export class GitHubPRSource {
37
64
  return parseUnifiedDiff(stdout);
38
65
  }
39
66
  /**
40
- * Check the PR HEAD out into a throwaway git worktree so the agents and verifier
41
- * read the PR's versions of files (not whatever branch happens to be checked out).
42
- * Fetches the head from the repo's own URL `refs/pull/<n>/head`, which the base
43
- * repo hosts even for fork PRs — so it's always the correct PR, independent of the
44
- * local `origin`. Fails SOFT: any problem (not a git repo, fetch/worktree error)
45
- * returns null, and the review falls back to reading the current checkout.
67
+ * Fetch `ref` from the repo's own HTTPS URL and materialize `oid` as a detached
68
+ * throwaway worktree under a fresh temp dir. Never falls back to a branch name:
69
+ * `oid` is validated as a full commit hash before reaching git.
46
70
  */
47
- async prepareReadRootAsync() {
48
- const cwd = this.options.cwd;
71
+ async materializeWorktreeAsync(ref, oid) {
49
72
  if (!this.options.repo) {
50
- // Without an explicit owner/repo we can't build the fetch URL safely.
51
- return null;
73
+ throw new Error("cannot materialize a PR tree without an explicit owner/repo");
74
+ }
75
+ if (!isCommitOid(oid)) {
76
+ throw new Error(`refusing to materialize a non-OID ref: "${oid}"`);
52
77
  }
78
+ const cwd = this.options.cwd;
53
79
  const url = `https://github.com/${this.options.repo}.git`;
54
- const ref = `refs/pull/${this.options.prNumber}/head`;
55
80
  let parent;
56
81
  try {
57
- await run("git", ["fetch", "--no-tags", "--depth=1", url, ref], { cwd });
58
- parent = await mkdtemp(path.join(tmpdir(), "ecr-prhead-"));
59
- const dir = path.join(parent, "head"); // must not pre-exist for `worktree add`
60
- await run("git", ["worktree", "add", "--detach", dir, "FETCH_HEAD"], { cwd });
82
+ await run("git", [...GH_CREDENTIAL_HELPER_ARGS, "fetch", "--no-tags", "--depth=1", url, ref], { cwd });
83
+ parent = await mkdtemp(path.join(tmpdir(), "ecr-tree-"));
84
+ const dir = path.join(parent, "tree"); // must not pre-exist for `worktree add`
85
+ // Check out the OID (not FETCH_HEAD): if the ref moved between the API call
86
+ // and this fetch, the OID is absent and this fails instead of silently
87
+ // materializing a different tree than the one the diff was fetched for.
88
+ await run("git", ["worktree", "add", "--detach", dir, oid], { cwd });
61
89
  const removeParent = parent;
62
90
  return {
63
91
  dir,
@@ -72,11 +100,64 @@ export class GitHubPRSource {
72
100
  },
73
101
  };
74
102
  }
75
- catch {
103
+ catch (error) {
76
104
  if (parent) {
77
105
  await rm(parent, { recursive: true, force: true }).catch(() => { });
78
106
  }
107
+ throw error;
108
+ }
109
+ }
110
+ /**
111
+ * Check the PR HEAD out into a throwaway worktree so the agents and verifier
112
+ * read the PR's versions of files (not whatever branch happens to be checked
113
+ * out), pinned to the immutable head OID. The fetch uses `refs/pull/<n>/head`,
114
+ * which the base repo hosts even for fork PRs.
115
+ *
116
+ * The worktree is SCRUBBED of ambient runtime config (opencode.json, .opencode
117
+ * plugins, AGENTS.md/CLAUDE.md, .mcp.json, .env*, …) before it's returned: the
118
+ * OpenCode server is started with this directory as its project root, and
119
+ * anything it discovers there is attacker-controlled PR content executing or
120
+ * injecting inside a process that holds the model credential and GH_TOKEN.
121
+ *
122
+ * Returns null only when no owner/repo is configured (a local `--pr` run
123
+ * without --repo, where the current checkout is an acceptable read root).
124
+ * Materialization FAILURES throw — the caller decides per mode whether that is
125
+ * fatal (CI: fail closed) or a soft fallback (local: the user's own checkout).
126
+ */
127
+ async prepareReadRootAsync() {
128
+ if (!this.options.repo) {
129
+ // Without an explicit owner/repo we can't build the fetch URL safely.
79
130
  return null;
80
131
  }
132
+ const metadata = await this.getMetadata();
133
+ if (!isCommitOid(metadata.headOid)) {
134
+ throw new Error("GitHub did not report an immutable head OID for this PR");
135
+ }
136
+ const root = await this.materializeWorktreeAsync(`refs/pull/${this.options.prNumber}/head`, metadata.headOid);
137
+ try {
138
+ await scrubAmbientRuntimeConfig(root.dir);
139
+ }
140
+ catch (error) {
141
+ // A half-scrubbed tree must never become the runtime's project root.
142
+ await root.cleanup().catch(() => { });
143
+ throw error;
144
+ }
145
+ return root;
146
+ }
147
+ /**
148
+ * Materialize the PR's BASE commit as the trusted configuration root: review
149
+ * policy, prompts, routing, and auth mapping load from here, so a PR cannot
150
+ * change the reviewer that evaluates it (config changes activate on merge).
151
+ * Failures throw — `ecr ci` must fail closed, never fall back to the checkout.
152
+ */
153
+ async prepareTrustedConfigRootAsync() {
154
+ const metadata = await this.getMetadata();
155
+ if (!isCommitOid(metadata.baseOid)) {
156
+ throw new Error("GitHub did not report an immutable base OID for this PR");
157
+ }
158
+ // The base OID is fetchable by SHA (it's the tip of the base branch as of the
159
+ // API call; GitHub serves reachable SHAs — the same mechanism actions/checkout
160
+ // uses for `ref:` pins).
161
+ return this.materializeWorktreeAsync(metadata.baseOid, metadata.baseOid);
81
162
  }
82
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -85,12 +85,18 @@ jobs:
85
85
  # config, and never `gh pr checkout` the PR head. The reviewer engine itself
86
86
  # is the PUBLISHED @expo/code-review-cli (fetched by npx), not built from any
87
87
  # checkout, so attacker-controlled PR code never runs here. The diff + PR
88
- # metadata come from the API (`gh pr diff`/`gh pr view`).
88
+ # metadata come from the API (`gh pr diff`/`gh pr view`); `ecr ci` loads
89
+ # configuration from the PR's immutable base commit and reads source from a
90
+ # head worktree scrubbed of ambient runtime config (opencode.json, plugins,
91
+ # AGENTS.md, .env, …) — the same trust model as the pull_request workflow.
89
92
  - name: Checkout (base ref only — never the PR head)
90
93
  if: steps.cmd.outputs.run == 'true'
91
94
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
92
95
  with:
93
96
  fetch-depth: 1
97
+ # The CLI's own git fetches authenticate through `gh` from GH_TOKEN, so
98
+ # the token never lands in .git/config.
99
+ persist-credentials: false
94
100
 
95
101
  - name: Set up Node
96
102
  if: steps.cmd.outputs.run == 'true'
@@ -83,6 +83,9 @@ jobs:
83
83
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
84
84
  with:
85
85
  fetch-depth: 1
86
+ # Dismiss never fetches; gh authenticates from GH_TOKEN. Keep the token
87
+ # out of .git/config.
88
+ persist-credentials: false
86
89
 
87
90
  - name: Set up Node
88
91
  if: steps.cmd.outputs.run == 'true'
@@ -39,10 +39,21 @@ jobs:
39
39
  # A reviewer failure must never fail the PR's checks.
40
40
  continue-on-error: true
41
41
  steps:
42
+ # SECURITY: check out the PR's immutable BASE commit, never the PR head or
43
+ # merge ref. Everything security-sensitive on this runner (`ecr verify-config`'s
44
+ # sweep, and any ambient files) therefore comes from a commit that already
45
+ # merged. `ecr ci` additionally enforces this itself: it materializes the base
46
+ # commit via the GitHub API for configuration and the head commit (scrubbed of
47
+ # runtime config) for source reads, so this checkout is defense in depth, not
48
+ # the only line. persist-credentials off — the CLI's own git fetches
49
+ # authenticate through `gh` from GH_TOKEN, so the token never lands in
50
+ # .git/config.
42
51
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
43
52
  with:
53
+ ref: ${{ github.event.pull_request.base.sha }}
44
54
  # Shallow is enough — the reviewer gets the diff from the API (`gh`).
45
55
  fetch-depth: 1
56
+ persist-credentials: false
46
57
 
47
58
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
48
59
  with:
@@ -52,16 +63,17 @@ jobs:
52
63
  # to save an empty cache and error).
53
64
  package-manager-cache: false
54
65
 
55
- # SECURITY: this workflow checks out the PR's code, including every
66
+ # SECURITY: the TRUSTED BASE checkout above includes every
56
67
  # .expo-code-review/config.jsonc + routing.jsonc, whose auth.tokenEnv names the
57
68
  # env var the CLI forwards as the model credential. The canonical guard ships
58
69
  # with the CLI: `ecr verify-config` sweeps every config (root + routing + all
59
70
  # scopes, referenced or not) with the engine's real JSONC parser and refuses
60
71
  # unless tokenEnv appears exactly once, in a ROOT-owned file, equal to the
61
- # expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a PR can't repoint it at
62
- # another runner secret, sneak in a JSON-escaped key, or stage an unreferenced
63
- # scope config with its own auth. This is layer 2; layer 1 is the runtime
64
- # ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift fails safe.
72
+ # expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a config change can't
73
+ # repoint it at another runner secret, sneak in a JSON-escaped key, or stage an
74
+ # unreferenced scope config with its own auth. This is layer 2; layer 1 is the
75
+ # runtime ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift
76
+ # fails safe.
65
77
  #
66
78
  # This step MUST run BEFORE `ecr ci` (before any PR code is built or loaded).
67
79
  # Only setup-node (runtime install) precedes it; running the PUBLISHED package