Compare commits

...
Author SHA1 Message Date
Philippe Loulidi bc128c837a Reference claude-opus-5 in model examples and test fixtures
Opus 5 is out at the same price as Opus 4.8 ($5/$25 per MTok); update the
config example comments and the test fixtures that model the current
Anthropic catalog. Historical incident comments and the ROADMAP changelog
keep their claude-opus-4-8 mentions — they describe what happened at the
time.
2026-07-27 22:28:02 -07:00
Brent Vatne 601b19afc1 Trusted base-commit configuration by default; isolate the runtime from PR-head config
ecr ci now loads all review configuration (config.jsonc, routing, prompts,
models, auth mapping) from the PR's immutable base commit, materialized via
the GitHub API — never from the PR head — failing closed with one terminal
comment when the base can't be materialized. The PR head is materialized
separately (pinned to its immutable OID, mandatory in CI) purely as source
content, and is scrubbed of ambient runtime config (opencode.json{,c},
.opencode/ plugins, AGENTS.md, CLAUDE.md, .claude/, .mcp.json, .cursor*,
.env*) before the OpenCode server starts, closing a fork-reachable code
execution path through the /review command workflow.

- git fetches append `-c credential.helper=!gh auth git-credential` so
  base-SHA checkouts with persist-credentials:false still fetch private repos
  (token never in argv or .git/config)
- run log + patch workspace anchor at the workspace (runsDir), never the
  temporary trusted root, so the artifact upload path keeps resolving
- a scope config that is new in a PR reviews with the root config and
  activates after merge (hasScopeConfig fallback)
- scaffolded workflows check out the base SHA with persist-credentials:false;
  the repo's own self-review workflow runs the engine from base source
- escape hatch: ecr ci --unsafe-config-from-head (loud warning, never
  scaffolded, removal scheduled)
