Skip to content

Fetch the pull request's head in the engine, so a calling workflow needs no checkout of untrusted code - #19

Merged
polewskm merged 4 commits into
mainfrom
fetch-head-in-engine
Oct 31, 2026
Merged

polewskm merged 4 commits into
mainfrom
fetch-head-in-engine

Conversation

@polewskm

Copy link
Copy Markdown
Contributor

Every repository that adopts the bot gets the same two CodeQL alerts on code-review.yml: checkout of untrusted code in a privileged context and its TOCTOU variant. They are accepted risks (the checkout is only read), but they repeat for each repository, mark the adoption PR's CodeQL check red, and need a dismissal each time. This removes the cause.

What changes

  • The workflow no longer checks the head out. The engine fetches the commit itself (src/fetch-head.ts) after it has decided to review: a /review comment from a writer, a pull request that isn't from a fork, a commit not already reviewed. A caller then needs only the checkout of its default branch.
  • The exact commit. It checks out the SHA from the event, or from the API for a comment, instead of refs/pull/N/head. A push between a /review comment and the checkout can no longer change what is reviewed, which the TOCTOU alert was about.
  • git runs bare. No system or user configuration (an empty folder stands in for the home directory), no hooks, no fsmonitor, no credential helper, no prompts. The token goes through GIT_CONFIG_* environment variables scoped to the remote's origin, never a command line or the remote's URL, and the test checks it isn't left in .git/config. A SHA that isn't 40 hex characters, a bad number and a non-empty folder are refused.
  • Backward compatible. A folder that is already a repository is left alone, so v0.2 workflows that still check the head out keep working.

What does not change

The risk is the same as before and is mitigated the same way: the head is data on disk, read by an agent with no settings, hooks, MCP servers or CLAUDE.md from it, read-only tools and a path guard. This moves the fetch into the audited engine, where those mitigations live, and out of every caller's workflow file. It does not make reading an untrusted checkout safer by itself, and I am not claiming it does.

Rollout

  1. Merge, then release v0.3.0.
  2. Update the template's code-review.yml and the repositories' workflows to v0.3.0 and delete their second checkout.
  3. The alerts already open on main are dismissed once; no repository gets new ones.

npm test (33 tests, 4 new, against a local repository standing in for the remote) and npm run typecheck pass. The fetch against github.com itself is untested until the first run of v0.3.0, which I'll watch.

🤖 Generated with Claude Code

…eds no checkout of untrusted code

The workflow checked the head out with actions/checkout in a privileged workflow, which CodeQL reports as two alerts (untrusted checkout, and its TOCTOU variant) on every repository that adopts the bot. The engine now fetches the exact commit itself, after it has decided to review, with git started without configuration, hooks or a credential helper, and the token passed through the environment. A workflow that still checks the head out keeps working.
@polewskm
polewskm deployed to code-review October 30, 2026 23:57 — with GitHub Actions Active

@ncode-review ncode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 NCode Review at 1c4b602: 9 new findings on the changed lines.

Not on a changed line:

  • 🟡 F8 Nit: The Versions section still says v0.1.0 while the example pins v0.3.0 README.md:70. The workflow example pins NCodeGroup/code-review@v0.3.0, but the Versions section still gives @v0.1.0 as its example. The new text also refers to v0.2 workflows. Fix: Use one tag throughout the README, and confirm that v0.3.0 is the release that ships this change.

Comment thread src/fetch-head.ts Resolved
Comment thread src/fetch-head.ts Resolved
Comment thread test/fetch-head.test.ts Resolved
Comment thread src/main.ts Resolved
Comment thread src/main.ts Resolved
Comment thread src/fetch-head.ts Resolved
Comment thread src/fetch-head.ts Resolved
Comment thread src/main.ts Resolved
Comment thread test/fetch-head.test.ts Resolved
@ncode-review

ncode-review Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🤖 Review complete

