@expo/code-review-cli 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,30 @@
1
1
  import { writeFile, mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
- import { run } from "../core/exec.js";
4
+ import { resolveTrustedTool, run } from "../core/exec.js";
5
5
  import { parseUnifiedDiff } from "../core/diff.js";
6
6
  import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
7
7
  import { fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
8
  import { appendStepSummary } from "../core/step-summary.js";
9
9
  const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
10
+ /**
11
+ * The reviewer's OWN marker comments, oldest-first: carrying the marker AND authored
12
+ * by `ownLogin`. The body marker alone is not identity — it defaults to a hardcoded,
13
+ * public literal and is readable in the base-branch config, so anyone who can comment
14
+ * on the PR (the untrusted PR author included) could post a comment carrying it plus a
15
+ * forged embedded review state; a newest-marker-wins lookup would then adopt that
16
+ * state and carry its `dismissed` list forward, silently suppressing real findings.
17
+ * GitHub sets a comment's author from the authenticated identity and it cannot be
18
+ * spoofed, so matching on author closes that. When `ownLogin` is null the author
19
+ * cannot be confirmed, so NOTHING is treated as ours (fail closed). Pure; exported for
20
+ * tests.
21
+ */
22
+ export function selectOwnComments(comments, marker, ownLogin) {
23
+ if (!ownLogin) {
24
+ return [];
25
+ }
26
+ return comments.filter((comment) => comment.body?.includes(marker) && comment.user?.login === ownLogin);
27
+ }
10
28
  /**
11
29
  * Maintains exactly one PR comment, updating it in place across re-reviews (and
12
30
  * cleaning up duplicates) so the review converges instead of churning. Runs the
@@ -15,10 +33,48 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
15
33
  export class GitHubReporter {
16
34
  options;
17
35
  marker;
36
+ /** Memoized login of the account this reporter posts as (see resolveOwnLogin). */
37
+ ownLoginResolution;
18
38
  constructor(options) {
19
39
  this.options = options;
20
40
  this.marker = commentMarker(options.commentTag);
21
41
  }
42
+ /**
43
+ * The login of the account this reporter comments as, so its own comment is
44
+ * recognized by AUTHOR (see selectOwnComments for why the body marker is not enough).
45
+ * Resolution: `gh api user` (a user/PAT token), else the scaffolded workflow's default
46
+ * GITHUB_TOKEN identity, `github-actions[bot]`, when running under Actions — an
47
+ * installation token can't read `/user`. Null when neither is available, which makes
48
+ * selectOwnComments treat no comment as ours (fail closed). Memoized: the identity is
49
+ * stable for the process, and every reporter method consults it.
50
+ */
51
+ resolveOwnLogin() {
52
+ this.ownLoginResolution ??= (async () => {
53
+ try {
54
+ const gh = await resolveTrustedTool("gh");
55
+ const { stdout } = await run(gh, ["api", "user", "--jq", ".login"], {
56
+ cwd: this.options.cwd,
57
+ });
58
+ const login = stdout.trim();
59
+ if (login) {
60
+ return login;
61
+ }
62
+ }
63
+ catch {
64
+ // The default GITHUB_TOKEN is an installation token: `/user` returns 403.
65
+ }
66
+ return process.env.GITHUB_ACTIONS ? "github-actions[bot]" : null;
67
+ })();
68
+ return this.ownLoginResolution;
69
+ }
70
+ /** This reporter's own marker comments, author-verified (see selectOwnComments). */
71
+ async ownComments() {
72
+ const [comments, ownLogin] = await Promise.all([
73
+ this.fetchAllComments(),
74
+ this.resolveOwnLogin(),
75
+ ]);
76
+ return selectOwnComments(comments, this.marker, ownLogin);
77
+ }
22
78
  async checkBreakGlass() {
23
79
  const comments = await this.fetchAllComments();
24
80
  return comments.some((comment) => typeof comment.body === "string" &&
@@ -65,8 +121,9 @@ export class GitHubReporter {
65
121
  * reviewdog #1911 lesson).
66
122
  */
67
123
  async clear() {
68
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
69
- for (const comment of marked) {
124
+ // Only ever delete comments WE authored — never touch a look-alike posted by
125
+ // someone else (see selectOwnComments).
126
+ for (const comment of await this.ownComments()) {
70
127
  await this.deleteComment(comment.id);
71
128
  }
72
129
  }
@@ -88,7 +145,8 @@ export class GitHubReporter {
88
145
  await Promise.all([
89
146
  (async () => {
90
147
  try {
91
- const { stdout } = await run("gh", ["pr", "diff", ...prArgs], { cwd });
148
+ const gh = await resolveTrustedTool("gh");
149
+ const { stdout } = await run(gh, ["pr", "diff", ...prArgs], { cwd });
92
150
  link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
93
151
  }
94
152
  catch {
@@ -97,7 +155,8 @@ export class GitHubReporter {
97
155
  })(),
98
156
  (async () => {
99
157
  try {
100
- const { stdout } = await run("gh", ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
158
+ const gh = await resolveTrustedTool("gh");
159
+ const { stdout } = await run(gh, ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
101
160
  cwd,
102
161
  });
103
162
  const oid = JSON.parse(stdout).baseRefOid;
@@ -146,10 +205,10 @@ export class GitHubReporter {
146
205
  await this.patchComment(existing.id, body);
147
206
  return { dismissedCount: dismissed.length, matched, unmatched };
148
207
  }
149
- /** Newest reviewer-tagged comment (id + body), or null if none posted yet. */
208
+ /** Newest comment WE authored carrying our marker (id + body), or null if none. */
150
209
  async findExistingComment() {
151
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
152
- const keep = marked[marked.length - 1];
210
+ const own = await this.ownComments();
211
+ const keep = own[own.length - 1];
153
212
  return keep ? { id: keep.id, body: keep.body ?? "" } : null;
154
213
  }
155
214
  // Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
@@ -165,8 +224,9 @@ export class GitHubReporter {
165
224
  */
166
225
  async fetchAllComments() {
167
226
  const all = [];
227
+ const gh = await resolveTrustedTool("gh");
168
228
  for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
169
- const { stdout } = await run("gh", [
229
+ const { stdout } = await run(gh, [
170
230
  "api",
171
231
  "-X",
172
232
  "GET",
@@ -199,7 +259,10 @@ export class GitHubReporter {
199
259
  * is the newest and is the keeper.
200
260
  */
201
261
  async upsertComment(body) {
202
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
262
+ // Update/clean up only comments WE authored, never a look-alike posted by
263
+ // someone else (see selectOwnComments) — otherwise the newest forged marker
264
+ // comment would be adopted as "ours" and edited/patched in its place.
265
+ const marked = await this.ownComments();
203
266
  if (marked.length === 0) {
204
267
  await this.createComment(body);
205
268
  }
@@ -227,7 +290,8 @@ export class GitHubReporter {
227
290
  }
228
291
  }
229
292
  async createComment(body) {
230
- await this.withBodyFile(body, (jsonPath) => run("gh", [
293
+ const gh = await resolveTrustedTool("gh");
294
+ await this.withBodyFile(body, (jsonPath) => run(gh, [
231
295
  "api",
232
296
  "-X",
233
297
  "POST",
@@ -237,7 +301,8 @@ export class GitHubReporter {
237
301
  ], { cwd: this.options.cwd }));
238
302
  }
239
303
  async patchComment(commentId, body) {
240
- await this.withBodyFile(body, (jsonPath) => run("gh", [
304
+ const gh = await resolveTrustedTool("gh");
305
+ await this.withBodyFile(body, (jsonPath) => run(gh, [
241
306
  "api",
242
307
  "-X",
243
308
  "PATCH",
@@ -247,6 +312,7 @@ export class GitHubReporter {
247
312
  ], { cwd: this.options.cwd }));
248
313
  }
249
314
  async deleteComment(commentId) {
250
- await run("gh", ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
315
+ const gh = await resolveTrustedTool("gh");
316
+ await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
251
317
  }
252
318
  }
@@ -1,8 +1,21 @@
1
1
  import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
- import { run } from "../core/exec.js";
4
+ import { resolveTrustedTool, run } from "../core/exec.js";
5
5
  import { parseUnifiedDiff } from "../core/diff.js";
6
+ import { removeEscapingSymlinks, 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,30 @@ 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() {
19
- const { stdout } = await run("gh", [
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() {
41
+ const gh = await resolveTrustedTool("gh");
42
+ const { stdout } = await run(gh, [
20
43
  "pr",
21
44
  "view",
22
45
  String(this.options.prNumber),
23
46
  ...this.repoArgs(),
24
47
  "--json",
25
- "title,body,baseRefName,headRefName",
48
+ // baseRefOid/headRefOid are the immutable commit OIDs backing this PR at
49
+ // this moment; every materialization below pins to them so a rename,
50
+ // force-push, or deleted head between API calls can't swap a tree.
51
+ "title,body,baseRefName,headRefName,baseRefOid,headRefOid",
26
52
  ], { cwd: this.options.cwd });
27
53
  const parsed = JSON.parse(stdout);
28
54
  return {
@@ -30,40 +56,45 @@ export class GitHubPRSource {
30
56
  body: parsed.body ?? "",
31
57
  baseRef: parsed.baseRefName ?? "",
32
58
  headRef: parsed.headRefName ?? "",
59
+ baseOid: isCommitOid(parsed.baseRefOid) ? parsed.baseRefOid.toLowerCase() : undefined,
60
+ headOid: isCommitOid(parsed.headRefOid) ? parsed.headRefOid.toLowerCase() : undefined,
33
61
  };
34
62
  }
35
63
  async getChangedFiles() {
36
- const { stdout } = await run("gh", ["pr", "diff", String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
64
+ const gh = await resolveTrustedTool("gh");
65
+ const { stdout } = await run(gh, ["pr", "diff", String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
37
66
  return parseUnifiedDiff(stdout);
38
67
  }
39
68
  /**
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.
69
+ * Fetch `ref` from the repo's own HTTPS URL and materialize `oid` as a detached
70
+ * throwaway worktree under a fresh temp dir. Never falls back to a branch name:
71
+ * `oid` is validated as a full commit hash before reaching git.
46
72
  */
47
- async prepareReadRootAsync() {
48
- const cwd = this.options.cwd;
73
+ async materializeWorktreeAsync(ref, oid) {
49
74
  if (!this.options.repo) {
50
- // Without an explicit owner/repo we can't build the fetch URL safely.
51
- return null;
75
+ throw new Error("cannot materialize a PR tree without an explicit owner/repo");
76
+ }
77
+ if (!isCommitOid(oid)) {
78
+ throw new Error(`refusing to materialize a non-OID ref: "${oid}"`);
52
79
  }
80
+ const cwd = this.options.cwd;
53
81
  const url = `https://github.com/${this.options.repo}.git`;
54
- const ref = `refs/pull/${this.options.prNumber}/head`;
82
+ const gitPath = await resolveTrustedTool("git");
55
83
  let parent;
56
84
  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 });
85
+ await run(gitPath, [...GH_CREDENTIAL_HELPER_ARGS, "fetch", "--no-tags", "--depth=1", url, ref], { cwd });
86
+ parent = await mkdtemp(path.join(tmpdir(), "ecr-tree-"));
87
+ const dir = path.join(parent, "tree"); // must not pre-exist for `worktree add`
88
+ // Check out the OID (not FETCH_HEAD): if the ref moved between the API call
89
+ // and this fetch, the OID is absent and this fails instead of silently
90
+ // materializing a different tree than the one the diff was fetched for.
91
+ await run(gitPath, ["worktree", "add", "--detach", dir, oid], { cwd });
61
92
  const removeParent = parent;
62
93
  return {
63
94
  dir,
64
95
  cleanup: async () => {
65
96
  try {
66
- await run("git", ["worktree", "remove", "--force", dir], { cwd });
97
+ await run(gitPath, ["worktree", "remove", "--force", dir], { cwd });
67
98
  }
68
99
  catch {
69
100
  // best effort — fall through to removing the temp dir
@@ -72,11 +103,68 @@ export class GitHubPRSource {
72
103
  },
73
104
  };
74
105
  }
75
- catch {
106
+ catch (error) {
76
107
  if (parent) {
77
108
  await rm(parent, { recursive: true, force: true }).catch(() => { });
78
109
  }
110
+ throw error;
111
+ }
112
+ }
113
+ /**
114
+ * Check the PR HEAD out into a throwaway worktree so the agents and verifier
115
+ * read the PR's versions of files (not whatever branch happens to be checked
116
+ * out), pinned to the immutable head OID. The fetch uses `refs/pull/<n>/head`,
117
+ * which the base repo hosts even for fork PRs.
118
+ *
119
+ * The worktree is SCRUBBED of ambient runtime config (opencode.json, .opencode
120
+ * plugins, AGENTS.md/CLAUDE.md, .mcp.json, .env*, …) before it's returned: the
121
+ * OpenCode server is started with this directory as its project root, and
122
+ * anything it discovers there is attacker-controlled PR content executing or
123
+ * injecting inside a process that holds the model credential and GH_TOKEN.
124
+ * Out-of-tree symlinks are stripped in the same pass: read tools are scoped by
125
+ * the literal path argument but follow symlinks underneath, so a PR-committed
126
+ * link escaping the tree would otherwise read arbitrary host files.
127
+ *
128
+ * Returns null only when no owner/repo is configured (a local `--pr` run
129
+ * without --repo, where the current checkout is an acceptable read root).
130
+ * Materialization FAILURES throw — the caller decides per mode whether that is
131
+ * fatal (CI: fail closed) or a soft fallback (local: the user's own checkout).
132
+ */
133
+ async prepareReadRootAsync() {
134
+ if (!this.options.repo) {
135
+ // Without an explicit owner/repo we can't build the fetch URL safely.
79
136
  return null;
80
137
  }
138
+ const metadata = await this.getMetadata();
139
+ if (!isCommitOid(metadata.headOid)) {
140
+ throw new Error("GitHub did not report an immutable head OID for this PR");
141
+ }
142
+ const root = await this.materializeWorktreeAsync(`refs/pull/${this.options.prNumber}/head`, metadata.headOid);
143
+ try {
144
+ await scrubAmbientRuntimeConfig(root.dir);
145
+ await removeEscapingSymlinks(root.dir);
146
+ }
147
+ catch (error) {
148
+ // A half-scrubbed tree must never become the runtime's project root.
149
+ await root.cleanup().catch(() => { });
150
+ throw error;
151
+ }
152
+ return root;
153
+ }
154
+ /**
155
+ * Materialize the PR's BASE commit as the trusted configuration root: review
156
+ * policy, prompts, routing, and auth mapping load from here, so a PR cannot
157
+ * change the reviewer that evaluates it (config changes activate on merge).
158
+ * Failures throw — `ecr ci` must fail closed, never fall back to the checkout.
159
+ */
160
+ async prepareTrustedConfigRootAsync() {
161
+ const metadata = await this.getMetadata();
162
+ if (!isCommitOid(metadata.baseOid)) {
163
+ throw new Error("GitHub did not report an immutable base OID for this PR");
164
+ }
165
+ // The base OID is fetchable by SHA (it's the tip of the base branch as of the
166
+ // API call; GitHub serves reachable SHAs — the same mechanism actions/checkout
167
+ // uses for `ref:` pins).
168
+ return this.materializeWorktreeAsync(metadata.baseOid, metadata.baseOid);
81
169
  }
82
170
  }
@@ -1,4 +1,4 @@
1
- import { git, run } from "../core/exec.js";
1
+ import { git, resolveTrustedTool, run } from "../core/exec.js";
2
2
  import { parseUnifiedDiff } from "../core/diff.js";
3
3
  /**
4
4
  * Reads local git state. No network calls. Default compares the working tree
@@ -94,7 +94,8 @@ export class LocalGitSource {
94
94
  const chunks = [];
95
95
  for (const file of files) {
96
96
  // `--` so a filename beginning with `-` can't be read as a git option.
97
- const { stdout } = await run("git", ["diff", "--no-index", "--", "/dev/null", file], {
97
+ const gitPath = await resolveTrustedTool("git");
98
+ const { stdout } = await run(gitPath, ["diff", "--no-index", "--", "/dev/null", file], {
98
99
  cwd: this.cwd,
99
100
  check: false,
100
101
  });
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.7.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": {
@@ -28,8 +28,8 @@
28
28
  "clean": "rimraf build",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "lint": "oxlint src",
31
- "fmt": "oxfmt src",
32
- "fmt:check": "oxfmt --check src",
31
+ "fmt": "oxfmt --threads=1 src",
32
+ "fmt:check": "oxfmt --threads=1 --check src",
33
33
  "dev": "bun run src/cli.ts",
34
34
  "test:unit": "bun test",
35
35
  "release": "bash scripts/release.sh",
@@ -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'
@@ -49,9 +49,9 @@
49
49
  // store the key as a repo secret and pass it under that env var
50
50
  // (the scaffolded workflow does). If you omit `auth` entirely,
51
51
  // OpenCode's own login / ambient provider env vars are used.
52
- // For Anthropic/Claude, set provider "anthropic", tokenEnv "ANTHROPIC_API_KEY",
53
- // and an anthropic/... model above. For another provider, omit `auth` and set
54
- // REVIEWER_MODEL after an `opencode auth login` for that provider.
52
+ // For Anthropic/Claude, see the dedicated block below (it now runs through the
53
+ // Claude Code CLI). For another provider, omit `auth` and set REVIEWER_MODEL
54
+ // after an `opencode auth login` for that provider.
55
55
  //
56
56
  // MIXED setup (a ChatGPT/Codex subscription for the default models, plus a
57
57
  // metered API key for pro-tier models the subscription doesn't offer): use the
@@ -65,6 +65,24 @@
65
65
  // (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
66
66
  // ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
67
67
  // token: it is single-use and dies on first rotation.)
68
+ //
69
+ // Anthropic runs through the Claude Code CLI, inferred from the model — set your
70
+ // models to "anthropic/…" (e.g. anthropic/claude-opus-5, anthropic/claude-sonnet-5)
71
+ // and run `claude setup-token`, exporting the token as CLAUDE_CODE_OAUTH_TOKEN (CI)
72
+ // or rely on your local `claude` login. An auth entry is OPTIONAL (tokenEnv just
73
+ // names the credential env); `ecr setup-auth` walks you through it.
74
+ // "auth": { "providers": {
75
+ // "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
76
+ // } }
77
+ //
78
+ // MIXING engines is supported: the engine is inferred per agent from its model, so
79
+ // an anthropic entry may coexist with an openai (or any other) OpenCode provider.
80
+ // Each agent's `model` selects its engine — an `anthropic/…` agent runs through the
81
+ // Claude Code CLI while an `openai/…` agent runs through OpenCode, in the SAME run.
82
+ // "auth": { "providers": {
83
+ // "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" },
84
+ // "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" }
85
+ // } }
68
86
  "auth": {
69
87
  "mode": "api-key",
70
88
  "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'
@@ -63,6 +63,34 @@ firehose. When in doubt, stay silent.
63
63
  **For now, report only `critical` and `warning` findings. Do not emit
64
64
  `suggestion`-level items at all.**
65
65
 
66
+ ## Write findings in Simplified Technical English
67
+
68
+ Your findings are read by engineers in many countries. Many of them do not speak
69
+ English as a first language. Write every piece of prose you emit — `title`,
70
+ `rationale`, `suggestion` — under the ASD-STE100 Simplified Technical English
71
+ rules:
72
+
73
+ - **One word, one meaning.** Choose one term for a thing and reuse it. Do not
74
+ alternate between synonyms for the same object ("the handler" / "the callback"
75
+ / "the hook").
76
+ - **Short sentences.** Use 20 words or fewer. Split a long sentence into two.
77
+ - **Active voice.** Write "the parser drops the flag", not "the flag is dropped
78
+ by the parser". Name the actor.
79
+ - **Plain words.** Write "use", not "utilize"; "before", not "prior to";
80
+ "because", not "due to the fact that". Remove hedges ("arguably", "it seems
81
+ that") and intensifiers ("very", "extremely").
82
+ - **One topic per paragraph.** Keep paragraphs short.
83
+ - **No idiom, metaphor, or sarcasm.** State what happens.
84
+
85
+ This rule is about prose only. `evidence` and any code you quote are copied
86
+ verbatim and are never rewritten to fit these rules. Identifiers, file paths,
87
+ error strings, and the `severity`/`category` values also stay exactly as they
88
+ are.
89
+
90
+ Simple language must not cost precision. Keep the concrete failure path, the
91
+ condition that triggers it, and the names of the affected code. Short sentences
92
+ are a way to say the same thing, not a way to say less.
93
+
66
94
  ## Output contract
67
95
 
68
96
  Return **only** a single fenced ```json code block, an object of this shape:
@@ -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