2026-07-26 22:29:18 -07:00
Brent Vatne 4990b2f5a0 docs: plan trusted review configuration 2026-07-26 22:07:52 -07:00
19 changed files with 1085 additions and 141 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
// Add or remove files to change the roster — no list needed here.
// shared.md (prepended to every agent + coordinator) and coordinator.md are
// reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
// e.g. `---\nmodel: anthropic/claude-opus-4-1\n---`.
// e.g. `---\nmodel: anthropic/claude-opus-5\n---`.
"policy": {
// Phase 1: keep signal high by surfacing only critical/warning.
+21 -11
View File
@@ -33,33 +33,43 @@ jobs:
# A reviewer failure must never fail the PR's checks.
continue-on-error: true
steps:
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
# merge ref. This repo self-reviews from source, so the base checkout is what
# keeps a PR from rewriting the ENGINE that reviews it — engine (and config)
# changes activate after merge, exactly like the trust model `ecr ci` enforces
# for config internally. persist-credentials off — the CLI's own git fetches
# authenticate through `gh` from GH_TOKEN, so the token never lands in
# .git/config.
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.base.sha }}
# Shallow is enough — the reviewer gets the diff from the API (`gh`).
fetch-depth: 1
persist-credentials: false
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
# SECURITY: this workflow checks out the PR's code, including
# .expo-code-review/config.jsonc, whose auth.tokenEnv names the env var the CLI
# forwards as the model credential. `ecr verify-config` is the canonical guard
# (ships with the CLI): it parses every config with the real JSONC parser and
# refuses unless tokenEnv appears exactly once, in a ROOT-owned file, equal to
# the expected value (repo var ECR_EXPECTED_TOKEN_ENV) so a PR can't repoint it
# at another runner secret. This repo self-reviews from its OWN source (not the
# published package), so the guard runs from source too — same engine as `ci`.
# SECURITY: the TRUSTED BASE checkout above includes .expo-code-review/
# config.jsonc, whose auth.tokenEnv names the env var the CLI forwards as the
# model credential. `ecr verify-config` is the canonical guard (ships with the
# CLI): it parses every config with the real JSONC parser and refuses unless
# tokenEnv appears exactly once, in a ROOT-owned file, equal to the expected
# value (repo var ECR_EXPECTED_TOKEN_ENV). This repo self-reviews from BASE
# source, so the guard runs from that same base source — same engine as `ci`.
- name: Guard config tokenEnv
env:
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CODEX_OAUTH_ACCESS_TOKEN,OPENAI_API_KEY' }}
run: bun run src/cli.ts verify-config
- name: Run AI review
# True self-review: this repo IS the reviewer, so run the CLI from the PR's
# own source instead of the published npm package. `bun run` puts
# node_modules/.bin (which has the `opencode` binary) on PATH.
# True self-review: this repo IS the reviewer, so run the CLI from source —
# the trusted BASE commit's source (see the checkout above), never the PR's.
# `bun run` puts node_modules/.bin (which has the `opencode` binary) on PATH.
# `ecr ci` materializes the PR head itself (OID-pinned, scrubbed of ambient
# runtime config) for the reviewers' source reads.
run: bun run src/cli.ts ci
continue-on-error: true
env:
+328
View File
@@ -0,0 +1,328 @@
# Plan: trusted configuration by default
## Status
Proposed security hardening for the next release of `@expo/code-review-cli`.
Reviewed 2026-07-26; amendments folded in below. The two most important changes
from review:
- **Runtime ambient-config isolation (§5) leads the rollout, not trails it.**
`runReview` chdirs into a PR-head worktree and then spawns the OpenCode server
with no `cwd`, and the pinned OpenCode binary reads project-directory config
(`opencode.json{,c}`, `.opencode/plugin`, `AGENTS.md`, `CLAUDE.md`, `.env`).
Because `issue_comment` (`/review`) is not fork-restricted, this is reachable
by **fork** PRs with full secrets in the process — a wider threat model than
the same-repo config swap the rest of this plan addresses.
- **Base-SHA checkout without mandatory head materialization is a silent
correctness regression.** `prepareReadRootAsync` fails soft to the current
checkout; once the checkout is base, that fallback makes agents and the
verifier read pre-PR file contents and silently drop real findings. Head
materialization must fail closed in CI in the same release as the template
flip.
The immediate workflow mitigation is straightforward: check out the pull
request's base SHA before invoking `ecr ci`. Version 0.5.2 already loads review
configuration before materializing the PR head for source reads, so a correctly
written workflow can keep configuration trusted while still reviewing the PR's
actual files.
That is not a sufficient product default. A security property this important
must be enforced by the CLI rather than depending on every adopter writing the
same workflow correctly.
## Problem
In a `pull_request` workflow, the default checkout is the PR merge ref. ECR then
loads the following security-sensitive inputs from that checkout:
- root and routed `config.jsonc` files;
- provider, auth mode, `tokenEnv`, model, budget, and trigger policy;
- reviewer, shared, and coordinator prompts;
- routing, enforced agents, break-glass policy, and comment tags.
A same-repository PR can therefore change the reviewer that evaluates that PR
while the process has a model credential and a comment-capable GitHub token.
`ECR_EXPECTED_TOKEN_ENV` and `ecr verify-config` constrain credential names, but
they do not make the rest of a PR-controlled configuration trustworthy.
## Security invariant
For a GitHub PR review:
> Review policy and executable reviewer configuration come from a trusted base
> commit or an explicit operator-owned directory. The PR head is untrusted data
> used only for its diff and source contents.
This includes every root and scope config, all prompt markdown, routing, model
selection, auth/provider mapping, break-glass behavior, and comment identity.
A config change in a PR becomes active only after it merges.
Failure to resolve or materialize the trusted config must fail closed. CI must
not silently fall back to the current checkout.
## Desired default behavior
1. `ecr ci` resolves the PR's immutable base and head commit OIDs through the
GitHub API.
2. It materializes the base commit into a temporary trusted-config root.
3. It loads and validates all ECR configuration and prompts from that root.
4. It fetches the diff through GitHub and materializes the PR head separately
for source reads and finding verification.
5. It starts the model runtime without loading ambient configuration from the
untrusted PR-head directory.
6. It removes both temporary trees on every success and failure path.
An explicit operator-owned `--config-dir` / `ECR_CONFIG_DIR` remains supported
and takes precedence. In CI, a relative override is resolved beneath the trusted
base root; an absolute override must be explicitly allowed and documented as an
operator trust decision.
## Implementation
### 1. Immediate workflow containment
Update all scaffolded auto-review workflows to:
```yaml
- uses: actions/checkout@<full-sha> # pinned release
with:
ref: ${{ github.event.pull_request.base.sha }}
fetch-depth: 1
persist-credentials: false
```
Keep the published ECR package pinned to an exact version. Run
`ecr verify-config` against this trusted checkout before `ecr ci`.
The command workflow already uses the base ref; align comments and tests so the
auto and command workflows state the same trust model. `dismiss.yml` also has a
bare checkout — give it `persist-credentials: false` too.
**Amendment — `persist-credentials: false` breaks the CLI's own fetches on
private repos.** `prepareReadRootAsync` runs `git fetch <https-url>` and relies
today on the `extraheader` credential `actions/checkout` persists. Ship this
only together with the CLI appending `-c credential.helper=!gh auth
git-credential` to its fetches (token read from `GH_TOKEN` env; never argv,
never `.git/config`). Existing helpers stay first, so local behavior is
unchanged.
**Amendment — this template flip must not precede mandatory head
materialization** (see §5a): with a base checkout, the soft fallback in
`prepareReadRootAsync` silently reviews/verifies pre-PR file contents.
### 2. Resolve immutable PR refs
Extend `GitHubPRSource.getMetadata()` to request immutable base/head OIDs in
addition to names. Treat missing OIDs, a repository mismatch, or an unexpected
base repository as a setup error.
Add a small ref type rather than passing branch names through security-sensitive
code:
```ts
interface PullRequestRefs {
base: { repo: string; oid: string };
head: { repo: string; oid: string };
}
```
Use argument arrays for every `git`/`gh` call and validate OIDs as full
hexadecimal commit hashes before passing them to Git.
**Amendment:** `runRoutedCi` already fetches `baseRefOid` for link context via a
second `gh pr view` — fold OID resolution into one metadata call rather than
adding another. The head worktree should also check out the resolved `headRefOid`
(detached), not `FETCH_HEAD`, so a force-push between diff fetch and
materialization can't swap the reviewed tree.
### 3. Materialize a trusted config root
Add a `prepareTrustedConfigRootAsync()` path to the GitHub PR source:
- fetch the base OID from the base repository's HTTPS URL without credentials
embedded in Git configuration;
- create a detached temporary worktree or archive;
- return its directory plus an idempotent cleanup callback;
- never fall back to `process.cwd()` in CI.
Local review modes may retain their current checkout behavior because the user
is the trust principal. `ecr ci`, and `review --pr` when posting with a token,
should default to base-ref trust.
### 4. Separate config root from source root
Change config loading APIs to receive an explicit `configRoot` instead of
implicitly reading from `cwd`. This must cover:
- `loadReviewConfig`;
- `loadRoutingManifest`;
- `loadScopeConfig`;
- agent discovery and frontmatter;
- `shared.md` and coordinator prompt loading;
- `verify-config`;
- run-log and comment-tag initialization.
Continue passing a distinct PR-head `sourceRoot` to the read/grep and finding
verification paths. Do not use `process.chdir()` as the mechanism that decides
which tree is trusted.
**Amendment — `runtimeRoot` is a first-class third root, not a test detail.**
The run log and patch workspace are derived from `config.configDir`
(`review.ts`: `path.join(config.configDir, ".runs")`), and both workflow
templates upload `.expo-code-review/.runs/reviews.jsonl` from the workspace.
Once `configDir` points into a temporary trusted root that is removed on every
exit path, the log dies with it. Anchor `.runs/` at the workspace checkout
explicitly, independent of where config was loaded from.
**Amendment — scope configs absent from base need a defined miss behavior.**
A PR that introduces a new package together with its scope config would
otherwise fail closed on exactly that PR (the scope's config dir doesn't exist
at the base commit). Fall back to the root config for such scopes, warn in the
job log, and let the scope config activate after merge.
### 5. Isolate the model runtime from PR-owned ambient configuration
**Amendment — this is the top-severity item and ships first** (see Status).
Verified during review: the OpenCode SDK spawns `opencode serve` with no `cwd`
(inheriting the PR-head worktree ECR chdir'd into), `OPENCODE_CONFIG_CONTENT`
merges with — does not replace — project config, and the pinned binary contains
project-discovery references to `opencode.json{,c}`, `.opencode/plugin`,
`AGENTS.md`, `CLAUDE.md`, and `.env*`. A PR-head `.env` alone could redirect a
provider base URL. Reachable by fork PRs via the `/review` command workflow.
**Amendment — a cheap first cut exists: scrub the worktree.** The head worktree
is a throwaway copy, so delete ambient runtime config from it (at every depth)
before the server starts: `opencode.json{,c}`, `.opencode/`, `AGENTS.md`,
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, `.env*`. Source reads keep
working; no OpenCode feature is required. Known tradeoffs: the reviewer no
longer sees the PR's file version of those files (their diffs are still inlined
in the prompt), and a finding citing a scrubbed file will fail verification.
The full empty-runtime-dir isolation below remains the end state.
### 5a. Make head materialization mandatory in CI
`prepareReadRootAsync` fails soft (returns null → review the current checkout).
Acceptable while the checkout is the merge ref; a silent correctness and trust
regression once the checkout is base. In CI mode a failed head materialization
must fail the run closed (one terminal comment), and the fetch must use the
immutable head OID. Local `ecr review` keeps the soft fallback — the user is
the trust principal there.
The target behavior is:
- start the model runtime from an empty ECR-owned runtime directory;
- provide explicit read-only access to the PR-head source root;
- pass ECR's trusted prompts and generated OpenCode configuration explicitly;
- disable project plugin, hook, MCP, and auto-discovery paths that are not part
of the trusted ECR configuration;
- permit writes only to ECR-owned run directories.
If OpenCode cannot provide those guarantees, implement ECR-owned read/grep tools
over `sourceRoot` and do not expose the PR tree as the runtime working directory.
### 6. Make verification trust-aware
`ecr verify-config` should accept the same resolved trusted config root as
`ecr ci`. In GitHub CI mode it should report both refs:
```text
config: <base-repo>@<base-oid>
source: <head-repo>@<head-oid>
```
The existing repo-wide sweep and `ECR_EXPECTED_TOKEN_ENV` lock remain useful
defense in depth, but they apply to the trusted tree. PR-head ECR config files
may be reported as pending configuration changes; they must not affect the
current run.
### 7. CLI and compatibility surface
Prefer a safe default with a conspicuous escape hatch:
- `ecr ci`: trusted base config, always;
- `ecr review --pr`: trusted base config by default;
- local diff/staged review: current checkout config;
- `--config-dir`: explicit operator-owned override;
- optional `--unsafe-config-from-head`: temporary compatibility escape hatch,
rejected unless explicitly supplied and never scaffolded.
Do not offer an environment variable that silently changes CI back to PR-head
trust. If compatibility requires one release of opt-out behavior, print a
security warning and remove it on a scheduled major/minor boundary.
**Amendments:**
- `ECR_CONFIG_DIR` is read deep inside `resolveConfigDir` and honored
independently by `hasConfig`, `doctor`, and `verify-config`. It is not
PR-controllable (the base repo's workflow defines the env), but the "no env
var flips CI back to head trust" rule must explicitly cover it: in CI a
relative value resolves beneath the trusted base root; an absolute value is
an operator trust decision (root config + manifest only — scope subtrees
still resolve beneath the trusted base root).
- "Fail closed" means **skip the review and post the one terminal comment** —
it does not mean failing the PR's checks. The non-blocking property
(`continue-on-error`, reviewer failures never block merges) is established
behavior and stays.
- `ecr review --pr` needs no trusted-root machinery: config is loaded from the
user's own checkout before the head worktree chdir, so the config principal
is already the local user, and §5's scrub covers the runtime surface. Document
this rather than building a second materialization path.
## Tests
Add end-to-end fixtures where a same-repository PR changes each of the following:
- `auth.provider`, `auth.mode`, and `auth.tokenEnv`;
- root and scope models;
- agent, shared, and coordinator prompts;
- routing and enforced-agent policy;
- trigger, break-glass, noise, budget, and comment tag;
- an unreferenced nested `.expo-code-review/config.jsonc`;
- `opencode.json`, `AGENTS.md`, `.claude/settings.json`, plugins, hooks, and MCP
definitions in the PR head.
Assert that:
- the run uses every relevant value from the base commit;
- the PR-head source contents and line numbers are still reviewed;
- no PR-owned hook, plugin, MCP server, or command executes;
- a config-only PR is reviewed with the previous config and activates only
after merge;
- missing/unfetchable base configuration fails closed with one actionable
terminal comment;
- fork PRs, renamed branches, force-pushes, and deleted heads use immutable OIDs;
- fetch URLs and `.git/config` never contain `GH_TOKEN`;
- temporary config/source/runtime directories are removed on all failure paths;
- a failed head materialization in CI never silently reviews base contents;
- the run-log artifact path (`.expo-code-review/.runs/reviews.jsonl` in the
workspace) still resolves when config comes from the trusted root.
Unit tests should make `configRoot`, `sourceRoot`, and `runtimeRoot` visibly
different directories so accidental `cwd` coupling fails immediately.
## Rollout (reordered on review)
The original order shipped containment that doesn't close the worst hole before
the isolation that does:
1. Runtime ambient-config scrub (§5) + mandatory head materialization on
immutable OIDs (§5a). CLI-only, no workflow change; closes the fork-reachable
runtime injection for every adopter on `latest`.
2. Base-SHA workflow templates (§1) + credential-helper fetches, in the same
release as (1).
3. Trusted base config root in `ecr ci` (§2–§4): explicit
`configRoot`/`sourceRoot`/`runtimeRoot` separation, scope-miss fallback,
red tests.
4. Release as a new minor; update `ecr init --with-workflow`, README security
documentation, `doctor`, and migration notes.
5. Run the release against a PR that maliciously changes every configuration
surface above before recommending adoption.
6. Follow-up (not blocking): full empty-runtime-dir isolation from §5;
trust-aware `config:`/`source:` ref reporting in `verify-config` (§6).
## Completion criteria
This work is complete when an adopter can use the scaffolded workflow—or a
minimal custom workflow calling pinned `ecr ci`—and a same-repository PR cannot
change the model credential destination, reviewer policy, prompts, tools, or
runtime extensions used to review itself.
+25 -10
View File
@@ -218,16 +218,31 @@ your-monorepo/
(e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
root roster — a scope defining a same-id agent gets the root one, so a team can't
shadow the enforced reviewer with a weaker version on its own subtree.
- **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
and routing globs for its own advisory review — that only steers what the
comment-only reviewer says about that PR. What a PR can **never** do is touch
credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
`ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
against whatever ref is checked out. A `/review`-command workflow that checks out
only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
resolving config from the base ref on the auto path as well is on the
[roadmap](./ROADMAP.md).
- **Configuration comes from the PR's trusted base commit.** In `ecr ci`, review
policy and reviewer configuration — `config.jsonc`, `routing.jsonc`, prompts,
models, and the auth mapping — load from the PR's immutable **base** commit,
materialized via the GitHub API. The PR head is untrusted data: it is
materialized separately (pinned to its immutable OID) purely as source content
to read and verify against. A PR editing rosters, prompts, or routing is
reviewed under the **previous** config; its changes activate after merge. If
the base commit can't be materialized, the run fails closed (one terminal
comment) — it never falls back to the checkout. A scope config that is new in
a PR is reviewed with the root config until it merges.
- **The model runtime never sees PR-owned ambient config.** The head worktree the
agents read from is scrubbed of runtime configuration before the OpenCode
server starts: `opencode.json{,c}`, `.opencode/` (plugins), `AGENTS.md`,
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, and `.env*` at every depth.
A PR can't install a plugin, MCP server, instruction file, or `.env` into the
process that holds the model credential and the comment token. (Changes to
those files are still reviewed — their diffs are inlined in the prompt — but a
finding citing one can't be re-read during verification; that's the tradeoff.)
- **The scaffolded workflows check out only the base commit** with
`persist-credentials: false`; the CLI's own git fetches authenticate through
`gh` from `GH_TOKEN`, so the token never lands in `.git/config` or argv. The
CLI enforces the trust model itself, so a custom workflow that checks out the
PR head still gets base-commit configuration. The temporary escape hatch
`ecr ci --unsafe-config-from-head` restores the old behavior with a loud
security warning and will be removed on a minor boundary.
Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
(the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
+12 -12
View File
@@ -385,18 +385,18 @@ Untrusted input reaches the reviewer from three places; defenses by layer:
- **Prior comments / dismissal state** — NOT fed back to the review agents (runs are
stateless), so there is no injection path there. The embedded comment state is
only ever read from the bot's own comment (GitHub write perms are the boundary).
- **The reviewer's OWN config, prompts, and code** — the sharpest vector, and the
one not fully closed. The `pull_request` auto-workflow checks out the PR *merge
ref*, so a same-repo PR can rewrite `.expo-code-review/` (agent prompts,
`config.jsonc`, `tokenEnv`) **or the CLI source itself** and it runs with secrets.
In-PR mitigations shipped: the `auth.ts` denylist refuses to forward well-known
non-provider secrets even if config names them, and the security agent is told to
treat PR-supplied config as attacker-controlled. **Structural fix (post-merge
follow-up):** have the auto-workflow build + load config from the **trusted base
ref** (the diff still comes from `gh pr diff`), exactly like the `/review` command
workflow already does — or switch to the published package via `npx` (item 9).
This can't land in *this* PR because the reviewer package isn't on `main` yet, so
the base ref has nothing to build; it becomes available the moment this merges.
- **The reviewer's OWN config, prompts, and code** — **structurally closed** (see
`PLAN-trusted-base-config.md`). `ecr ci` loads all review configuration (config,
routing, prompts, models, auth mapping) from the PR's immutable **base commit**,
materialized via the GitHub API, failing closed when it can't; the PR head is
materialized separately (OID-pinned) as source data only and is **scrubbed of
ambient runtime config** (`opencode.json{,c}`, `.opencode/` plugins, `AGENTS.md`,
`CLAUDE.md`, `.claude/`, `.mcp.json`, `.cursor*`, `.env*`) before the OpenCode
server starts, so a PR can't install a plugin/MCP/instruction file/`.env` into
the credentialed process. The scaffolded workflows check out only the base SHA
with `persist-credentials: false`, and the engine runs as the published npm
package. Earlier defense-in-depth stays: the `auth.ts` denylist, the
scope-schema auth rejection, `ECR_EXPECTED_TOKEN_ENV`, and `verify-config`.
## 4. Caching (LLM cost / quota / latency)
+4 -4
View File
@@ -192,13 +192,13 @@ test("a stall is retried exactly once, and only with budget left to land it", ()
// claude-opus-4-8" — a real config/version problem buried in per-pass noise.
const AVAILABLE = {
anthropic: ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"],
anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
openai: ["gpt-5.5"],
};
test("resolvable models produce no complaints", () => {
expect(
findUnknownModels(["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"], AVAILABLE),
findUnknownModels(["anthropic/claude-sonnet-5", "anthropic/claude-opus-5"], AVAILABLE),
).toEqual([]);
});
@@ -212,7 +212,7 @@ test("unknown model suggests near matches from that provider", () => {
const [problem] = findUnknownModels(["anthropic/claude-sonnet-9"], AVAILABLE);
expect(problem?.reason).toBe("model");
// Prefix-matched against the requested id, so the suggestions are the plausible ones.
expect(problem?.suggestions).toEqual(["claude-haiku-4-5", "claude-opus-4-8", "claude-sonnet-5"]);
expect(problem?.suggestions).toEqual(["claude-haiku-4-5", "claude-opus-5", "claude-sonnet-5"]);
});
test("a bare model id (no provider) is reported, not silently accepted", () => {
@@ -235,7 +235,7 @@ test("duplicate model ids are reported once", () => {
test("a refused credential blames the credential, not the model id", () => {
const unknown = findUnknownModels(
["anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8"],
["anthropic/claude-sonnet-5", "anthropic/claude-opus-5"],
{ openai: ["gpt-5.5"] },
"anthropic",
);
+4 -4
View File
@@ -96,7 +96,7 @@ test("loadScopeConfig: nested scope reads its own roster/prompts; auth forced fr
await writeConfigDir(path.join(root, "server", "www", CONFIG_DIRNAME), {
// No auth here; scope-specific model + noise override the root.
config:
'{ "model": "anthropic/claude-opus-4-1", "noise": { "additionalIgnores": ["www/gen/**"] } }',
'{ "model": "anthropic/claude-opus-5", "noise": { "additionalIgnores": ["www/gen/**"] } }',
agents: { style: agent("style") },
});
const manifest = manifestOf({
@@ -109,7 +109,7 @@ test("loadScopeConfig: nested scope reads its own roster/prompts; auth forced fr
const scoped = await loadScopeConfig(root, manifest.scopes[1]!, manifest, rootConfig);
expect(scoped.agents.some((a) => a.id === "style")).toBe(true);
expect(scoped.noise.additionalIgnores).toEqual(["www/gen/**"]);
expect(scoped.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-4-1");
expect(scoped.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-5");
// auth is forced from the root even though the scope config declares none.
expect(scoped.auth[0]?.tokenEnv).toBe("ROOT_TOKEN");
});
@@ -237,7 +237,7 @@ test("config-dir override composes: root config + routing.jsonc from override, s
);
// The scope subtree lives at the REPO ROOT, not under the override dir.
await writeConfigDir(path.join(root, "server", "www", CONFIG_DIRNAME), {
config: '{ "model": "anthropic/claude-opus-4-1" }',
config: '{ "model": "anthropic/claude-opus-5" }',
agents: { style: agent("style") },
});
@@ -258,7 +258,7 @@ test("config-dir override composes: root config + routing.jsonc from override, s
// override dir — the override must not relocate the scopes themselves.
const www = await loadScopeConfig(root, manifest!.scopes[1]!, manifest!, rootConfig);
expect(www.agents.some((a) => a.id === "style")).toBe(true);
expect(www.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-4-1");
expect(www.agents.find((a) => a.id === "style")!.model).toBe("anthropic/claude-opus-5");
});
test("loadReviewConfig: ECR_CONFIG_DIR loads from the alternate dir; unset → .expo-code-review (BACKCOMPAT)", async () => {
+189
View File
@@ -0,0 +1,189 @@
import { test, expect } from "bun:test";
import { mkdtemp, mkdir, writeFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { isAmbientRuntimeConfig, scrubAmbientRuntimeConfig } from "../core/scrub.js";
import { isCommitOid } from "../sources/github-pr.js";
import { hasScopeConfig } from "../config/load.js";
import { resolveReadRoot } from "../core/review.js";
import type { PreparedReadRoot, ReviewSource } from "../sources/source.js";
// ---------------------------------------------------------------------------
// scrub: ambient runtime config never reaches the model runtime's project root
// ---------------------------------------------------------------------------
const exists = async (p: string): Promise<boolean> =>
stat(p).then(
() => true,
() => false,
);
async function makeTree(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), "ecr-scrub-test-"));
const put = async (rel: string, dir = false): Promise<void> => {
const full = path.join(root, rel);
if (dir) {
await mkdir(full, { recursive: true });
} else {
await mkdir(path.dirname(full), { recursive: true });
await writeFile(full, "x", "utf8");
}
};
// Ambient runtime config at several depths (all attacker-writable in a PR).
await put("opencode.json");
await put("AGENTS.md");
await put("CLAUDE.md");
await put(".env");
await put(".env.local");
await put(".mcp.json");
await put(".cursorrules");
await put(".opencode/plugin/evil.js");
await put(".claude/settings.json");
await put(".cursor/rules/x.md");
await put("packages/app/opencode.jsonc");
await put("packages/app/AGENTS.md");
await put("packages/app/.env.production");
// Legitimate source content that must survive.
await put("src/index.ts");
await put("packages/app/src/main.ts");
await put("README.md");
await put("environment.ts"); // not a .env file
// Never descended into / never removed as a unit.
await put(".git/config");
await put("node_modules/dep/AGENTS.md");
return root;
}
test("scrubAmbientRuntimeConfig removes runtime config at every depth and keeps source", async () => {
const root = await makeTree();
const removed = await scrubAmbientRuntimeConfig(root);
for (const gone of [
"opencode.json",
"AGENTS.md",
"CLAUDE.md",
".env",
".env.local",
".mcp.json",
".cursorrules",
".opencode",
".claude",
".cursor",
"packages/app/opencode.jsonc",
"packages/app/AGENTS.md",
"packages/app/.env.production",
]) {
expect(await exists(path.join(root, gone))).toBe(false);
}
for (const kept of ["src/index.ts", "packages/app/src/main.ts", "README.md", "environment.ts"]) {
expect(await exists(path.join(root, kept))).toBe(true);
}
// .git is the worktree link back to the real repo; node_modules is skipped.
expect(await exists(path.join(root, ".git/config"))).toBe(true);
expect(await exists(path.join(root, "node_modules/dep/AGENTS.md"))).toBe(true);
// Removed paths are reported (repo-relative) so runs can log them.
expect(removed).toContain("opencode.json");
expect(removed).toContain(path.join("packages", "app", "AGENTS.md"));
expect(removed).not.toContain("README.md");
});
test("isAmbientRuntimeConfig: exact names, .env prefix family, nothing else", () => {
for (const name of [
"opencode.json",
".opencode",
"AGENTS.md",
".env",
".env.local",
".mcp.json",
]) {
expect(isAmbientRuntimeConfig(name)).toBe(true);
}
for (const name of [
"environment.ts",
"env.ts",
"opencode.json.bak",
"agents.md.txt",
"README.md",
]) {
expect(isAmbientRuntimeConfig(name)).toBe(false);
}
});
// ---------------------------------------------------------------------------
// OIDs: only full commit hashes reach security-sensitive git calls
// ---------------------------------------------------------------------------
test("isCommitOid accepts only full 40-hex commit hashes", () => {
expect(isCommitOid("a".repeat(40))).toBe(true);
expect(isCommitOid("0123456789abcdef0123456789abcdef01234567")).toBe(true);
expect(isCommitOid("0123456789ABCDEF0123456789ABCDEF01234567")).toBe(true);
expect(isCommitOid("a".repeat(39))).toBe(false); // short
expect(isCommitOid("a".repeat(41))).toBe(false); // long
expect(isCommitOid("main")).toBe(false); // branch name
expect(isCommitOid("refs/pull/1/head")).toBe(false); // ref
expect(isCommitOid("--upload-pack=evil")).toBe(false); // argument injection
expect(isCommitOid(undefined)).toBe(false);
expect(isCommitOid("")).toBe(false);
});
// ---------------------------------------------------------------------------
// resolveReadRoot: CI fails closed, local degrades softly, null is never an error
// ---------------------------------------------------------------------------
const sourceWith = (
prepare: (() => Promise<PreparedReadRoot | null>) | undefined,
): ReviewSource => ({
getMetadata: async () => ({ title: "", body: "", baseRef: "", headRef: "" }),
getChangedFiles: async () => [],
...(prepare ? { prepareReadRootAsync: prepare } : {}),
});
test("resolveReadRoot: materialization failure is fatal in ci mode", async () => {
const source = sourceWith(async () => {
throw new Error("fetch failed");
});
await expect(resolveReadRoot(source, "ci", () => {})).rejects.toThrow(
/trusted base|fetch failed/,
);
});
test("resolveReadRoot: materialization failure falls back softly in local mode", async () => {
const messages: string[] = [];
const source = sourceWith(async () => {
throw new Error("fetch failed");
});
const root = await resolveReadRoot(source, "local", (m) => messages.push(m));
expect(root).toBeNull();
expect(messages.join("\n")).toContain("fetch failed");
});
test("resolveReadRoot: null (nothing to materialize) is fine in every mode", async () => {
const source = sourceWith(async () => null);
expect(await resolveReadRoot(source, "ci", () => {})).toBeNull();
expect(await resolveReadRoot(source, "local", () => {})).toBeNull();
expect(await resolveReadRoot(sourceWith(undefined), "ci", () => {})).toBeNull();
});
test("resolveReadRoot: a materialized root passes through untouched", async () => {
const handle: PreparedReadRoot = { dir: "/tmp/x", cleanup: async () => {} };
const source = sourceWith(async () => handle);
expect(await resolveReadRoot(source, "ci", () => {})).toBe(handle);
});
// ---------------------------------------------------------------------------
// hasScopeConfig: scope-miss detection against the trusted base root
// ---------------------------------------------------------------------------
test("hasScopeConfig: default scope always exists; nested scope needs config.json(c) at the root", async () => {
const root = await mkdtemp(path.join(tmpdir(), "ecr-scope-test-"));
const scope = { name: "app", config: "packages/app", include: ["packages/app/**"] };
expect(hasScopeConfig(root, { ...scope, config: "." })).toBe(true);
expect(hasScopeConfig(root, scope)).toBe(false); // absent from base → miss
await mkdir(path.join(root, "packages/app/.expo-code-review"), { recursive: true });
expect(hasScopeConfig(root, scope)).toBe(false); // dir alone isn't a config
await writeFile(path.join(root, "packages/app/.expo-code-review/config.jsonc"), "{}", "utf8");
expect(hasScopeConfig(root, scope)).toBe(true);
});
+189 -57
View File
@@ -1,6 +1,10 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import {
CONFIG_DIRNAME,
hasScopeConfig,
loadAuthFromRoot,
loadReviewConfig,
loadScopeConfig,
@@ -22,6 +26,7 @@ import type { CoordinatorOutput } from "../core/schema.js";
import { runReview } from "../core/review.js";
import { GitHubPRSource } from "../sources/github-pr.js";
import { memoizeSource } from "../sources/source.js";
import type { PreparedReadRoot, ReviewSource } from "../sources/source.js";
import { GitHubReporter } from "../reporters/github.js";
/** Resolve the PR number from the Actions event payload or GITHUB_REF. */
@@ -53,6 +58,15 @@ For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
and non-blocking (a reviewer failure never fails the PR's checks).
Trust model: review policy and reviewer configuration (config.jsonc, routing,
prompts, models, auth mapping) load from the PR's immutable BASE commit,
materialized via the GitHub API — never from the PR head — so a PR cannot change
the reviewer that evaluates it; config changes activate after merge. The PR head
is materialized separately (pinned to its immutable OID, scrubbed of ambient
runtime config) purely as source content to read and verify against. If the
trusted base cannot be materialized, the run fails closed with one terminal
comment; it never falls back to the checkout.
Monorepos: when .expo-code-review/routing.jsonc exists, ci fans out INTERNALLY —
it assigns each changed file to exactly one scope (last-match-wins) and reviews
each active scope over only its files, then renders one aggregated comment (or one
@@ -63,8 +77,17 @@ Options:
--route Let the router pick relevant agents from the diff
--scopes <a,b> Limit the fan-out to these named scopes (routing only)
--config-dir <dir> Load the ROOT config.jsonc + routing.jsonc from <dir>
instead of .expo-code-review/ (also ECR_CONFIG_DIR). Scope
subtrees stay repo-root-relative.
instead of .expo-code-review/ (also ECR_CONFIG_DIR). A
RELATIVE dir resolves beneath the trusted base commit; an
ABSOLUTE dir is an explicit operator trust decision. Scope
subtrees always resolve beneath the trusted base commit.
--unsafe-config-from-head
COMPATIBILITY ESCAPE HATCH: load configuration from the
current checkout instead of the PR's trusted base commit.
This lets a same-repo PR change the reviewer (policy,
prompts, model, auth mapping) that evaluates itself.
Never scaffolded; prints a security warning; will be
removed on a scheduled minor boundary.
--comment <mode> Override manifest comment mode: single | per-scope
--force Manual override: review even if the trigger policy (label
trigger / ai-review:skip) would skip. Break-glass and the
@@ -99,6 +122,7 @@ export async function ciCommand(argv: string[] = []): Promise<void> {
// A maintainer's explicit `/review` (comment command or --force) is a manual
// escape hatch that bypasses the trigger-policy gate only (see passesTriggerGate).
const bypassTriggerGate = shouldBypassTriggerGate(argv);
const unsafeConfigFromHead = argv.includes("--unsafe-config-from-head");
const root = await repoRoot();
if (root && root !== process.cwd()) {
process.chdir(root);
@@ -115,37 +139,138 @@ export async function ciCommand(argv: string[] = []): Promise<void> {
return;
}
// A malformed manifest is a loud, non-blocking error (never a silent fallback).
let manifest: RoutingManifest | null;
try {
manifest = await loadRoutingManifest(cwd, { configDir });
} catch (error) {
process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
return;
}
// ONE source for the whole run: metadata (incl. immutable OIDs), the diff, and
// the PR-head read root are each fetched once and shared across scopes.
const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
const source = memoizeSource(ghSource);
if (manifest == null) {
await runLegacyCi(repo, prNumber, cwd, agents, route, bypassTriggerGate, configDir);
return;
}
try {
await runRoutedCi(
manifest,
repo,
prNumber,
cwd,
agents,
route,
scopesFilter,
commentOverride,
bypassTriggerGate,
configDir,
// Trusted configuration root: review policy and reviewer config load from the
// PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
// when the base can't be materialized, post the one terminal comment and stop —
// silently reading the checkout would let a head checkout smuggle config in.
let trustedRoot: PreparedReadRoot | null = null;
let configRoot = cwd;
if (unsafeConfigFromHead) {
process.stderr.write(
"CI reviewer: ⚠ SECURITY — --unsafe-config-from-head is set: reviewer configuration " +
"(policy, prompts, models, auth mapping) is being loaded from the current checkout, so " +
"a same-repository PR can change the reviewer that evaluates it. This escape hatch will " +
"be removed in a future minor release.\n",
);
} catch (error) {
// Fan-out failures stay non-blocking (single-writer property is the point).
process.stderr.write(`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`);
} else {
try {
trustedRoot = await ghSource.prepareTrustedConfigRootAsync();
configRoot = trustedRoot.dir;
} catch (error) {
const reason = errorMessage(error);
process.stderr.write(
`CI reviewer: could not materialize the PR's base commit for trusted configuration ` +
`(failing closed, not reviewing): ${reason}\n`,
);
await postTerminalFailureNote(
repo,
prNumber,
cwd,
`it could not load trusted configuration from the PR's base commit (${reason}). ` +
`This usually means the runner has no git checkout or no usable GH_TOKEN; re-run once fixed`,
);
return;
}
}
try {
// A malformed manifest is a loud, non-blocking error (never a silent fallback).
let manifest: RoutingManifest | null;
try {
manifest = await loadRoutingManifest(configRoot, { configDir });
} catch (error) {
process.stderr.write(`CI reviewer: invalid routing.jsonc: ${errorMessage(error)}\n`);
return;
}
if (manifest == null) {
await runLegacyCi(source, repo, prNumber, cwd, configRoot, {
agents,
route,
bypassTriggerGate,
configDir,
});
return;
}
try {
await runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, {
agents,
route,
scopesFilter,
commentOverride,
bypassTriggerGate,
configDir,
});
} catch (error) {
// Fan-out failures stay non-blocking (single-writer property is the point).
process.stderr.write(
`CI reviewer: routed run failed (non-blocking): ${errorMessage(error)}\n`,
);
}
} finally {
await source.dispose();
await trustedRoot?.cleanup();
}
}
/**
* The one terminal "this PR was NOT reviewed" comment for failures that happen
* before any configuration is loaded (trusted-root materialization). Uses the
* DEFAULT comment tag/break-glass marker because the config that could customize
* them is exactly what failed to load; a repo with a custom tag gets a fresh
* comment rather than an upsert, which is the acceptable degraded case.
*/
async function postTerminalFailureNote(
repo: string,
prNumber: number,
cwd: string,
reason: string,
): Promise<void> {
try {
const reporter = new GitHubReporter({
prNumber,
repo,
commentTag: "expo-ai-code-reviewer",
breakGlassMarker: "/skip-review",
cwd,
});
await reporter.report({
decision: "approve_with_comments",
findings: [],
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed: ${reason}.`,
incomplete: [],
couldNotComplete: true,
});
} catch (postError) {
process.stderr.write(
`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`,
);
}
}
/** Options shared by the legacy and routed CI paths (parsed from argv once). */
interface CiRunOptions {
agents: string[] | undefined;
route: boolean;
scopesFilter?: string[] | undefined;
commentOverride?: "single" | "per-scope" | undefined;
bypassTriggerGate: boolean;
configDir: string | undefined;
}
/**
* Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
* (temporary, removed-on-exit) trusted config root — the workflow uploads
* `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
*/
function workspaceRunsDir(cwd: string): string {
return path.join(cwd, CONFIG_DIRNAME, ".runs");
}
/**
@@ -153,17 +278,17 @@ export async function ciCommand(argv: string[] = []): Promise<void> {
* routing.jsonc the CLI behaves exactly as before (backcompat invariant).
*/
async function runLegacyCi(
source: ReviewSource,
repo: string,
prNumber: number,
cwd: string,
agents: string[] | undefined,
route: boolean,
bypassTriggerGate: boolean,
configDir: string | undefined,
configRoot: string,
options: CiRunOptions,
): Promise<void> {
const { agents, route, bypassTriggerGate, configDir } = options;
let config;
try {
config = await loadReviewConfig(cwd, { configDir });
config = await loadReviewConfig(configRoot, { configDir });
} catch (error) {
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
return;
@@ -211,11 +336,12 @@ async function runLegacyCi(
}
try {
const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd }), {
const review = await runReview(source, {
config,
mode: "ci",
agents,
route,
runsDir: workspaceRunsDir(cwd),
onProgress: (message) => process.stderr.write(`${message}\n`),
});
await reporter.report(review);
@@ -254,20 +380,19 @@ function failureReview(scopeName: string, reason: string): CoordinatorOutput {
/** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
async function runRoutedCi(
source: ReviewSource & { dispose(): Promise<void> },
manifest: RoutingManifest,
repo: string,
prNumber: number,
cwd: string,
agents: string[] | undefined,
route: boolean,
scopesFilter: string[] | undefined,
commentOverride: "single" | "per-scope" | undefined,
bypassTriggerGate: boolean,
configDir: string | undefined,
configRoot: string,
options: CiRunOptions,
): Promise<void> {
const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
// The root config + manifest follow the override; scope configs stay
// repo-root-relative (loadScopeConfig reads <root>/<scope.config>/.expo-code-review).
const rootConfig = await loadReviewConfig(cwd, { configDir });
// relative to the TRUSTED root (loadScopeConfig reads
// <configRoot>/<scope.config>/.expo-code-review).
const rootConfig = await loadReviewConfig(configRoot, { configDir });
// The root/aggregate marker is the ACTUAL root-owned comment tag so the
// pre-routing comment and its dismissal state upsert in place, not stranded
// under a new marker (risk 8/9). manifest.defaults.commentTag is the
@@ -322,7 +447,6 @@ async function runRoutedCi(
return;
}
const source = memoizeSource(new GitHubPRSource({ prNumber, repo, cwd }));
const changed = await source.getChangedFiles();
const resolution = resolveScopes(
manifest,
@@ -369,7 +493,6 @@ async function runRoutedCi(
if (await bgReporter.checkBreakGlass()) {
process.stderr.write(`CI reviewer: ${rootConfig.breakGlassMarker} detected; skipping.\n`);
await bgReporter.postSkipNote();
await source.dispose();
return;
}
} catch (error) {
@@ -379,21 +502,17 @@ async function runRoutedCi(
}
// Build ONE link context for all scopes (rate-limit hygiene): diff lines from the
// already-fetched changed files, base SHA via a single `gh pr view`.
// already-fetched changed files, base OID from the memoized PR metadata (the same
// immutable OID the trusted config root was materialized from).
const link: LinkContext = {
repo,
prNumber,
diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
};
try {
const { stdout } = await run(
"gh",
["pr", "view", String(prNumber), "--repo", repo, "--json", "baseRefOid"],
{ cwd },
);
const oid = (JSON.parse(stdout) as { baseRefOid?: string }).baseRefOid;
if (oid) {
link.baseSha = oid;
const { baseOid } = await source.getMetadata();
if (baseOid) {
link.baseSha = baseOid;
}
} catch {
// leave baseSha unset → out-of-diff findings degrade to plain text
@@ -419,7 +538,20 @@ async function runRoutedCi(
const isDefault = scope.configDir === ".";
let review: CoordinatorOutput;
try {
const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
// A scope whose config dir doesn't exist at the TRUSTED base commit is a
// scope this PR introduces: review it with the root config rather than
// failing the run on exactly that PR. The scope's own config (PR-owned,
// untrusted for this run) activates once it merges.
let effectiveScopeDef = scopeDef;
if (!hasScopeConfig(configRoot, scopeDef)) {
process.stderr.write(
`CI reviewer: [${scope.name}] no config at "${scopeDef.config}" in the PR's base ` +
`commit (new in this PR?); reviewing with the root config — the scope's config ` +
`takes effect after merge.\n`,
);
effectiveScopeDef = { ...scopeDef, config: "." };
}
const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
review = await runReview(source, {
config,
mode: "ci",
@@ -427,6 +559,7 @@ async function runRoutedCi(
route,
includePaths: scope.files,
passesBudgetMs: budget,
runsDir: workspaceRunsDir(cwd),
onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
});
} catch (error) {
@@ -436,7 +569,6 @@ async function runRoutedCi(
}
results.push({ scope: scope.name, isDefault, review });
}
await source.dispose();
const reporterFor = (tag: string, withLink = false): GitHubReporter =>
new GitHubReporter({
+5 -3
View File
@@ -40,9 +40,11 @@ Options:
--no-fail always exit 0, even on request-changes
-h, --help show this help
Note: agents read the local working tree for surrounding context, so --pr uses the
PR's diff (authoritative) but your checked-out files for context. For full fidelity
on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
Note: with --repo (or in CI), --pr materializes the PR-head tree (pinned to its
immutable commit, scrubbed of ambient runtime config) so reads match the PR; if
that isn't possible, it falls back to your checked-out files with a warning.
Config always loads from YOUR checkout in local runs — you are the trust
principal here. In \`ecr ci\`, config loads from the PR's trusted base commit.
Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
`;
+16
View File
@@ -248,6 +248,22 @@ export interface LoadedScopeConfig extends LoadedConfig {
scopeName: string;
}
/**
* Whether a scope's own config dir exists under `root` — the same path
* `loadScopeConfig` reads (deliberately NOT via resolveConfigDir: ECR_CONFIG_DIR
* must never redirect scope subtrees). `ecr ci` uses this against the TRUSTED
* BASE root to give scopes that are new in a PR a defined miss behavior (review
* with the root config; the scope config activates after merge) instead of
* failing the run on exactly the PR that introduces the scope.
*/
export function hasScopeConfig(root: string, scope: RoutingScope): boolean {
if (scope.config === ".") {
return true;
}
const dir = path.join(root, scope.config, CONFIG_DIRNAME);
return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
}
/**
* Load one scope's fully-resolved config. The default scope (config '.') reuses
* the root config unchanged except auth; a nested scope reads its own
+72 -14
View File
@@ -1,7 +1,7 @@
import path from "node:path";
import type { LoadedAgent, LoadedConfig } from "../config/schema.js";
import type { ReviewSource } from "../sources/source.js";
import type { PreparedReadRoot, ReviewSource } from "../sources/source.js";
import { prepareAuth } from "./auth.js";
import { coordinate } from "./coordinator.js";
import { writeRunLog } from "./log.js";
@@ -46,6 +46,14 @@ export interface ReviewRunOptions {
includePaths?: string[];
/** Wall-clock ceiling for all review passes. Default: today's PASSES_BUDGET_MS. */
passesBudgetMs?: number;
/**
* Directory for the run log + patch workspace (`.runs/`). Defaults to
* `<configDir>/.runs` — correct when config lives in the checkout, WRONG when
* config was materialized into a temporary trusted root (removed on exit, and
* CI uploads the log from the workspace path). `ecr ci` pins this to the
* workspace checkout explicitly.
*/
runsDir?: string;
}
/**
@@ -96,7 +104,7 @@ export async function runReview(
const started = Date.now();
const runId = makeRunId();
const progress = options.onProgress ?? (() => {});
const runsRoot = path.join(config.configDir, ".runs");
const runsRoot = options.runsDir ?? path.join(config.configDir, ".runs");
const runDir = path.join(runsRoot, runId);
const logPath = path.join(runsRoot, "reviews.jsonl");
@@ -151,19 +159,28 @@ export async function runReview(
return output;
}
// Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
// and doing it first means nothing that can throw sits between the chdir and the
// guarded blocks — so a prepareAuth failure can't leak the worktree or leave cwd
// pointing at it.
const auth = await prepareAuth(config);
// Read the PR-head tree (not the current checkout) when the source can materialize
// it, so the agents' surrounding-source reads and the verifier's re-reads see the
// versions that match the diff. Config is already fully loaded in memory, so the
// chdir doesn't affect it; run-log/patch paths are absolute; gh/git calls already
// ran above. Fails soft to the current directory.
// Materialize the PR-head tree (not the current checkout) when the source can, so
// the agents' surrounding-source reads and the verifier's re-reads see the versions
// that match the diff. Config is already fully loaded in memory, so the chdir below
// doesn't affect it; run-log/patch paths are absolute; gh/git calls already ran
// above. Failure policy is MODE-DEPENDENT (see resolveReadRoot): CI fails closed —
// with a base-SHA checkout the fallback tree is pre-PR content, and silently
// reviewing/verifying that drops real findings — while a local run falls back to
// the user's own checkout with a warning.
const originalCwd = process.cwd();
const readRoot = (await source.prepareReadRootAsync?.()) ?? null;
const readRoot = await resolveReadRoot(source, options.mode, progress);
// Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
// and doing it after readRoot but before chdir means a prepareAuth failure can't
// leave cwd pointing at the worktree — it only has the worktree itself to release.
let auth: Awaited<ReturnType<typeof prepareAuth>>;
try {
auth = await prepareAuth(config);
} catch (error) {
await readRoot?.cleanup();
throw error;
}
const restoreCwd = async (): Promise<void> => {
if (readRoot) {
process.chdir(originalCwd);
@@ -873,6 +890,47 @@ export function reconcileSummary(summary: string, remaining: number): string {
);
}
/**
* Resolve the tree the review reads from, applying the mode's trust policy:
*
* - `null` from the source means "nothing to materialize" — reviewing the current
* checkout is intended (local diffs, or `--pr` without a repo). Never an error.
* - A materialization FAILURE (throw) is fatal in CI: the checkout there is the
* trusted BASE tree, and falling back to it would silently review and verify
* pre-PR file contents (dropping real findings with no trace in the output).
* The throw propagates to `ecr ci`'s catch, which posts the one terminal
* "not reviewed" comment.
* - The same failure in local mode degrades softly to the user's own checkout —
* the user is the trust principal there and sees the warning directly.
*
* Exported for tests.
*/
export async function resolveReadRoot(
source: ReviewSource,
mode: ReviewRunOptions["mode"],
progress: (message: string) => void,
): Promise<PreparedReadRoot | null> {
if (!source.prepareReadRootAsync) {
return null;
}
try {
return await source.prepareReadRootAsync();
} catch (error) {
if (mode === "ci") {
throw new Error(
`Could not materialize the PR-head tree to review (and the CI checkout is the ` +
`trusted base, so reviewing it instead would silently review the wrong ` +
`contents): ${errorMessage(error)}`,
);
}
progress(
`Could not materialize the PR-head tree (${errorMessage(error)}); ` +
`reading the current checkout instead — file contents may not match the PR.`,
);
return null;
}
}
/** Capitalize the first letter (coverage notes read as sentences). */
function capitalize(text: string): string {
return text.length > 0 ? text[0]!.toUpperCase() + text.slice(1) : text;
+7
View File
@@ -38,6 +38,13 @@ export interface ReviewMetadata {
body: string;
baseRef: string;
headRef: string;
/**
* Immutable commit OIDs for the PR's base and head, when the source can resolve
* them (GitHub PRs). Materialization pins to these — never to branch names —
* so a rename/force-push between API calls can't swap the reviewed tree.
*/
baseOid?: string;
headOid?: string;
}
export const FindingSchema = z.object({
+66
View File
@@ -0,0 +1,66 @@
import { readdir, rm } from "node:fs/promises";
import path from "node:path";
/**
* Ambient runtime configuration the OpenCode server (and the Claude-compatible
* loaders inside it) discovers from its project directory. The review core chdirs
* into a materialized PR-HEAD worktree before starting the server, so every one
* of these is attacker-writable in a PR: a plugin or MCP definition is arbitrary
* code execution in a process holding the model credential and a comment-capable
* GH_TOKEN; a `.env` can repoint a provider base URL; instruction files inject
* system-level prompts. OPENCODE_CONFIG_CONTENT (how ECR passes its own config)
* MERGES with project config rather than replacing it, so deleting these from the
* throwaway worktree is the only isolation that doesn't depend on OpenCode
* semantics.
*
* Exact-name entries match files or directories at any depth; `.env` is matched
* as a prefix (`.env`, `.env.local`, …). The PR's CHANGES to these files are
* still reviewed — their diffs are inlined in the task prompt — but the reviewer
* can no longer open their full head contents, and a finding citing one will
* fail verification (a documented tradeoff of the scrub approach).
*/
export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
"opencode.json",
"opencode.jsonc",
".opencode",
"AGENTS.md",
"CLAUDE.md",
".claude",
".mcp.json",
".cursor",
".cursorrules",
]);
/** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
const SKIP_DIRS = new Set([".git", "node_modules"]);
/** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
export function isAmbientRuntimeConfig(name: string): boolean {
return AMBIENT_RUNTIME_CONFIG_NAMES.has(name) || name === ".env" || name.startsWith(".env.");
}
/**
* Remove ambient runtime config from a THROWAWAY materialized tree, at every
* depth. Must only ever run on a tree ECR created and will delete (a worktree or
* extracted archive) — never on the user's checkout. Returns the repo-relative
* paths removed so callers can log them.
*/
export async function scrubAmbientRuntimeConfig(root: string): Promise<string[]> {
const removed: string[] = [];
const walk = async (dir: string): Promise<void> => {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (isAmbientRuntimeConfig(entry.name)) {
await rm(full, { recursive: true, force: true });
removed.push(path.relative(root, full));
continue;
}
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
await walk(full);
}
}
};
await walk(root);
return removed.sort();
}
+114 -19
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { run } from "../core/exec.js";
import { parseUnifiedDiff } from "../core/diff.js";
import { scrubAmbientRuntimeConfig } from "../core/scrub.js";
import type { DiffEntry, ReviewMetadata } from "../core/schema.js";
import type { PreparedReadRoot, ReviewSource } from "./source.js";
@@ -14,6 +15,20 @@ export interface GitHubPRSourceOptions {
cwd?: string;
}
/** A full 40-hex-char commit OID — the only ref form passed to security-sensitive git calls. */
export function isCommitOid(value: string | undefined): value is string {
return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
}
/**
* Append gh as a git credential helper for a single command. The token comes from
* GH_TOKEN via the credential-helper protocol — never argv, never `.git/config` —
* so a base-SHA checkout with `persist-credentials: false` (no extraheader) can
* still fetch a private repo. Appending (not replacing) keeps a local user's own
* helpers first, so developer machines behave exactly as before.
*/
const GH_CREDENTIAL_HELPER_ARGS = ["-c", "credential.helper=!gh auth git-credential"];
/**
* Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
* authenticated on GitHub Actions runners via GH_TOKEN.
@@ -21,11 +36,22 @@ export interface GitHubPRSourceOptions {
export class GitHubPRSource implements ReviewSource {
constructor(private readonly options: GitHubPRSourceOptions) {}
private metadataPromise: Promise<ReviewMetadata> | undefined;
private repoArgs(): string[] {
return this.options.repo ? ["--repo", this.options.repo] : [];
}
async getMetadata(): Promise<ReviewMetadata> {
/**
* Memoized internally (not just by memoizeSource) because the materialization
* paths below need the immutable OIDs and must not depend on the caller having
* called getMetadata() first.
*/
getMetadata(): Promise<ReviewMetadata> {
return (this.metadataPromise ??= this.fetchMetadata());
}
private async fetchMetadata(): Promise<ReviewMetadata> {
const { stdout } = await run(
"gh",
[
@@ -34,7 +60,10 @@ export class GitHubPRSource implements ReviewSource {
String(this.options.prNumber),
...this.repoArgs(),
"--json",
"title,body,baseRefName,headRefName",
// baseRefOid/headRefOid are the immutable commit OIDs backing this PR at
// this moment; every materialization below pins to them so a rename,
// force-push, or deleted head between API calls can't swap a tree.
"title,body,baseRefName,headRefName,baseRefOid,headRefOid",
],
{ cwd: this.options.cwd },
);
@@ -43,12 +72,16 @@ export class GitHubPRSource implements ReviewSource {
body?: string;
baseRefName?: string;
headRefName?: string;
baseRefOid?: string;
headRefOid?: string;
};
return {
title: parsed.title ?? "",
body: parsed.body ?? "",
baseRef: parsed.baseRefName ?? "",
headRef: parsed.headRefName ?? "",
baseOid: isCommitOid(parsed.baseRefOid) ? parsed.baseRefOid.toLowerCase() : undefined,
headOid: isCommitOid(parsed.headRefOid) ? parsed.headRefOid.toLowerCase() : undefined,
};
}
@@ -62,27 +95,32 @@ export class GitHubPRSource implements ReviewSource {
}
/**
* Check the PR HEAD out into a throwaway git worktree so the agents and verifier
* read the PR's versions of files (not whatever branch happens to be checked out).
* Fetches the head from the repo's own URL — `refs/pull/<n>/head`, which the base
* repo hosts even for fork PRs — so it's always the correct PR, independent of the
* local `origin`. Fails SOFT: any problem (not a git repo, fetch/worktree error)
* returns null, and the review falls back to reading the current checkout.
* Fetch `ref` from the repo's own HTTPS URL and materialize `oid` as a detached
* throwaway worktree under a fresh temp dir. Never falls back to a branch name:
* `oid` is validated as a full commit hash before reaching git.
*/
async prepareReadRootAsync(): Promise<PreparedReadRoot | null> {
const cwd = this.options.cwd;
private async materializeWorktreeAsync(ref: string, oid: string): Promise<PreparedReadRoot> {
if (!this.options.repo) {
// Without an explicit owner/repo we can't build the fetch URL safely.
return null;
throw new Error("cannot materialize a PR tree without an explicit owner/repo");
}
if (!isCommitOid(oid)) {
throw new Error(`refusing to materialize a non-OID ref: "${oid}"`);
}
const cwd = this.options.cwd;
const url = `https://github.com/${this.options.repo}.git`;
const ref = `refs/pull/${this.options.prNumber}/head`;
let parent: string | undefined;
try {
await run("git", ["fetch", "--no-tags", "--depth=1", url, ref], { cwd });
parent = await mkdtemp(path.join(tmpdir(), "ecr-prhead-"));
const dir = path.join(parent, "head"); // must not pre-exist for `worktree add`
await run("git", ["worktree", "add", "--detach", dir, "FETCH_HEAD"], { cwd });
await run(
"git",
[...GH_CREDENTIAL_HELPER_ARGS, "fetch", "--no-tags", "--depth=1", url, ref],
{ cwd },
);
parent = await mkdtemp(path.join(tmpdir(), "ecr-tree-"));
const dir = path.join(parent, "tree"); // must not pre-exist for `worktree add`
// Check out the OID (not FETCH_HEAD): if the ref moved between the API call
// and this fetch, the OID is absent and this fails instead of silently
// materializing a different tree than the one the diff was fetched for.
await run("git", ["worktree", "add", "--detach", dir, oid], { cwd });
const removeParent = parent;
return {
dir,
@@ -95,11 +133,68 @@ export class GitHubPRSource implements ReviewSource {
await rm(removeParent, { recursive: true, force: true });
},
};
} catch {
} catch (error) {
if (parent) {
await rm(parent, { recursive: true, force: true }).catch(() => {});
}
return null;
throw error;
}
}
/**
* Check the PR HEAD out into a throwaway worktree so the agents and verifier
* read the PR's versions of files (not whatever branch happens to be checked
* out), pinned to the immutable head OID. The fetch uses `refs/pull/<n>/head`,
* which the base repo hosts even for fork PRs.
*
* The worktree is SCRUBBED of ambient runtime config (opencode.json, .opencode
* plugins, AGENTS.md/CLAUDE.md, .mcp.json, .env*, …) before it's returned: the
* OpenCode server is started with this directory as its project root, and
* anything it discovers there is attacker-controlled PR content executing or
* injecting inside a process that holds the model credential and GH_TOKEN.
*
* Returns null only when no owner/repo is configured (a local `--pr` run
* without --repo, where the current checkout is an acceptable read root).
* Materialization FAILURES throw — the caller decides per mode whether that is
* fatal (CI: fail closed) or a soft fallback (local: the user's own checkout).
*/
async prepareReadRootAsync(): Promise<PreparedReadRoot | null> {
if (!this.options.repo) {
// Without an explicit owner/repo we can't build the fetch URL safely.
return null;
}
const metadata = await this.getMetadata();
if (!isCommitOid(metadata.headOid)) {
throw new Error("GitHub did not report an immutable head OID for this PR");
}
const root = await this.materializeWorktreeAsync(
`refs/pull/${this.options.prNumber}/head`,
metadata.headOid,
);
try {
await scrubAmbientRuntimeConfig(root.dir);
} catch (error) {
// A half-scrubbed tree must never become the runtime's project root.
await root.cleanup().catch(() => {});
throw error;
}
return root;
}
/**
* Materialize the PR's BASE commit as the trusted configuration root: review
* policy, prompts, routing, and auth mapping load from here, so a PR cannot
* change the reviewer that evaluates it (config changes activate on merge).
* Failures throw — `ecr ci` must fail closed, never fall back to the checkout.
*/
async prepareTrustedConfigRootAsync(): Promise<PreparedReadRoot> {
const metadata = await this.getMetadata();
if (!isCommitOid(metadata.baseOid)) {
throw new Error("GitHub did not report an immutable base OID for this PR");
}
// The base OID is fetchable by SHA (it's the tip of the base branch as of the
// API call; GitHub serves reachable SHAs — the same mechanism actions/checkout
// uses for `ref:` pins).
return this.materializeWorktreeAsync(metadata.baseOid, metadata.baseOid);
}
}
+5
View File
@@ -17,6 +17,11 @@ export interface ReviewSource {
* agents also read surrounding source and the verifier re-reads cited files — and
* those must be the PR-HEAD versions, not whatever happens to be checked out.
* Return `null` (or omit the method) to just review the current working directory.
*
* Contract: `null` means "no tree to materialize" (a deliberate, safe
* fall-through to the current checkout). A materialization FAILURE throws —
* the review core fails closed on it in CI mode and falls back softly only in
* local mode, where the user's own checkout is an acceptable read root.
*/
prepareReadRootAsync?(): Promise<PreparedReadRoot | null>;
}
+7 -1
View File
@@ -85,12 +85,18 @@ jobs:
# config, and never `gh pr checkout` the PR head. The reviewer engine itself
# is the PUBLISHED @expo/code-review-cli (fetched by npx), not built from any
# checkout, so attacker-controlled PR code never runs here. The diff + PR
# metadata come from the API (`gh pr diff`/`gh pr view`).
# metadata come from the API (`gh pr diff`/`gh pr view`); `ecr ci` loads
# configuration from the PR's immutable base commit and reads source from a
# head worktree scrubbed of ambient runtime config (opencode.json, plugins,
# AGENTS.md, .env, …) — the same trust model as the pull_request workflow.
- name: Checkout (base ref only — never the PR head)
if: steps.cmd.outputs.run == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
# The CLI's own git fetches authenticate through `gh` from GH_TOKEN, so
# the token never lands in .git/config.
persist-credentials: false
- name: Set up Node
if: steps.cmd.outputs.run == 'true'
+3
View File
@@ -83,6 +83,9 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
# Dismiss never fetches; gh authenticates from GH_TOKEN. Keep the token
# out of .git/config.
persist-credentials: false
- name: Set up Node
if: steps.cmd.outputs.run == 'true'
+17 -5
View File
@@ -39,10 +39,21 @@ jobs:
# A reviewer failure must never fail the PR's checks.
continue-on-error: true
steps:
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
# merge ref. Everything security-sensitive on this runner (`ecr verify-config`'s
# sweep, and any ambient files) therefore comes from a commit that already
# merged. `ecr ci` additionally enforces this itself: it materializes the base
# commit via the GitHub API for configuration and the head commit (scrubbed of
# runtime config) for source reads, so this checkout is defense in depth, not
# the only line. persist-credentials off — the CLI's own git fetches
# authenticate through `gh` from GH_TOKEN, so the token never lands in
# .git/config.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
# Shallow is enough — the reviewer gets the diff from the API (`gh`).
fetch-depth: 1
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -52,16 +63,17 @@ jobs:
# to save an empty cache and error).
package-manager-cache: false
# SECURITY: this workflow checks out the PR's code, including every
# SECURITY: the TRUSTED BASE checkout above includes every
# .expo-code-review/config.jsonc + routing.jsonc, whose auth.tokenEnv names the
# env var the CLI forwards as the model credential. The canonical guard ships
# with the CLI: `ecr verify-config` sweeps every config (root + routing + all
# scopes, referenced or not) with the engine's real JSONC parser and refuses
# unless tokenEnv appears exactly once, in a ROOT-owned file, equal to the
# expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a PR can't repoint it at
# another runner secret, sneak in a JSON-escaped key, or stage an unreferenced
# scope config with its own auth. This is layer 2; layer 1 is the runtime
# ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift fails safe.
# expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a config change can't
# repoint it at another runner secret, sneak in a JSON-escaped key, or stage an
# unreferenced scope config with its own auth. This is layer 2; layer 1 is the
# runtime ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift
# fails safe.
#
# This step MUST run BEFORE `ecr ci` (before any PR code is built or loaded).
# Only setup-node (runtime install) precedes it; running the PUBLISHED package