Verdict: 💬 Comment — This PR moves the pull request head checkout out of the calling workflow and into the engine. The engine now fetches the exact commit with git, after it has decided to review, using an empty home folder, no hooks, no credential helper and a token scoped to the remote's origin. It also adds a fork check on the automatic pull_request path. The design holds together and the security choices are sound: the commit is fixed by SHA, the token never appears in argv or in .git/config, and a half-made checkout is removed on failure. What's left are robustness gaps: the network fetches have no timeout, the by-name fetch fallback and the new main.ts guards have no tests, and one test waits on a fixed delay.

8 reviewers run — 7 findings (run details)

Reviewer Model Findings Top
synthesis opus-5-5 7 🟠
architecture sonnet-5-5 0 🟢
completeness sonnet-5-5 0 🟢
conventions sonnet-5-5 0 🟢
correctness sonnet-5-5 1 🟠
hygiene haiku-5-5 2 🟡
rule-check sonnet-5-5 3 🟠
security sonnet-5-5 0 🟢
tests haiku-5-5 4 🟠

Skipped: api-compat, data-model, infrastructure, intent, performance

Findings
  • 🟠 F1 src/fetch-head.ts:56: Network git fetches run with no timeout
  • 🟠 F2 test/fetch-head.test.ts:74: The by-name fetch fallback never runs on its success path
  • 🟠 F3 test/fetch-head.test.ts:153: A fixed 200 ms sleep comes before the assertion on the server's received headers
  • 🟠 F4 src/main.ts:100: The new fork guard on the event path and the engine fetch in main have no tests
  • 🟡 F5 test/fetch-head.test.ts:142: The auth-header unit test starts a real HTTP server on loopback
  • 🟡 F6 test/fetch-head.test.ts:6: The isFromFork test is in the fetch-head test file, and the imports are out of order
  • 🟡 F7 README.md:63: The new README paragraph breaks the existing line wrapping

Trial: findings are posted as review threads and in the check run. $0.51 on Amazon Bedrock, 83s, at 1516681. (how to interact)

…nd test the fetch's inputs

Reviewing the engine fetch (findings F1 to F10): an existing .git is accepted only at the named commit; the fetch asks for the commit by name when a push left it off the fetched refs, and says the head moved and clears the folder when it still cannot get it; the temporary home is removed; the token's header and the remote URL are built by functions the tests exercise; the fetch takes the parsed pull request's head; the tests run without the machine's git configuration and remove their folders; the README names one version and describes current behavior.
@polewskm

Copy link
Copy Markdown
Contributor Author

Fixed in 0848176. F1: an existing checkout is accepted only at the named commit, otherwise the run stops with a message to comment /review. F2: the fetch asks for the commit by name when a push left it off the fetched refs, and says the head moved (and clears the folder) when it still can't get it; this path is covered by the unreachable-commit test, but the by-name fetch against github.com itself is untested. F3, F4, F5: the token header and the remote URL come from authEnvironment and fetchHeadFor, which the tests exercise, and the fetch takes pullRequest.headSha. F6: the folder is cleared when init, fetch or checkout fails. F7: the temporary home is removed; the git wrapper stays separate from gather.ts's on purpose, because that one only reads an existing checkout while this one reaches the network with the token (the file header says so). F8, F9: one version in the README, and wording in the present tense. F10: the tests run without the machine's git configuration and remove their folders. 37 tests pass.

@polewskm

Copy link
Copy Markdown
Contributor Author

