Skip to content

Harden pr-merge's run-cache restore in the token-holding jobs #9455

Description

@kyle-sexton

Problem

Two jobs of .github/workflows/pr-merge.yml restore the pr-merge-run-cache-* actions/cache entry by bare prefix (restore-keys: pr-merge-run-cache-) after they hold the lanes App merge token:

Any job in the default branch's cache scope can write a newer key under that prefix. carry-run-cache.mjs already filters the restored JSON, so a poisoned entry's contents can only make the gate refuse; they cannot forge a pass.

Two risks are not covered:

  • Extraction path (UNVERIFIED). The security review of fix(pr-merge): require a pr-verify pass before a lane pull request merges natively #9450 raised this concern from published cache-poisoning research (Cacheract): actions/cache may extract a crafted archive with entries outside the declared path. If it does, a poisoned entry could overwrite files under .base/ (for example carry-run-cache.mjs or enable-auto-merge.mjs) before they run with the lane token and id-token: write. This has not been verified for actions/cache/restore v6.1.0.
  • Who can write the key. It has not been checked which default-branch jobs could be made to run attacker-influenced code that writes that key.

Proposal

Pick one, after verifying the extraction behaviour:

  1. Verify the archive. Restore into an empty directory in a step or job that holds no token. List the archive entries, refuse any path outside the single expected file, and only then copy the file into place.
  2. Move to the artifact-read pattern. Use the pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 uses for the head-ref map: an artifact uploaded only by a scheduled or hand-started run of main's pr-merge.yml, found through that run's record. That removes the shared cache scope from the trust path.

Either change should land in both jobs together.

Done when

  • The extraction behaviour of the pinned cache restore is verified, and the source is cited.
  • Neither token-holding job extracts a cache archive a non-pr-merge job could write, or the archive's paths are checked first.
  • carry-run-cache.mjs's content filter is kept.

Refs: #9450

