News

GitHub Actions cache-mode: Stop Low-Trust Workflows From Poisoning the Cache Your AI Pipeline Trusts

Coding agents work through pull requests and comments, so the workflows around them tend to run on events that should restore a cache but not save one. GitHub now lets you set cache access per workflow and per job. Map the effective mode for every trigger and job, then prove the boundary with two fixtures and a positive control.

2026-09-11

Dark build hall with a tall glass enclosure holding shelves of crates beside a robotic arm on an assembly line, and a separate raised glass box with a single amber-lit crate, outlined by cyan light along the floor.
Illustration: restoring from a shared cache and saving to it are separate permissions, and cache-mode now sets them per job.

What Changed on September 10

GitHub's changelog of September 10, 2026 introduces cache-mode, a workflow key for least-privilege access to the Actions cache. GitHub says it is generally available on github.com for all GitHub plans. You can set it at the top of a workflow, on a single job, or both; the job-level value wins for that job.

ValueRestore cachesSave caches
readYesNo
writeYesYes
write-onlyNoYes
noneNoNo

The workflow syntax reference says access is enforced with scoped cache tokens, so a job cannot restore or save beyond its mode. Workflows that omit the key keep GitHub's existing defaults. The new exposure is a declaration: an explicit write or write-only on a low-trust trigger overrides the read-only default, and GitHub adds a warning annotation when that happens. An agent that edits workflow files can add that one line.

Why Agent Traffic Hits Low-Trust Triggers

The dependency caching reference is blunt about the payload: cache contents are not signed or verified, and an extracted cache may modify files that a later step executes. A run can restore caches from its own branch or the default branch, and a pull request run also from its base branch. A single default-branch entry is therefore shared by many later runs, including privileged ones.

Seven triggers can create or overwrite caches in the default branch's scope: push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build and schedule. Runs from any other event that resolves to the default branch get read-only access there. GitHub names pull_request_target, issue_comment and workflow_run as examples. Comment commands, bot integrations and chained workflows around AI pull requests often run on exactly these events.

Plain pull_request is not affected by that restriction. Its caches are created for the merge ref, refs/pull/.../merge, and can be restored by re-runs of that pull request. The docs do not put it in the trusted or low-trust column, so do not guess its default; record it from a run.

Build the Trigger-by-Trigger Inventory

The effective mode for one job on one event is the job's cache-mode, else the workflow's, else the trigger default: write for the seven triggers above and read for low-trust events on the default branch. Inventory per trigger and job, not per file. A workflow that listens to both push and issue_comment has two different defaults for the same job.

Reusable workflows need their own column. According to the caching reference, when the calling job neither sets nor inherits an explicit cache-mode, the called workflow can explicitly request write even though the caller's low-trust trigger defaults to read. Setting cache-mode: read on the calling job caps it. That makes an unset caller the gap to look for first.

# cache_mode_inventory.py - run from the repository root; needs PyYAML
import pathlib, sys, yaml

TRUSTED = {"push", "workflow_dispatch", "repository_dispatch", "delete",
           "registry_package", "page_build", "schedule"}

def events(doc):
    on = doc.get("on", doc.get(True))  # PyYAML loads a bare on: key as True
    return [on] if isinstance(on, str) else list(on or [])

def default_for(event):
    if event in TRUSTED:
        return "write (trusted default)"
    if event == "pull_request":
        return "not classified; record ACTIONS_CACHE_MODE"
    if event == "workflow_call":
        return "capped by caller"
    return "read (low-trust default on default branch)"

rows = []
for path in sorted(pathlib.Path(".github/workflows").glob("*.y*ml")):
    doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    for event in events(doc):
        for job_id, job in (doc.get("jobs") or {}).items():
            explicit = job.get("cache-mode", doc.get("cache-mode"))
            flag = ""
            if explicit in ("write", "write-only") and event not in TRUSTED | {"pull_request", "workflow_call"}:
                flag = "EXPLICIT WRITE ON LOW-TRUST TRIGGER"
            elif "uses" in job and explicit is None and event not in TRUSTED:
                flag = "UNCAPPED REUSABLE CALL"
            rows.append((path.name, event, job_id, explicit or default_for(event), flag))

for row in rows:
    print(" | ".join(row))
sys.exit(1 if any(row[4] for row in rows) else 0)

The comment on events() is not decoration. PyYAML's documentation shows on and off loading as booleans, so a naive doc["on"] lookup silently finds no triggers and prints an empty, reassuring inventory. The script is a static reading of your files. The runtime truth for a job is the ACTIONS_CACHE_MODE environment variable, which the runner sets to the effective mode and which actions/cache honors. The fixtures below print it.

Fixture 1: A Low-Trust Run Cannot Save

Use a disposable repository with no secrets and commit these files to its default branch. The probe and the control are identical except for the workflow name and trigger; the event name goes into both the marker and the key, so neither file hard-codes its own result.

# .github/workflows/lowtrust-save-probe.yml
name: Low-trust save probe
on:
  issue_comment:
    types: [created]
permissions:
  contents: read
jobs:
  probe:
    runs-on: ubuntu-latest
    steps:
      - run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
      - run: mkdir -p probe && echo "${{ github.event_name }}-${{ github.run_id }}" > probe/marker.txt
      - uses: actions/cache@v4
        with:
          path: probe
          key: cache-mode-probe-${{ github.event_name }}-v1
# .github/workflows/trusted-save-control.yml
name: Trusted save control
on:
  workflow_dispatch:
permissions:
  contents: read
jobs:
  probe:
    runs-on: ubuntu-latest
    steps:
      - run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
      - run: mkdir -p probe && echo "${{ github.event_name }}-${{ github.run_id }}" > probe/marker.txt
      - uses: actions/cache@v4
        with:
          path: probe
          key: cache-mode-probe-${{ github.event_name }}-v1

Do not read the probe's status as evidence. GitHub documents that a blocked or skipped save leaves the step and job running, with a log message instead of a failure, so the low-trust run should finish green. Verify from a third, trusted run that itself cannot write:

# .github/workflows/verify-cache-probe.yml
name: Verify cache probe
on:
  workflow_dispatch:
cache-mode: read
permissions:
  contents: read
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
      - id: lowtrust
        uses: actions/cache/restore@v4
        with:
          path: probe
          key: cache-mode-probe-issue_comment-v1
      - id: trusted
        uses: actions/cache/restore@v4
        with:
          path: probe
          key: cache-mode-probe-workflow_dispatch-v1
      - run: |
          echo "low-trust hit=${{ steps.lowtrust.outputs.cache-hit }} trusted hit=${{ steps.trusted.outputs.cache-hit }}"
          test "${{ steps.trusted.outputs.cache-hit }}" = "true"
          test "${{ steps.lowtrust.outputs.cache-hit }}" != "true"

Dispatch the control, post a comment on an issue in the test repository, wait for both runs to finish, then dispatch the verifier. Keep path: probe identical in every file: GitHub stamps a cache version with the path, so a restore with a different path can miss and look exactly like a blocked save. The trusted hit is your positive control; without it, a broken key or a missing save would pass the test.

Fixture 2: An Over-Requesting Callee Fails

Add a called workflow that asks for more than a low-trust caller should grant, and a caller that caps it:

# .github/workflows/callee-write.yml
name: Callee requesting write
on: workflow_call
cache-mode: write
jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
# .github/workflows/capped-caller.yml
name: Capped caller
on:
  issue_comment:
    types: [created]
permissions:
  contents: read
jobs:
  call:
    cache-mode: read
    uses: ./.github/workflows/callee-write.yml

The caching reference says a called workflow that declares access beyond the caller's explicit limit stops the run before it starts, with a validation error. For the control, delete the single cache-mode: read line from the caller and comment again. The docs say the unset caller lets the callee request write. The docs do not say whether the callee must declare its mode at workflow or job level, so record which form you tested.

CaseTriggerDeclaredExpected evidence
Trusted controlworkflow_dispatchNothingLog shows write; verifier trusted hit is true
Low-trust saveissue_commentNothingLog shows read; job green; verifier low-trust hit is not true
Capped callissue_commentCaller read, callee writeRun does not start; validation error retained
Uncapped controlissue_commentCallee writeRun starts; callee log shows write

These are expected results from GitHub's documentation, not results observed for this article. If a row differs, keep the run URL and log and stop before changing production workflows.

Close the Gaps Before Agents Scale Up

For every low-trust row, GitHub's mitigations are specific. Put an explicit cache-mode: read on the job, or none if it does not need the cache. Switch the job to actions/cache/restore, and let a push-triggered workflow maintain the entry. For every UNCAPPED REUSABLE CALL, add cache-mode: read to the calling job. If a low-trust job truly must write, the docs advise limiting that to jobs that process no untrusted input, including code checked out from forks and pull requests, before saving. Treat the resulting cache as untrusted everywhere, and do not restore it into runs with secrets or elevated permissions.

Keep secrets out of cached paths as well: GitHub notes that anyone who can open a pull request can read caches in the base branch. The caching step in the AI-assisted CI/CD guide makes builds faster; this inventory decides which triggers may save that cache.

Make the warning annotation a review blocker when an AI-authored pull request touches .github/workflows/. Rerun the inventory script on that change. Pair it with the reusable workflow identity fixture so a reviewer knows which callee ran and how much cache access it had. The deliverable is the four-row table with run URLs, plus an inventory that exits 0.

Sources checked 2026-09-11