F8 (the README's Versions section still said v0.1.0) was in the review summary, not a thread: fixed in 0848176, the README now names v0.3.0 in both places.

@polewskm

Copy link
Copy Markdown
Contributor Author

/review

@ncode-review ncode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 NCode Review at 0848176: 4 new findings on the changed lines.
3 more were already posted on this pull request.

Comment thread src/fetch-head.ts Resolved
Comment thread src/fetch-head.ts Resolved
Comment thread README.md Resolved
Comment thread src/fetch-head.ts Resolved
…t the token reaching the remote

Second review of the engine fetch: a pull request from another repository, or from none, is refused by the engine for pull_request_target as it is for a comment; the failure reading HEAD keeps git's message; the git helper binds its folders in one argument; the wiring is two statements; the tests cover a repository with no commit, a header sent to a real server (in its own process, since the fetch blocks this one) and the fork check.
@polewskm

Copy link
Copy Markdown
Contributor Author

The three findings that were not on a changed line:

  • F1 (fork exclusion for pull_request_target): fixed in 1516681. The engine now refuses a pull request whose head is in another repository, or in none (a deleted fork), for both a comment and a pull_request_target event, through one function (isFromFork) with a test. Until now only the workflow's if: kept forks out of that path; with the engine fetching the head itself, the engine should not rely on it.
  • F4 (untested failure paths): fixed in 1516681. New tests: an existing repository with no commit, and the token's header reaching a server. The server runs in its own process, because the fetch blocks the test's while git runs; it records that every request carried Authorization: basic <base64 of x-access-token:token>. What github.com does with it is still only known from a real run.
  • F6 (nested call): fixed in 1516681. fetchHeadFor and fetchPullRequestHead are two statements.

40 tests and the typecheck pass.

@polewskm
polewskm marked this pull request as ready for review October 31, 2026 00:26
@polewskm
polewskm deployed to code-review October 31, 2026 00:26 — with GitHub Actions Active
@polewskm
polewskm merged commit a229ad3 into main Oct 31, 2026
4 checks passed
@polewskm
polewskm deleted the fetch-head-in-engine branch October 31, 2026 00:26

@ncode-review ncode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 NCode Review at 1516681: 7 new findings on the changed lines.

Comment thread src/fetch-head.ts
// or the checked-out code could hold there is read.
function git(where: { cwd: string; empty: string }, args: string[], extra: Record<string, string> = {}): string {
const { cwd, empty } = where;
return execFileSync('git', ['-C', cwd, '-c', `core.hooksPath=${empty}`, '-c', 'core.fsmonitor=false', '-c', 'advice.detachedHead=false', ...args], {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Should fix: Network git fetches run with no timeout F1 · correctness, rule-check

git() calls execFileSync without a timeout option, including for the two fetch calls that go over the network.

Consequence: If the remote or proxy stalls, the engine blocks synchronously until the job-level timeout kills the runner. Nothing can cancel it, no useful error is printed, and the 'comment /review' hint never appears.

Fix: Pass a timeout from a named constant (for example a few minutes) to execFileSync for the fetch calls. The timeout error then reaches the existing catch, which cleans up and reports a fetch failure.

Evidence

execFileSync('git', [...], { encoding, stdio, env }) sets no timeout; the git(where, ['fetch', ...], auth) calls at lines ~105–107 are network calls. Rule: outbound fetches must always have a timeout.

Confidence 0.45.

Comment thread test/fetch-head.test.ts
git(remote, 'commit', '-q', '-m', 'three');
git(remote, 'update-ref', 'refs/pull/1/head', git(remote, 'rev-parse', 'HEAD'));
const dir = target();
fetchPullRequestHead({ path: dir, remoteUrl: pathToFileURL(remote).href, number: 1, sha: head });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Should fix: The by-name fetch fallback never runs on its success path F2 · tests

In the 'moved' test, refs/pull/1/head is moved to a child of the named commit, so the first fetch already brings that commit down and if (!has(where, options.sha)) git(fetch origin &lt;sha>) is skipped.

Consequence: A regression that drops or breaks the by-name fetch (the force-push case the code comment describes) still passes the suite, and in production the reviewed commit then fails to check out after a force-push.

Fix: Add a test where the named commit can't be reached from any branch or pull ref (for example, point refs/pull/1/head at an unrelated commit after recording the head) and assert that fetchPullRequestHead still checks out the named SHA. Keep the existing test as the control.

Evidence

test/fetch-head.test.ts lines 65–76 set refs/pull/1/head to commit 'three', whose parent is head; in src/fetch-head.ts the by-name fetch only runs when has() is false.

Confidence 0.70.

Comment thread test/fetch-head.test.ts
// The server has no repository, so the fetch fails; what matters is what it received before that.
assert.throws(() => fetchPullRequestHead({ path: dir, remoteUrl: `http://127.0.0.1:${port}/o/r.git`, number: 1, sha: 'b'.repeat(40), token: 'ghs_tokentoken' }), /Couldn't fetch commit bbbbbbb/);
await new Promise((resolve) => setTimeout(resolve, 200));
const headers = [...output.matchAll(/^H (.*)$/gm)].map((match) => match[1]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Should fix: A fixed 200 ms sleep comes before the assertion on the server's received headers F3 · tests, rule-check

After the fetch fails, the auth-header test waits a fixed setTimeout(resolve, 200) and then asserts once on the headers the child server printed.

Consequence: On a slow or loaded CI runner the child may not have flushed stdout yet, and the test fails at random with 'the server saw no request'. On a fast runner it waits longer than it needs to.

Fix: Poll output every 20–100 ms until an H line appears or a deadline of several seconds passes, then assert.

Evidence

await new Promise((resolve) => setTimeout(resolve, 200)); followed by assert.ok(headers.length > 0, ...). Rule: poll until a deadline, never a fixed delay followed by one assertion.

Confidence 0.60.

Comment thread src/main.ts
if (isFromFork(event.pull_request, repository)) {
console.log(`#${event.pull_request?.number} comes from a fork, and forks aren't reviewed.`);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Should fix: The new fork guard on the event path and the engine fetch in main have no tests F4 · tests

main.ts now returns early for a fork pull_request event and calls fetchHeadFor/fetchPullRequestHead before gather, but only the helper isFromFork is tested, and only in isolation.

Consequence: A later edit could remove the fork guard on the automatic path, and fork code would be fetched and reviewed with every test still passing.

Fix: Move the decision (skip forks, skip already-reviewed heads, fetch) into a small pure function that takes the event and returns an action. Test it with a fork event, a same-repo event and a null head.

Evidence

main.ts adds if (isFromFork(event.pull_request, repository)) { ... return; } and the fetchHeadFor(...) and fetchPullRequestHead(headFetch) lines; no test drives main.

Confidence 0.40.

Comment thread test/fetch-head.test.ts
});

test('fetchPullRequestHead sends the token to the remote as a Basic header', async () => {
// The server runs in its own process, because the fetch blocks this one while git runs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: The auth-header unit test starts a real HTTP server on loopback F5 · tests, rule-check

The test spawns a node child process that listens on 127.0.0.1 and runs a real git fetch against it, which depends on local ports and on being able to spawn a process.

Consequence: It can fail or flake where loopback networking or process spawning is restricted, which conflicts with the rule that unit tests stay hermetic.

Fix: If this is the only check that the token actually reaches the wire, keep it and add a comment saying why, or move it to an integration suite. Otherwise rely on the existing authEnvironment test.

Evidence

Lines 142–160 spawn process.execPath -e "...createServer...listen(0,'127.0.0.1')". Rule: keep unit tests hermetic, with no network and no machine state.

Confidence 0.30.

Comment thread test/fetch-head.test.ts
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { after, test } from 'node:test';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: The isFromFork test is in the fetch-head test file, and the imports are out of order F6 · hygiene

The isFromFork test lives in fetch-head.test.ts although the function is in command.ts, and import { pathToFileURL } from 'node:url' sits between the two ../src imports.

Consequence: A reader looking for the command.ts tests won't find this one, and the import block is harder to scan.

Fix: Move the isFromFork test to the file that tests command.ts, and put the node: imports together before the local imports.

Evidence

Line 6 imports ../src/command.ts, line 7 imports node:url, line 8 imports ../src/fetch-head.ts; the isFromFork test is the last test in the file.

Confidence 0.35.

Comment thread README.md

`pull_request_target` and `issue_comment` both run this file from the default branch, so a pull request can't change
its own review, and the pull request's code is only read, never built or run. Pushes start nothing; comment `/review`
its own review, and the pull request's code is only read, never built or run. The workflow doesn't check the head out: the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: The new README paragraph breaks the existing line wrapping F7 · hygiene

The added sentence runs well past the column width the rest of the README is wrapped to.

Consequence: Only the Markdown source looks inconsistent; the rendered page is unaffected.

Fix: Re-wrap the paragraph to the README's usual width.

Evidence

The line ending ...if it is at that commit). Pushes start nothing; comment /review`` is much longer than the lines around it.

Confidence 0.30.

This branch was successfully deployed

1 active deployment
code-review — 1516681d Deployed Oct 31, 2026 by polewskm via review #7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant