Skip to content

security(ci): drop persisted Git credentials from compiling checkouts#319

Merged
doublegate merged 2 commits into
mainfrom
security/persist-credentials-hardening
Jul 21, 2026
Merged

security(ci): drop persisted Git credentials from compiling checkouts#319
doublegate merged 2 commits into
mainfrom
security/persist-credentials-hardening

Conversation

@doublegate

@doublegate doublegate commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Closes #318. Follow-up to #317, where CodeRabbit raised this and the fix was applied to the one new job.

Problem

actions/checkout defaults to persist-credentials: true, writing the workflow GITHUB_TOKEN into .git/config. Any code the job then executes from the checkout — Cargo build scripts, proc macros, test binaries, Gradle build scripts, scripts/*.sh, the MkDocs build — can read it. On a pull request that tree is by definition unreviewed code, and every CI job here compiles and runs it.

18 of 19 checkouts hardened. One deliberate exception, below.

This was audited, not applied blanket

Three findings shaped the result:

1. .github/actions/rust-setup performs no checkout of its own (it documents this at L5-6) despite being used by 12 call sites — so hardening at each call site is complete coverage, with no hidden checkout to miss.

2. No job actually needed the persisted credential. There is exactly one Git network operation in the entire .github/ tree. Nothing pushes commits, tags, or branches; there are no submodules; Pages uses the OIDC flow (configure-pages / upload-pages-artifact / deploy-pages), not a gh-pages push, and its deploy job has no checkout at all.

The usual false positives were each checked and dismissed:

Looks like it needs git Actually uses
gh release create (release-auto.yml:159) GH_TOKEN → REST API
softprops/action-gh-release@v3 (release.yml:150) GITHUB_TOKEN → REST API
actions/deploy-pages@v5 (web.yml:187) OIDC; job has no checkout
fastlane match (ios.yml:128) Clones a different repo via its own MATCH_GIT_* secrets

persist-credentials only affects git network operations relying on the stored credential — not the gh CLI, not API calls. That distinction is the main source of false "needs credentials" classifications.

3. The highest-exposure site is not a ci.yml job. web.yml declares pages: write + id-token: write at the workflow level (L52-55), so that block also covers its build job — which is PR-reachable and runs trunk, cargo doc, pip install, and mkdocs build. That is the most valuable token in the repo sitting in the job with the widest untrusted-code surface.

The one deliberate exception

release-auto.yml's prepare keeps its credentials, and now carries an inline comment saying why:

  • it holds the tree's only real Git network operation (git ls-remote --tags origin, L88);
  • it is unreachable from untrusted input (workflow_run, further gated to event == 'push');
  • it executes no repository code at all — it reads two text files.

So hardening buys nothing there while risking a release path only exercised at a version cut. The comment records the safe migration order for anyone later wanting uniformity: convert the ls-remote to gh api .../git/ref/tags/$tag first, then drop the credentials. That swap also fixes a latent bug — the current >/dev/null 2>&1 makes a transient failure indistinguishable from "tag does not exist", which would publish a duplicate release.

Worth a second look during review

ci.yml's changes job is the only place this touches the git layer at all, since dorny/paths-filter reads local git on push events. It needs no authentication (fetch-depth: 0 means the base commit is already local; the pull_request path uses the GitHub API), so this should be inert. An inline comment records the reasoning and the benign failure mode — if it were ever wrong, the filter reports "everything changed" and the full suite runs, costing only the docs-only skip.

Post-merge verification: confirm a docs-only push to main still skips the heavy jobs.

Not included

zizmor's unpinned-uses (no action pinned to a commit SHA). Different risk class, and SHA-pinning is a maintenance commitment that needs the github-actions Dependabot ecosystem enabled first — otherwise it trades a supply-chain risk for a stale-action risk, which is a live concern in a repo that has already had to move off Node-20-deprecated action majors.

One sub-item is worth doing soon on its own, and is called out in #318: .github/actions/rust-setup:41 uses dtolnay/rust-toolchain@master — a branch ref that advances on every upstream commit, unlike the @vN tags used everywhere else. That composite feeds 12 of the 19 checkouts including release.yml (contents: write) and web.yml (pages: write + id-token: write), and it is the action that installs the compiler. Upstream publishes a v1 tag, so it is a one-line fix.

Scope

Purely additive CI configuration. No source, build output, or emulation behavior changes — AccuracyCoin and every golden vector are untouched by construction.

CI green on this PR exercises 10 of the changed sites directly. android.yml is paths-gated and pgo.yml / ios.yml / release.yml are tag/dispatch-only, so those are covered by inspection plus the reasoning above rather than by this run.

Summary by CodeRabbit

  • Security

    • Strengthened CI checkout handling by disabling GitHub credential persistence across build, lint, test, release, and security-related workflows.
    • Improved protection for build and deployment automation by reducing the chance of runner credentials being stored for later steps.
    • Documented an exception for tag existence checks where credentials may be temporarily needed for Git operations.
  • Documentation

    • Added an unreleased changelog entry describing the CI checkout security hardening and its audited scope.

Closes #318.

actions/checkout defaults to persist-credentials: true, which writes the
workflow GITHUB_TOKEN into .git/config as an
http.https://github.com/.extraheader entry. Any code the job subsequently
executes from the checkout can read it: Cargo build scripts, proc macros,
test binaries, Gradle build scripts, scripts/*.sh, the MkDocs build. On a
pull request that tree is by definition unreviewed code, and every CI job
in this repo compiles and runs it. Raised by CodeRabbit on #317, where the
fix was applied to the one new job; this is the repo-wide sweep.

Applied to 18 of the 19 checkouts under .github/.

Audited rather than applied blanket. Three findings shaped the result:

1. .github/actions/rust-setup performs no checkout of its own (it says so
   at L5-6 and is used by 12 call sites), so hardening at each call site is
   complete coverage — there is no hidden checkout to miss.

2. No job actually needed the persisted credential. There is exactly ONE
   Git network operation in the whole .github/ tree. Nothing pushes commits,
   tags, or branches; there are no submodules; GitHub Pages uses the OIDC
   flow (configure-pages/upload-pages-artifact/deploy-pages) rather than a
   gh-pages branch push, and its deploy job has no checkout at all. The
   usual false positives were each checked and dismissed: `gh release
   create` and softprops/action-gh-release authenticate via GH_TOKEN against
   the REST API, and `fastlane match` clones a DIFFERENT repository using its
   own MATCH_GIT_* credentials. persist-credentials only affects git network
   operations relying on the stored credential — not the gh CLI, not the API.

3. The highest-exposure site is not a ci.yml job. web.yml declares
   pages: write + id-token: write at the WORKFLOW level, so that block also
   covers its `build` job — which is pull-request-reachable and runs trunk,
   cargo doc, pip install, and mkdocs build. That is the most valuable token
   in the repo sitting in the job with the widest untrusted-code surface.

The single deliberate exception is release-auto.yml's `prepare`, which keeps
its credentials and now carries an inline comment explaining why. It holds
the tree's only real Git network operation (git ls-remote --tags origin), it
is unreachable from untrusted input (workflow_run, further gated to
event == 'push'), and it executes no repository code at all — it reads two
text files. Hardening it would buy nothing while risking a release path that
is only exercised at a version cut. The comment also records the safe
migration order for anyone later wanting uniformity: convert the ls-remote to
`gh api .../git/ref/tags/$tag` FIRST, then drop the credentials. That swap
additionally fixes a latent bug, since the current `>/dev/null 2>&1` makes a
transient failure indistinguishable from "tag does not exist" and would
publish a duplicate release.

ci.yml's `changes` job is the one place the change touches the git layer at
all, since dorny/paths-filter reads local git on push events. It needs no
authentication (fetch-depth: 0 means the base commit is already local, and
the pull_request path uses the GitHub API), so this is expected to be inert;
an inline comment records the reasoning and the benign failure mode.

Deliberately NOT included: zizmor's unpinned-uses finding. SHA-pinning every
action is a different risk class and a maintenance commitment that needs the
github-actions Dependabot ecosystem enabled first, or it trades a
supply-chain risk for a stale-action risk. One sub-item is worth doing soon
on its own: .github/actions/rust-setup pins dtolnay/rust-toolchain@master —
a branch ref that advances on every upstream commit, unlike the @vn tags used
everywhere else — and that composite feeds 12 of the 19 checkouts including
release.yml (contents: write) and web.yml (pages: write). Tracked in #318.

Purely additive CI configuration: no source, build output, or emulation
behavior changes.
Copilot AI review requested due to automatic review settings July 21, 2026 02:03
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b78aed51-be6d-4a1b-8549-fb5e3027f7c4

📥 Commits

Reviewing files that changed from the base of the PR and between fe020c6 and 88e95af.

📒 Files selected for processing (2)
  • .github/workflows/release-auto.yml
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

GitHub Actions checkout steps now disable credential persistence across build, test, release, and security workflows. The automatic release tag-check exception is documented, and the unreleased changelog records the CI hardening scope.

Changes

CI checkout credential hardening

Layer / File(s) Summary
Harden workflow checkouts
.github/workflows/android.yml, .github/workflows/ci.yml, .github/workflows/ios.yml, .github/workflows/pgo.yml, .github/workflows/release.yml, .github/workflows/security.yml, .github/workflows/web.yml
Affected actions/checkout@v7 steps set persist-credentials: false across the workflow jobs.
Document release checkout exception
.github/workflows/release-auto.yml, CHANGELOG.md
Comments and the unreleased security entry document the intentional credential-persistence exception for git ls-remote --tags origin, audit coverage, and unchanged source/build behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 9
✅ Passed checks (9 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: hardening CI checkouts by dropping persisted Git credentials.
Linked Issues check ✅ Passed The PR matches #318 by hardening applicable checkout sites, preserving the trusted release-auto exception, and documenting the changes.
Out of Scope Changes check ✅ Passed The added changelog and release-auto comments support the requested hardening and do not introduce unrelated behavior changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Docs-As-Spec Sync ✅ Passed Only .github/workflows/release-auto.yml and CHANGELOG.md changed; no rustynes-* chip crates or docs/.md files were touched, so no docs sync was needed.
Changelog Entry For User-Visible Changes ✅ Passed CI/tooling-only workflow hardening; no user-visible behavior changed. CHANGELOG.md still adds an Unreleased Security note.
No Unwrap/Expect/Panic On Untrusted Input ✅ Passed Diff touches only .github/workflows/release-auto.yml and CHANGELOG.md; no new unwrap/expect/panic on untrusted input was introduced.
Safety Comment On New Unsafe Blocks ✅ Passed No new Rust unsafe blocks or unsafe fn appear in the PR; only workflow YAML and CHANGELOG changes were made.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/persist-credentials-hardening

Comment @coderabbitai help to get the list of available commands.

Copilot AI 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.

Pull request overview

Hardens GitHub Actions checkouts by preventing actions/checkout from persisting the workflow token into .git/config, reducing token-exfiltration risk from untrusted code paths executed during CI (build scripts, proc macros, tests, Gradle, shell scripts, docs builds).

Changes:

  • Add persist-credentials: false to actions/checkout@v7 across PR-reachable and release/dispatch workflows.
  • Preserve the single intentional exception in release-auto.yml and document why it retains persisted credentials.
  • Document the change in CHANGELOG.md.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
CHANGELOG.md Adds release-note entry describing the credential persistence hardening.
.github/workflows/web.yml Hardens the Pages build job checkout (high-permission token surface).
.github/workflows/security.yml Hardens checkouts for audit/deny/clippy-security jobs.
.github/workflows/release.yml Hardens checkout for the release build workflow.
.github/workflows/release-auto.yml Keeps persisted credentials for tag-existence check; adds rationale comment.
.github/workflows/pgo.yml Hardens checkout for PGO/BOLT workflows.
.github/workflows/ios.yml Hardens checkout for iOS workflow.
.github/workflows/ci.yml Hardens all CI checkouts (including changes job with fetch-depth: 0).
.github/workflows/android.yml Hardens checkouts for Android cross-build and Gradle packaging jobs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/release-auto.yml Outdated
Comment thread .github/workflows/release-auto.yml Outdated
Comment thread CHANGELOG.md Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release-auto.yml:
- Around line 74-81: Scope the credential exception to the checkout’s origin
credential: in .github/workflows/release-auto.yml lines 74-81, update the
comment to say this is the only operation requiring that credential, not the
only Git network operation. In CHANGELOG.md lines 52-64, change the statement
that no job needed persisted credentials to say all other jobs do not need the
checkout credential, and remove the broader Git-operation claim.
- Around line 83-88: Update the tag existence check in the release workflow to
distinguish a missing tag from lookup failures: either switch the check to the
specified gh api ref endpoint, or preserve git ls-remote while handling nonzero
errors separately and failing the workflow closed. Ensure only a confirmed
missing tag reaches gh release create, and transient authentication or network
failures cannot trigger publication.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f120365c-2fbd-4a0e-a5a0-c67bf2455b12

📥 Commits

Reviewing files that changed from the base of the PR and between e55e9f2 and fe020c6.

📒 Files selected for processing (9)
  • .github/workflows/android.yml
  • .github/workflows/ci.yml
  • .github/workflows/ios.yml
  • .github/workflows/pgo.yml
  • .github/workflows/release-auto.yml
  • .github/workflows/release.yml
  • .github/workflows/security.yml
  • .github/workflows/web.yml
  • CHANGELOG.md

Comment thread .github/workflows/release-auto.yml Outdated
Comment thread .github/workflows/release-auto.yml Outdated
Three accuracy corrections from Copilot and CodeRabbit on #319, all
adopted; none change behaviour.

1. "reads two text files" / "Cargo.toml + CHANGELOG.md" undercounted:
   the job also reads the optional .github/release-notes/vX.Y.Z.md
   override (L119). Both the pre-existing comment and mine now describe
   the set without pinning an exact count, so the security rationale
   cannot go stale the next time a file is added.

2. "the only real Git network operation in the whole .github/ tree" was
   too broad. fastlane match in ios.yml also clones over the network — it
   just uses its own MATCH_GIT_* secrets against a different remote, so it
   is unaffected by persist-credentials. Reworded to the claim that
   actually justifies the exception: the only operation needing THIS
   checkout's origin credential. The CHANGELOG had the mirrored problem,
   asserting no job needed the credential and then naming an exception;
   it now says every *hardened* job was confirmed not to need it.

3. The CHANGELOG claimed "every CI job compiles and runs" the checked-out
   tree. The audit and deny jobs do neither — they install prebuilt
   binaries and parse Cargo.lock — and overstating a security claim in
   release notes is exactly what should not happen.

Also corrected a claim in my own comment: a masked ls-remote failure was
described as publishing "a duplicate release". Per gh's documented
behaviour it would instead fail outright when a release already exists for
the tag, or publish against the pre-existing tag when one does not — noisy
or wrong, but not silently duplicated.

Declined in this PR (tracked in #318): converting the ls-remote to a
fail-closed gh api call. It is a real latent bug, but it changes the
release path, cannot be verified without a version cut, and does not
belong in a credential-hardening change. The comment records the safe
migration order.
@doublegate
doublegate merged commit 85ee20d into main Jul 21, 2026
26 checks passed
@doublegate
doublegate deleted the security/persist-credentials-hardening branch July 21, 2026 02:42
doublegate added a commit that referenced this pull request Jul 21, 2026
CodeRabbit flagged that the entry added here says release-auto.yml joined
the sweep while the #319 entry below it still said "18 of 19" and called
release-auto.yml a deliberate exception.

Correct, and the underlying problem is larger than the numbers: both
entries sit in the same [Unreleased] section, so they ship in one release
and a reader gets "we hardened 18 with one exception" followed by "...and
now the 19th". That is PR history leaking into release notes, which this
project explicitly does not want there ("Keep deep engineering/lineage
narrative out of the CHANGELOG"). Patching the count would have left two
entries narrating one coherent change.

Merged into a single entry describing the end state — all 19 checkouts
hardened, with the fail-closed tag check and the toolchain pin as the two
related items — and dropped the now-purely-historical intermediate
narrative. The audit facts worth keeping are retained verbatim:
rust-setup performs no checkout of its own, no job needed the credential,
and web.yml's build is the highest-exposure site.
doublegate added a commit that referenced this pull request Jul 21, 2026
#321)

* security(ci): fail-closed release tag check + pin the toolchain action

Finishes the two items #318 tracked that PR #319 deliberately deferred.
Reopened first: #318 had auto-closed one second after #319 merged, purely
from a `Closes #318.` keyword in that commit body, while both items were
still present on main. Deliberately no closing keyword here — the issue is
closed by hand once this is verified.

1. Fail-closed release-tag check
-------------------------------
`release-auto.yml` decided whether to publish a release with:

    git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1

That collapses three distinct outcomes — tag present, tag absent, lookup
failed — into a two-way answer, reading ANY non-zero exit as "absent". A
transient network or auth blip therefore pushed an already-released version
down the should_release=true path. This one call decides the entire release,
so guessing is the single thing it must not do.

Replaced with `gh api repos/$GITHUB_REPOSITORY/git/matching-refs/tags/$tag`.
`matching-refs` was chosen over the more obvious `git/ref/tags/$tag`
specifically because it answers "absent" with HTTP 200 and an empty array
rather than a 404 — a genuine miss can never look like an error, and no
error-body parsing is needed to tell the two apart.

`matching-refs` matches by PREFIX, so the exact ref is compared in jq. That
is load-bearing rather than defensive, and was verified against the live
API: `v2.2` prefix-matches two real tags (v2.2.0, v2.2.1) while exact-
matching none, so a naive non-empty check would have reported a nonexistent
tag as present.

Fail-closed behaviour verified under `shell: bash` + `set -euo pipefail`,
which is what the step actually runs: a `gh` HTTP error aborts the
assignment (exit 1), and a non-array body makes jq error under pipefail
(exit 5). Neither can resolve to a release decision. An unexpected match
count aborts explicitly. (Verifying this in the local zsh gave a false
"passes" result — zsh's `set -e` handling of `var="$(cmd)"` differs from
bash's, so the check must be exercised with `bash -c`.)

Because the check no longer touches Git, `release-auto.yml`'s checkout drops
its persisted credentials as well. It was the single documented exception in
#319's sweep, held back only by this ls-remote, so all 19 checkouts in the
repo are now uniform and the exception comment is gone rather than stale.

2. Pin dtolnay/rust-toolchain
-----------------------------
`.github/actions/rust-setup` used `dtolnay/rust-toolchain@master` — a BRANCH
ref that advances on every upstream commit, so every run silently resolved
to whatever HEAD happened to be, with no review window and no signal that
anything changed. Every other action in this repo is referenced by a `@vN`
tag, which is not expected to move.

The exposure is disproportionate: this composite is consumed by 12 of the
repo's 19 checkouts, including release.yml (contents: write, builds the
shipped binaries) and web.yml (pages: write + id-token: write), and it is
the action that installs the compiler — a position where a compromise is
very hard to detect from build output.

Pinned to e97e2d8cc328f1b50210efc529dca0028893a2d9, verified via the API as
the current target of upstream's refs/tags/v1. The trailing `# v1` is the
convention Dependabot's github-actions ecosystem reads; that ecosystem is
already enabled in .github/dependabot.yml, so this does not trade a supply-
chain risk for a stale-action one.

Deliberately still NOT done: SHA-pinning the remaining 12 actions. Those are
`@vN` tags rather than branch refs, so they carry materially less risk than
`@master` did, and a blanket pin is a policy decision about pin maintenance
rather than a drive-by fix.

Addresses #318.

* docs(changelog): consolidate the #318 hardening into one entry

CodeRabbit flagged that the entry added here says release-auto.yml joined
the sweep while the #319 entry below it still said "18 of 19" and called
release-auto.yml a deliberate exception.

Correct, and the underlying problem is larger than the numbers: both
entries sit in the same [Unreleased] section, so they ship in one release
and a reader gets "we hardened 18 with one exception" followed by "...and
now the 19th". That is PR history leaking into release notes, which this
project explicitly does not want there ("Keep deep engineering/lineage
narrative out of the CHANGELOG"). Patching the count would have left two
entries narrating one coherent change.

Merged into a single entry describing the end state — all 19 checkouts
hardened, with the fail-closed tag check and the toolchain pin as the two
related items — and dropped the now-purely-historical intermediate
narrative. The audit facts worth keeping are retained verbatim:
rust-setup performs no checkout of its own, no job needed the credential,
and web.yml's build is the highest-exposure site.
doublegate added a commit that referenced this pull request Jul 22, 2026
…sync

The template re-sync in 9875dab pulled the upstream reviewer over the hardened
version from 0237135 and silently reverted five of its fixes -- three of which
CodeRabbit had already marked "Addressed in commit 0237135" on this PR, so the
resolved threads no longer described the tree. This restores the hardening on
top of the re-sync's genuine improvements (job-level concurrency, the agy flock,
the retry loop), and closes the remaining review findings.

Trigger and execution gating (workflow)

  The `pull_request` fork check was dropped, so any fork PR could schedule work
  on the self-hosted runner. GitHub's "require approval for outside
  collaborators" is not a substitute: its default covers only FIRST-TIME
  contributors, so a returning outside contributor gets a runner on the
  maintainer's own hardware. The `head.repo.full_name == github.repository`
  condition is restored, alongside the `issue_comment` author_association gate.

  The checkout now takes the DEFAULT BRANCH rather than the PR head. The job
  executes the checked-out `scripts/agy-review.sh` with a token in scope, so
  checking out the PR let the reviewed change rewrite its own reviewer -- code
  execution on the runner from an untrusted diff. Review content is unaffected:
  the diff comes from the API via `gh pr diff`, not the working tree. The
  consequence is worth stating plainly: a PR that edits the reviewer or the
  style guide is reviewed by the version already on main. `persist-credentials:
  false` joins it per the repo-wide rule from PR #319, and the action moves to
  `actions/checkout@v7`, matching the other 19 checkout sites (this was the only
  `@v4` in the repo).

Token exposure at the agy boundary

  `--dangerously-skip-permissions` stays: it is required for headless
  operation -- without it agy blocks on an interactive approval no one is there
  to answer and the run times out. Removing the flag, as suggested, would break
  the feature rather than secure it. What the flag actually costs is the
  approval gate, so the agent could act on instructions embedded in the
  attacker-controlled diff it is reviewing; the exposure that matters is the
  GitHub token in the job environment. agy is therefore launched through
  `env -u GH_TOKEN -u GITHUB_TOKEN`. `gh` runs in this script, before and after,
  and agy never needs the token. `--sandbox` still confines filesystem and
  network access.

Command injection in the script(1) fallback

  The re-sync replaced the argv-safe `_agy_pty.py` wrapper with
  `script -qfec "... ${flags[*]}"`. `script -c` takes a command STRING and runs
  it through `sh -c`, so interpolating the flags array raw makes any space or
  metacharacter in AGY_MODEL / AGY_EFFORT / AGY_PRINT_TIMEOUT -- all
  env-settable -- into shell syntax. The command string is now assembled with
  `printf '%q '`, which emits a shell-safe rendering of each element. Verified:
  a flag value of `5m; touch /tmp/AGY_PWNED` now arrives as one literal argv
  element and the injected command does not run.

Single-argument size ceiling (a live E2BIG, not a hypothetical)

  The prompt reaches agy as ONE argv string, and Linux caps a single argument at
  MAX_ARG_STRLEN = 32 * PAGE_SIZE = 128 KiB -- a separate and far lower ceiling
  than ARG_MAX (4 MiB here). The workflow shipped `MAX_DIFF_BYTES: "200000"`,
  which exceeds it outright, and capping the diff alone would not be sufficient
  anyway since the instruction boilerplate and the style guide ride in the same
  string. Measured locally on this kernel: 131000 bytes execs, 131073 fails,
  200000 fails. The diff budget drops to 90 KB and a new MAX_PROMPT_BYTES
  (120 KB) caps the ASSEMBLED prompt.

Conversation-database fallback could publish an unrelated session

  The `ls -t | head -1` fallback takes the globally newest conversation DB, so
  when agy fails BEFORE creating a conversation it posts the last assistant
  message from whatever else ran on that host -- another repo's review, or the
  owner's interactive chat -- into a public PR comment. The search is now scoped
  with `find -newermt` to a DB this attempt actually touched; with none, the
  fallback yields nothing and the retry/failure path takes over. This also
  retires the `ls`-parsing shellcheck flagged as SC2012.

Prior-comment cleanup deleted by marker alone

  The HTML marker is plain text in a public comment and the filter ignored the
  author, so anyone who pasted the marker into a comment had it deleted on the
  next run -- and the job holds `issues: write`, so the delete succeeded.
  Deletion is now restricted to comments authored by `github-actions[bot]`.

Also: the log path gains a run-ID suffix (a fixed name collides between
concurrent jobs whenever RUNNER_TEMP is unset and /tmp is shared), the
script-side author_association re-check is restored as defense in depth against
a future workflow edit losing the gate, and the bare `|| true` around the agy
invocation now records the exit code -- it had erased the difference between
"agy failed" and "agy produced nothing".

CHANGELOG gains an [Unreleased] Added + Security entry. CI-only: no crate, no
shipped artifact, and nothing under crates/rustynes-{cpu,ppu,apu,mappers,core}
is touched, so the deterministic chip stack and every golden vector are
unchanged by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
doublegate added a commit that referenced this pull request Jul 22, 2026
* ci: add Antigravity PR reviewer (self-hosted, Ultra)

* ci(review): harden reviewer — gate triggers, argv-safe PTY, drop racy sqlite fallback

* ci(review): re-sync reviewer to template — job-level concurrency, agy flock + retry

* ci(review): re-harden the Antigravity reviewer after the template re-sync

The template re-sync in 9875dab pulled the upstream reviewer over the hardened
version from 0237135 and silently reverted five of its fixes -- three of which
CodeRabbit had already marked "Addressed in commit 0237135" on this PR, so the
resolved threads no longer described the tree. This restores the hardening on
top of the re-sync's genuine improvements (job-level concurrency, the agy flock,
the retry loop), and closes the remaining review findings.

Trigger and execution gating (workflow)

  The `pull_request` fork check was dropped, so any fork PR could schedule work
  on the self-hosted runner. GitHub's "require approval for outside
  collaborators" is not a substitute: its default covers only FIRST-TIME
  contributors, so a returning outside contributor gets a runner on the
  maintainer's own hardware. The `head.repo.full_name == github.repository`
  condition is restored, alongside the `issue_comment` author_association gate.

  The checkout now takes the DEFAULT BRANCH rather than the PR head. The job
  executes the checked-out `scripts/agy-review.sh` with a token in scope, so
  checking out the PR let the reviewed change rewrite its own reviewer -- code
  execution on the runner from an untrusted diff. Review content is unaffected:
  the diff comes from the API via `gh pr diff`, not the working tree. The
  consequence is worth stating plainly: a PR that edits the reviewer or the
  style guide is reviewed by the version already on main. `persist-credentials:
  false` joins it per the repo-wide rule from PR #319, and the action moves to
  `actions/checkout@v7`, matching the other 19 checkout sites (this was the only
  `@v4` in the repo).

Token exposure at the agy boundary

  `--dangerously-skip-permissions` stays: it is required for headless
  operation -- without it agy blocks on an interactive approval no one is there
  to answer and the run times out. Removing the flag, as suggested, would break
  the feature rather than secure it. What the flag actually costs is the
  approval gate, so the agent could act on instructions embedded in the
  attacker-controlled diff it is reviewing; the exposure that matters is the
  GitHub token in the job environment. agy is therefore launched through
  `env -u GH_TOKEN -u GITHUB_TOKEN`. `gh` runs in this script, before and after,
  and agy never needs the token. `--sandbox` still confines filesystem and
  network access.

Command injection in the script(1) fallback

  The re-sync replaced the argv-safe `_agy_pty.py` wrapper with
  `script -qfec "... ${flags[*]}"`. `script -c` takes a command STRING and runs
  it through `sh -c`, so interpolating the flags array raw makes any space or
  metacharacter in AGY_MODEL / AGY_EFFORT / AGY_PRINT_TIMEOUT -- all
  env-settable -- into shell syntax. The command string is now assembled with
  `printf '%q '`, which emits a shell-safe rendering of each element. Verified:
  a flag value of `5m; touch /tmp/AGY_PWNED` now arrives as one literal argv
  element and the injected command does not run.

Single-argument size ceiling (a live E2BIG, not a hypothetical)

  The prompt reaches agy as ONE argv string, and Linux caps a single argument at
  MAX_ARG_STRLEN = 32 * PAGE_SIZE = 128 KiB -- a separate and far lower ceiling
  than ARG_MAX (4 MiB here). The workflow shipped `MAX_DIFF_BYTES: "200000"`,
  which exceeds it outright, and capping the diff alone would not be sufficient
  anyway since the instruction boilerplate and the style guide ride in the same
  string. Measured locally on this kernel: 131000 bytes execs, 131073 fails,
  200000 fails. The diff budget drops to 90 KB and a new MAX_PROMPT_BYTES
  (120 KB) caps the ASSEMBLED prompt.

Conversation-database fallback could publish an unrelated session

  The `ls -t | head -1` fallback takes the globally newest conversation DB, so
  when agy fails BEFORE creating a conversation it posts the last assistant
  message from whatever else ran on that host -- another repo's review, or the
  owner's interactive chat -- into a public PR comment. The search is now scoped
  with `find -newermt` to a DB this attempt actually touched; with none, the
  fallback yields nothing and the retry/failure path takes over. This also
  retires the `ls`-parsing shellcheck flagged as SC2012.

Prior-comment cleanup deleted by marker alone

  The HTML marker is plain text in a public comment and the filter ignored the
  author, so anyone who pasted the marker into a comment had it deleted on the
  next run -- and the job holds `issues: write`, so the delete succeeded.
  Deletion is now restricted to comments authored by `github-actions[bot]`.

Also: the log path gains a run-ID suffix (a fixed name collides between
concurrent jobs whenever RUNNER_TEMP is unset and /tmp is shared), the
script-side author_association re-check is restored as defense in depth against
a future workflow edit losing the gate, and the bare `|| true` around the agy
invocation now records the exit code -- it had erased the difference between
"agy failed" and "agy produced nothing".

CHANGELOG gains an [Unreleased] Added + Security entry. CI-only: no crate, no
shipped artifact, and nothing under crates/rustynes-{cpu,ppu,apu,mappers,core}
is touched, so the deterministic chip stack and every golden vector are
unchanged by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(review): drop the shared conversation-store fallback; state the trust model

Two Major findings from the re-review of e5ccbc9, both adopted.

Remove the SQLite conversation-store fallback entirely

  The previous round scoped it with `find -newermt` against a per-attempt
  timestamp, which was an improvement but not the fix. mtime bounds a time
  WINDOW, not ownership: `$HOME/.gemini/antigravity-cli/conversations` is shared
  across every agy session on the host, and the flock in this script serializes
  review JOBS only -- it does nothing about the owner's own interactive agy usage
  on the same machine. Any session that touches a DB during the window could
  still be selected and posted into a PUBLIC pull-request comment.

  The sound version of this fallback needs a conversation ID or a private store
  tied to this invocation, and agy exposes neither. Relocating the store per run
  means relocating $HOME, which is where the OAuth session lives -- the thing
  that makes these reviews free under Ultra. Since ownership cannot be
  established, the fallback fails closed instead of guessing: no usable stdout
  means retry, then fail the job. Losing a review is recoverable (`/agy-review`
  re-runs it); publishing someone else's conversation is not.

  This deletes the fallback block along with the now-unused CONV_DIR and
  attempt_start. The PTY path (unbuffer, else script(1)) was always the actual
  mechanism; the store read was belt-and-suspenders for agy issue #76.

Stop presenting `--sandbox` as the isolation boundary

  The prior comment claimed "--sandbox still confines filesystem and network
  access." That overclaims. Upstream antigravity-cli#36 reports that
  `--dangerously-skip-permissions` can auto-approve the very prompts required to
  escape the sandbox, and there is a published prompt-injection -> RCE/sandbox-
  escape writeup against the CLI. The flag stays (defense in depth, no cost), but
  the comment no longer rests any argument on it.

  The invocation site now documents where the boundary actually is: the
  workflow's `if:`. A fork PR cannot schedule this job, and `/agy-review`
  requires OWNER/MEMBER/COLLABORATOR, so a same-repo branch means the diff was
  pushed by someone who already holds write access -- and has far more direct
  means available than prompt injection. No external party's diff reaches agy.
  That gate is the entire defense, and the comment says so in the imperative:
  weaken it (add `pull_request_target`, drop the fork check) and this becomes
  remote code execution on the maintainer's machine.

  The reviewer's recommended remedy -- an ephemeral account or VM -- is recorded
  as the prerequisite for ever opening this to untrusted diffs, rather than
  silently declined. It is incompatible with the $HOME-resident OAuth session
  today, which is a constraint worth writing down rather than rediscovering.

CHANGELOG's [Unreleased] Security entry is updated to match on both points.
CI-only; no crate and no emulation-core file is touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(review): close the fork-PR hole on the /agy-review comment path

The trust-model comment added in 675fa52 claimed "there is no path by which an
external party's diff reaches agy." That claim was false, and the review caught
it: the `issue_comment` branch authorizes the COMMENTER, never the DIFF. A
collaborator typing `/agy-review` on a fork PR scheduled this job against that
fork's diff, which `gh pr diff` fetches by number regardless of where the head
branch lives. So an external diff did reach agy -- under
--dangerously-skip-permissions, on the maintainer's own machine -- through the
single most natural use of the feature: a maintainer asking for a review of a
contributor's pull request.

The workflow `if:` cannot fix this. An `issue_comment` payload carries no
head-repo information at all -- only `.issue.pull_request` as a marker that the
comment is on a PR -- so the same-repo condition that guards the `pull_request`
trigger has nothing to evaluate. The check has to happen after the PR number is
resolved, which is here.

`gh pr view` now requests `isCrossRepository` alongside the title, and a fork PR
exits 0 with a message pointing at the alternatives (review by hand, or push the
branch into this repo). The metadata fetch is also now FAIL-CLOSED: it used to
fall back to `{}` on error, which was harmless when the only field read was the
title and is not harmless when the same document decides whether an untrusted
diff reaches agy. A lookup failure and "same-repo" must not be the same outcome.

Note the `jq` filter is `.isCrossRepository`, deliberately NOT
`.isCrossRepository // empty`: jq's `//` alternative fires on `false` as well as
on null, so the `// empty` form collapses the same-repo case into the unknown
case and refuses every legitimate review. Caught by table-testing the gate
against all four input shapes (true / false / `{}` / non-JSON) before pushing;
the first version had exactly that bug.

The trust model at the invocation site is rewritten to describe what is actually
enforced: the boundary is "agy only ever sees a same-repo diff", and it takes TWO
checks because neither trigger is covered by one -- the workflow for
`pull_request`, this script for `issue_comment`. Both are named as load-bearing,
with the failure mode spelled out, so a future edit cannot remove either without
reading why it exists. The CHANGELOG entry is corrected to match rather than
left restating the overstated version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
doublegate added a commit that referenced this pull request Jul 23, 2026
The Antigravity reviewer could not review this repository's larger pull
requests. Two independent ceilings stacked, and the second one failed
silently, which is the worse of the two.

GitHub's API refuses any diff over 20,000 lines with HTTP 406, so
`gh pr diff` failed outright on PR #325 (67,770 lines) and the check went
red six seconds in. Beneath that sat a second limit: the assembled prompt
reaches agy as one argv string, and Linux caps a single argument at
MAX_ARG_STRLEN = 32 * PAGE_SIZE = 128 KiB regardless of ARG_MAX, which is
where MAX_DIFF_BYTES=90000 came from. A diff over that budget was
truncated with `head -c`. For a 2.68 MB diff that is a review of the
first 3.3% of the bytes, published under a heading that reads as a review
of the pull request. A confident verdict over an arbitrary prefix is
worse than no verdict, because nothing in the output says which it is.

Rename detection does not rescue this and was measured rather than
assumed: `git diff -M` and `-M -C --find-copies-harder` both return
67,770 lines / 2,686,694 bytes, identical to plain `git diff`. The mapper
re-split rewrote each module (new `//!` preamble, a subset of the
original body), so git sees content change, not renames. Excluding
`Cargo.lock` saves about 3 KB. There is no compression available here;
the diff is genuinely that large.

The fix is to stop treating the argv ceiling as the review's ceiling.
agy is an agent with file-reading tools, so argv bounds only what can be
INLINED. Above MAX_DIFF_BYTES the diff is now written into agy's own
workspace and the prompt points at it:

  * `.agy-review-work/` sits inside the checkout deliberately, so it is
    already part of agy's workspace -- no `--add-dir`, no sandbox
    exception. It is gitignored, created per run, and removed by the exit
    trap, which is guarded on a flag rather than on the directory
    existing so it can never delete a pre-existing path.
  * The diff is split into AGY_DIFF_PART_BYTES (150 KB) parts rather than
    handed over as one 2.68 MB file. A single read that size is at the
    mercy of whatever per-read cap the agent applies, and a silent cap is
    precisely the failure being replaced. The prompt lists every part
    with its line and byte count, and asks for a
    `<!-- coverage: read N/M parts -->` header, so incomplete reading is
    visible in the output instead of invisible in it.
  * The prompt instructs that the parts are consecutive slices of one
    file -- a hunk header can straddle a boundary -- and that an honest
    partial review beats a confident review of an unread diff.
  * AGY_PRINT_TIMEOUT_LARGE (25m) applies only on this path, and only
    when the caller did not set AGY_PRINT_TIMEOUT explicitly. An inlined
    diff arrives with the prompt; a handed-off one must be read first,
    one tool call per part, before reasoning starts. Timing that out
    would reproduce the empty-review failure from the other direction.

Above that, the 406 itself is now distinguished from a genuine failure
(auth, network, bad PR number, which stay fatal) and falls back to a
local `git diff`. It fetches `refs/pull/N/head` and the base branch into
private `refs/agy/*`, computes the merge base, and diffs -- fetching
objects but never checking them out, so the working tree stays on the
default branch and a PR still cannot rewrite its own reviewer. The refs
are deleted by the same exit trap.

That fetch needs credentials without violating the repo-wide
`persist-credentials: false` rule (PR #319), and it needs them in two
different environments. `AUTHORIZATION: bearer` via `http.extraheader` is
what Actions' GITHUB_TOKEN accepts and is tried first; it is rejected
outright by a personal token from `gh auth token` ("remote: invalid
credentials"), so a hand-run falls through to a plain fetch using git's
configured credential helper. Neither path writes anything into
.git/config.

Verified end-to-end against PR #325 through a new AGY_DRY_RUN escape
hatch, which exercises diff acquisition and prompt assembly without
spending an agy run or posting a comment: the API 406 is caught, the
local fallback produces 67,770 lines / 2,686,694 bytes, 18 parts are
written whose sizes sum to exactly the diff, and the assembled prompt is
4,322 bytes against a 120,000-byte cap. shellcheck reports nothing beyond
the pre-existing SC1007 on line 93; actionlint is clean.

Note on rollout: the workflow checks out the default branch, never the PR
head, so this fix does not apply to the check on the PR that introduces
it -- that job keeps running main's copy of the script until this merges.
That is the security property working as designed, not a regression.
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.

Harden actions/checkout credential persistence across all workflows

2 participants