@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.
@@ -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.1",
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'
@@ -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": "CODEX_OAUTH_REFRESH_TOKEN" },
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 REFRESH token from an `opencode auth login`
63
- // ChatGPT sign-in — copy `.openai.refresh` from OpenCode's auth.json.)
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",
@@ -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