@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 +25 -10
- package/build/commands/ci.js +149 -32
- package/build/commands/review.js +5 -3
- package/build/config/load.js +15 -0
- package/build/core/review.js +54 -12
- package/build/core/scrub.js +62 -0
- package/build/sources/github-pr.js +99 -18
- package/package.json +1 -1
- package/templates/command.yml +7 -1
- package/templates/dismiss.yml +3 -0
- package/templates/workflow.yml +17 -5
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
|
-
- **
|
|
222
|
-
|
|
223
|
-
and
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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`
|
package/build/commands/ci.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import
|
|
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).
|
|
46
|
-
|
|
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
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 (
|
|
109
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
//
|
|
204
|
-
|
|
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
|
|
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 {
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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,
|
package/build/commands/review.js
CHANGED
|
@@ -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:
|
|
42
|
-
|
|
43
|
-
|
|
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
|
`;
|
package/build/config/load.js
CHANGED
|
@@ -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
|
package/build/core/review.js
CHANGED
|
@@ -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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
48
|
-
const cwd = this.options.cwd;
|
|
71
|
+
async materializeWorktreeAsync(ref, oid) {
|
|
49
72
|
if (!this.options.repo) {
|
|
50
|
-
|
|
51
|
-
|
|
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-
|
|
59
|
-
const dir = path.join(parent, "
|
|
60
|
-
|
|
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
package/templates/command.yml
CHANGED
|
@@ -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'
|
package/templates/dismiss.yml
CHANGED
|
@@ -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'
|
package/templates/workflow.yml
CHANGED
|
@@ -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:
|
|
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
|
|
62
|
-
# another runner secret, sneak in a JSON-escaped key, or stage an
|
|
63
|
-
# scope config with its own auth. This is layer 2; layer 1 is the
|
|
64
|
-
# ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift
|
|
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
|