Activity

  1. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 23, 2026
  2. github-actions commented on Oct 23, 2026

    @github-actions
    Contributor

    Proposed work class: none

    Thanks for the report. Automated triage handed this issue to a maintainer.

    Automated triage. A maintainer reviews it.

  3. added
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    and removed
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 23, 2026
  4. kyle-sexton commented on Oct 23, 2026

    @kyle-sexton
    MemberAuthor

    The claude-security-review lane on #9450 (#9450 (comment)) suggests an integrity check on the carried run cache: an HMAC, or a check that a main-branch pr-merge run saved it. It also suggests reading the cache back through the artifact path. Today the carried sections can only count a FAIL (carry-run-cache.mjs:9-11; Reader._run_state reads a PASS's run fresh), so this protects the refuse direction and the extraction path, not the pass direction.

  5. kyle-sexton commented on Oct 26, 2026

    @kyle-sexton
    MemberAuthor

    Owner question (security), not a decision.

    Owner question (security): The body's two unverified risks were checked this session. The extraction risk is verified; the writer chain holds up to one link not read (item 4). Rated HIGH: a chain ends in arbitrary code in a job that holds the lanes App merge token and id-token: write. This record decides nothing else.

    Evidence:

    1. The pinned restore extracts outside path. actions/cache/restore@55cc8345… is v6.1.0 and depends on "@actions/cache": "^6.1.0" (https://raw.githubusercontent.com/actions/cache/55cc8345863c7cc4c66a329aec7e433d2d1c52a9/package.json). Its bundled dist/restore-only/index.js:94080-94082 extracts with -xf <archive> -P -C <GITHUB_WORKSPACE> (:94128-94131), and :94413-94418 lists the archive only in debug mode, then extracts it with no entry filter. With -P, an archive entry keeps its own path, so an entry such as .base/.github/scripts/pr-merge/carry-run-cache.mjs lands in the checkout, not under path.
    2. The restore runs where that matters. In .github/workflows/pr-merge.yml, the merge job holds id-token: write (:456), restores by bare prefix restore-keys: pr-merge-run-cache- (:756-760), and the next step runs node .base/.github/scripts/pr-merge/carry-run-cache.mjs (:768). The enable-auto-merge job does the same (:1444, :1553-1565). Both restores come after the lane token step (:605, :1522). The file's own comment already says "Any job in the default branch's cache scope can write that prefix" (:746).
    3. Head code runs in a job that can write the default-branch scope. GitHub's docs say "only these workflow triggers can create or overwrite caches in the default branch's scope", listing push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build and schedule, and that cache-mode "controls the cache access granted to the job's token" (https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching, read 2026-10-26). pr-unblock.yml runs on workflow_dispatch (:35) and calls pr-run-activity-read.yml (:318 and five more call sites), whose run job runs PR head code (pr-run-activity-read.yml:15-16) on a runner the file calls hostile (:216). pr-refine.yml does the same (:25, :126, :200). No job in those files sets cache-mode; the only one on main is work-resolve-issue.yml:132 (cache-mode: read).
    4. Not read this session (UNVERIFIED): whether head code in that run job can obtain the job's cache-write token and save an entry. The published cache-poisoning research the body cites (Cacheract) claims it can; its source was not read here.

    The carried JSON's content filter (carry-run-cache.mjs) does not cover this: a crafted archive replaces the script before it runs.

    Options, each a .github/workflows/** edit (.github/actions/resolve-config/patch-rules.json:64 refuses github-workflows for lane patches, so a host session builds it):

    • A. Body option 2: move the carry to the artifact-read pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 uses, so neither token-holding job restores a cache. Removes the shared cache scope from the trust path.
    • B. Body option 1: restore into an empty directory in a job with no token, list the archive, refuse any entry but the one file, then hand the file over.
    • C. Set cache-mode: none (documented on the page above) on every job that runs PR head code, so none can write the default-branch scope. Narrows the writers but keeps the prefix restore.
    • Recommended: A, as the body proposes. Basis: it removes the cause in both jobs and reuses an existing pattern (DRY); C is a cheap extra layer, not a substitute, since any later head-code job without the key reopens the path.
  6. removed
    status: needs-decisionAwaiting a human or maintainer judgment call.
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 26, 2026
  7. kyle-sexton commented on Oct 26, 2026

    @kyle-sexton
    MemberAuthor

    Released for re-triage under the owner's 2026-10-26 rule: a person is involved only for "1. A change in original intent/vision 2. Something comes up during implementation ... that alters intent/vision or the primary design 3. If there is a HIGH/CRITICAL security issue". None applies here, so the hold label came off and intake triage runs again now.

    To hold this issue again, add needs-human (or status: needs-decision) and name which of the three applies.

  8. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    and removed
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 26, 2026
  9. melodic-automation-lanes commented on Oct 26, 2026

    @melodic-automation-lanes
    Contributor

    The triage verdict left this issue at status: needs-decision:

    • no Size: line
    • no Complexity: line
    • no Uncertainty: line
    • no Flags: line
    • the triage run escalated
    • held by needs-human
    • no lanes App verdict comment shows whether an earlier verdict was flagged
  10. kyle-sexton commented on Oct 26, 2026

    @kyle-sexton
    MemberAuthor

    Decision recorded. The owner answered in the Refinement session (2026-10-26), choosing option A for this HIGH finding: "A: artifacts (Recommended)" (carry the run data the way #9416 does, so neither token-holding job restores a cache).

    This is a HIGH security finding (rated in the evidence below). Decided: option A, the hardening option, which removes the cause and loosens nothing.

    Evidence:

    1. The pinned restore extracts outside path. actions/cache/restore@55cc8345… is v6.1.0 and depends on "@actions/cache": "^6.1.0" (https://raw.githubusercontent.com/actions/cache/55cc8345863c7cc4c66a329aec7e433d2d1c52a9/package.json). Its bundled dist/restore-only/index.js:94080-94082 extracts with -xf <archive> -P -C <GITHUB_WORKSPACE> (:94128-94131), and :94413-94418 lists the archive only in debug mode, then extracts it with no entry filter. With -P, an archive entry keeps its own path, so an entry such as .base/.github/scripts/pr-merge/carry-run-cache.mjs lands in the checkout, not under path.
    2. The restore runs where that matters. In .github/workflows/pr-merge.yml, the merge job holds id-token: write (:456), restores by bare prefix restore-keys: pr-merge-run-cache- (:756-760), and the next step runs node .base/.github/scripts/pr-merge/carry-run-cache.mjs (:768). The enable-auto-merge job does the same (:1444, :1553-1565). Both restores come after the lane token step (:605, :1522). The file's own comment already says "Any job in the default branch's cache scope can write that prefix" (:746).
    3. Head code runs in a job that can write the default-branch scope. GitHub's docs say "only these workflow triggers can create or overwrite caches in the default branch's scope", listing push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build and schedule, and that cache-mode "controls the cache access granted to the job's token" (https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching, read 2026-10-26). pr-unblock.yml runs on workflow_dispatch (:35) and calls pr-run-activity-read.yml (:318 and five more call sites), whose run job runs PR head code (pr-run-activity-read.yml:15-16) on a runner the file calls hostile (:216). pr-refine.yml does the same (:25, :126, :200). No job in those files sets cache-mode; the only one on main is work-resolve-issue.yml:132 (cache-mode: read).
    4. Not read this session (UNVERIFIED): whether head code in that run job can obtain the job's cache-write token and save an entry. The published cache-poisoning research the body cites (Cacheract) claims it can; its source was not read here.

    The carried JSON's content filter (carry-run-cache.mjs) does not cover this: a crafted archive replaces the script before it runs.

    Options, each a .github/workflows/** edit (.github/actions/resolve-config/patch-rules.json:64 refuses github-workflows for lane patches, so a host session builds it):

    • A. Body option 2: move the carry to the artifact-read pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 uses, so neither token-holding job restores a cache. Removes the shared cache scope from the trust path.
    • B. Body option 1: restore into an empty directory in a job with no token, list the archive, refuse any entry but the one file, then hand the file over.
    • C. Set cache-mode: none (documented on the page above) on every job that runs PR head code, so none can write the default-branch scope. Narrows the writers but keeps the prefix restore.
      Decided: A. It removes the shared cache scope from the trust path of both token-holding jobs and reuses the artifact-read pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 already uses (DRY). C may be added as a second layer; it is not a substitute.

    Build: host session

    Acceptance criteria:

    • Neither the merge nor the enable-auto-merge job in .github/workflows/pr-merge.yml runs actions/cache/restore; the carried run data reaches them through the artifact-read pattern perf(source-control): carry untied FAIL head refs between pr-merge runs #9416 uses.
    • No job that holds id-token: write or a lanes App token restores a cache by a bare restore-keys prefix.
    • carry-run-cache.mjs reads only the carried file, from a path no extracted archive can write.
    • A test or workflow check fails if a token-holding job in pr-merge.yml adds a cache restore.
  11. added
    status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.
    and removed
    status: needs-decisionAwaiting a human or maintainer judgment call.
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions