Post

CI cache access is a trust boundary

GitHub Actions cache-mode looks like a small CI option, but it makes an important idea explicit: build caches are a capability boundary between untrusted and trusted automation.

CI cache access is a trust boundary

GitHub shipped a new YAML key this month. It is called cache-mode, which is exactly the kind of name that makes most of us file it under “small CI improvement” and move on with our day.

That would be a mistake.

The interesting part is not that a workflow can now say read, write, write-only, or none for its cache access. The interesting part is the thing this forces us to admit: a build cache is not only a performance feature. It is a cross-run channel through which one execution can influence another.

Once a low-trust execution can affect something a high-trust execution later restores and runs, that cache is a trust boundary. Treating it as plumbing is how teams end up discovering that their fastest CI optimization was also a surprisingly good privilege-escalation path.

the old mental model was incomplete

Caches have a good reputation because they solve a boring, real problem. Downloading dependencies on every run is slow, expensive, and irritating. A warm dependency cache makes pull requests friendlier and reduces runner minutes. Nobody argues with that.

So cache configuration usually lands in the “developer experience” bucket alongside parallel jobs and nicer test output. It gets set up once, copied into the next repository, and ignored until a lockfile change makes CI mysteriously slow.

The missing question is: who is allowed to shape the bytes that another workflow will trust?

That is not a cache-key question. It is an authorization question.

GitHub’s own description of cache poisoning is refreshingly direct: malicious content placed in a cache by a lower-privilege workflow can lead to code execution in a more privileged one. In other words, a cache can be a sideways movement path inside a repository: contributor-controlled context on one side; release credentials, deployment permissions, or package-publishing authority on the other.

The risk does not require a cartoonishly bad workflow. A job can be unsafe because it runs code influenced by an external pull request, a pull request title, a commit message, or an issue comment. If that job has a route to write a cache used by a trusted push, schedule, or release workflow, the cache becomes the bridge.

That is why “but the cache key includes the lockfile hash” is not a complete answer. Good keys matter. So do short lifetimes, content validation, and not caching executable build output near sensitive jobs. But none of those change the basic question of authority: which execution is allowed to save state that another execution may consume?

GitHub finally gives the boundary a name

GitHub started tightening this in June by issuing read-only cache tokens for a set of untrusted, default-branch-scoped triggers. Its October general-availability release turns the policy into an explicit workflow control: cache-mode can be set for an entire workflow or overridden per job.

The modes are deliberately simple:

ModeRestoreSaveA good default use
readYesNoUntrusted validation that benefits from known-good dependency state
writeYesYesTrusted builds that legitimately maintain a cache
write-onlyNoYesA dedicated warming job that must not consume previous state
noneNoNoSensitive jobs where a cache adds more risk than value

The implementation detail that matters is that this is enforced by scoped cache tokens. It is not a polite convention for actions/cache to follow. A job without save permission cannot save because the cache service rejects that capability.

There is another useful property: a job-level setting overrides the workflow-level setting, and a called reusable workflow cannot receive more cache access than its caller grants. Reusable workflows are often treated like shared YAML; they should be reviewed more like an API that receives capabilities.

Here is the kind of policy I want to see more often:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
name: pull request checks

on:
  pull_request:

cache-mode: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test

And separately, a trusted path can own cache maintenance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: cache warmer

on:
  push:
    branches: [main]

jobs:
  warm-npm-cache:
    runs-on: ubuntu-latest
    cache-mode: write-only
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - run: npm ci

The exact workflow split will vary. The principle should not: restore and save are different powers, and they deserve different defaults.

this deserves the same treatment as GITHUB_TOKEN

We already know how to think about token permissions. A workflow should have the smallest set of privileges required for its job. We review increases. We set a narrow default and make exceptions obvious in the diff.

Cache access now belongs in that same mental model.

I would add a short cache policy to every repository that runs meaningful CI:

  1. Classify triggers before writing YAML. Ask whether the event, its checked-out code, and the values interpolated into scripts can be influenced by people without repository write access.
  2. Make low-trust workflows read or none. read is useful when dependencies are safe to restore and the speed gain is worth it. none is a perfectly respectable answer for unusually sensitive jobs.
  3. Reserve write for trusted, boring maintenance paths. push to protected branches is the common case. A release job is not automatically a good cache writer just because it is trusted; it is often better with no cache at all.
  4. Use write-only deliberately. It is ideal when a job exists to populate a cache but should not execute or depend on prior cached state.
  5. Set the boundary at the caller of reusable workflows. A reusable workflow should receive only the cache capability its caller intends to delegate.
  6. Review cache changes like permission changes. A diff that turns read into write should make a reviewer ask “why does this execution now get to influence future runs?”

GitHub warns when write or write-only is declared on low-trust triggers because that can override the secure read-only default and put the poisoning path back. That warning is welcome, but it cannot infer whether your event context is actually safe. The workflow author and reviewer still own that decision.

the AI angle is real, but it does not need drama

This is not another “AI will destroy the software supply chain” post. The supply-chain risks here existed before anyone was asking an agent to fix a flaky workflow.

AI changes the economics, though.

Coding agents can modify workflow YAML, dependency files, and cache keys at a rate that makes casual review much less reliable. They are good at producing plausible CI changes because CI YAML is repetitive and examples are everywhere. They are also very capable of copying a cache stanza from a trusted push workflow into a pull-request workflow without carrying over the threat model that made the original placement acceptable.

This is exactly why implicit security policy ages badly. A convention that lives only in the heads of two staff engineers does not survive a repository where humans, bots, and agents are all proposing automation changes.

Explicit cache-mode gives teams a small, reviewable statement of intent. If the policy says pull requests are read and releases are none, a generated change that asks for write has to explain itself. That is not bureaucracy. It is making a security-relevant exception visible where it belongs: next to the workflow that needs it.

The same applies to agentic workflows that react to issues, comments, or pull-request content. Those inputs are often untrusted text. Before optimizing them with cache reuse, determine whether they can run code, alter paths, influence keys, or reach a cache scope that a privileged workflow later restores. Treat the answer as a design decision, not a speed tweak.

do the boring audit

This is a good weekend-hardening task because the first pass is pleasantly mechanical.

List the workflows. For each job that restores or saves a cache, write down its trigger, what it checks out or executes, its cache mode, and what more privileged jobs restore the same class of cache. Then look for the uncomfortable arrows: untrusted execution to cache write; cache restore to secrets; reusable workflow with a broader capability than its caller needs.

Do not try to make every job equally locked down. That produces either slow CI or a policy nobody follows. Instead, give every job the cache access it actually needs and document the few exceptions. The outcome should be boring enough that a new maintainer can understand it in one review.

That is the larger lesson from a very small YAML key. An optimization that crosses trust levels is a security control. Caches, artifacts, shared workspaces, package registries, and generated metadata all deserve the same question: who can write the state, and who will later trust it?

Once you start asking that question, cache-mode stops looking like CI housekeeping. It looks like what it is: a capability boundary that was always there, now finally visible.

references

To test my projects, I use Railway. If you want $20 USD to get started, use this link.

This post is licensed under CC BY 4.0 by the author.