@expo/code-review-cli 0.5.1 → 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 +46 -19
- package/build/commands/ci.js +149 -32
- package/build/commands/review.js +5 -3
- package/build/commands/setup-auth.js +34 -17
- package/build/config/load.js +15 -0
- package/build/config/schema.js +8 -3
- package/build/core/auth.js +56 -6
- package/build/core/opencode.js +52 -9
- package/build/core/render.js +2 -2
- package/build/core/review.js +83 -14
- package/build/core/schema.js +8 -0
- package/build/core/scrub.js +62 -0
- package/build/core/throttle.js +94 -0
- package/build/sources/github-pr.js +99 -18
- package/package.json +1 -1
- package/templates/command.yml +7 -1
- package/templates/config.jsonc +7 -3
- package/templates/dismiss.yml +3 -0
- package/templates/workflow.yml +17 -5
|
@@ -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/config.jsonc
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
// Large diffs are split into focused chunks by changed-line count, plus a
|
|
24
24
|
// cross-cutting pass for multi-file issues. Diffs under maxChangedLines are one
|
|
25
25
|
// full-context pass. Defaults shown; raise/lower per your model + PR sizes.
|
|
26
|
+
// Concurrency defaults by auth mode: 6 with an API key, 3 on a subscription
|
|
27
|
+
// (oauth) credential — one account handles many parallel streams poorly, and
|
|
28
|
+
// several PRs may review on the same credential at once. Set it to override.
|
|
26
29
|
// "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 4 },
|
|
27
30
|
|
|
28
31
|
// Which PRs `ecr ci` reviews. This is the source of truth for trigger policy;
|
|
@@ -56,11 +59,12 @@
|
|
|
56
59
|
// of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
|
|
57
60
|
// workflow to the comma-separated set of both env names.
|
|
58
61
|
// "auth": { "providers": {
|
|
59
|
-
// "openai": { "mode": "oauth", "tokenEnv": "
|
|
62
|
+
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
60
63
|
// "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
61
64
|
// } }
|
|
62
|
-
// (openai oauth: tokenEnv holds the
|
|
63
|
-
// ChatGPT sign-in —
|
|
65
|
+
// (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
|
|
66
|
+
// ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
|
|
67
|
+
// token: it is single-use and dies on first rotation.)
|
|
64
68
|
"auth": {
|
|
65
69
|
"mode": "api-key",
|
|
66
70
|
"provider": "openai",
|
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
|