CI runner security when an agent writes your workflow
10m read time

CI runner security when an agent writes your workflow

Self-hosting your forge and your runner is a defensible call in 2026. Nobody writes the CI runner security guide for the platform you moved to, and an agent permission model built around a dialog does not survive the trip into a pipeline.

The pipeline in this repository is one day old.

Before Saturday there was nothing. Every gate ran on my machine, which is how an undeclared dependency sat in eslint.config.js long enough to become invisible: it passed on the one laptop that happened to have a stray copy of it, and nothing else ever looked.

So I wrote a workflow. Lint, format, types, markdown, unit tests, build, end-to-end, then a second job that builds the image and proves it starts. Ordinary work, an hour of it.

What I actually did was give this project a second computer that runs my code, and it is not my laptop.

Running it yourself is a defensible call

I run Forgejo, self-hosted, with its own runner. Nothing about the last few months has made me want to move back.

GitHub's own availability report for July 2026, published on 12 August, lists eight incidents in a single month. On 19 July, over five hours and ten minutes, "78.99% of larger-hosted jobs, 29.8% of scale-set jobs, and 8.7% of self-hosted jobs took more than five minutes to acquire a runner". On 25 July, in the worse of two windows: "At peak, 30% of runs were delayed by more than five minutes, and 60% of runs failed with an infrastructure error". These are GitHub's figures about GitHub, which is why they are worth more than anybody's outage tracker.

Read that 8.7% carefully, because it is the honest version of the self-hosting argument. What was slow was acquiring a runner, and self-hosted jobs came through the best of the three while still coming through it. The machine was on someone's own hardware. The control plane that hands it work still sat with GitHub.

The supply chain points the same way. Every large campaign this year landed on GitHub, because that is where the ecosystem is. On 22 July, Socket reported 583 malicious workflow files across ten packages, pushed into a PHP developer's GitHub repositories after their account was taken over and synchronised outward through Packagist between 12 and 13 July. The workflows existed to turn other people's CI runners into what Socket calls "disposable scanning and exploitation infrastructure", aimed at cPanel and WHM servers.

On 14 July, Wiz documented an attacker opening 37 pull requests against the AsyncAPI generator, one of which hit a workflow that "used pull_request_target to trigger on pull requests, but then checked out the pull request's code rather than the base branch". The payload scanned the runner's environment for secrets and posted them to a pastebin, which is the same egress problem I once put a proxy in front of to watch, running on a machine where nobody is watching anything.

Moving off the biggest target removes you from a lot of other people's blast radius. That is a real gain and I would make the trade again.

The gain comes with homework nobody has written down for you.

The hardening advice assumes a platform

Open any GitHub Actions hardening guide and the first item is the same: set a least-privilege permissions: block on every workflow.

My workflow file does not have one. It has this comment instead:

yaml
# No `permissions:` block: Forgejo does not support one and warns that it is
# being ignored, which is noise on every run. Capabilities are granted through
# Authorized Integrations instead.

I found that gap the moment I tried to follow the advice, long before this post, and I wrote the reason down because a future me would otherwise add the block back. The control exists in the guide and nowhere on my platform.

Before that reads as a mark against self-hosting, look at what the same control is worth on GitHub. Anthropic's own documentation for the Claude Code GitHub Action lists eleven permissions the Claude GitHub App requests, including Actions read-and-write and Workflows read-and-write, and then says plainly: "When you install the app, you accept its full permission set. GitHub doesn't let you accept a subset." The action itself uses three of them.

So you can write permissions: contents: read at the top of a workflow with real care, and have installed, in the same repository, an integration that can rewrite workflow files and re-run jobs. Both platforms teach the same lesson from opposite ends. What runs is decided by the integrations you granted, and the workflow file only describes the job.

There is a second one specific to a self-hosted forge, and it is the sort of thing an agent walks straight into. In my file the steps read uses: actions/checkout@v4, uses: actions/setup-node@v4, uses: docker/build-push-action@v5. Those names look like GitHub, and they resolve to whatever host my instance is told to use. Forgejo's Actions reference explains that a relative action "will clone the repository at the URL composed by prepending the DEFAULT_ACTIONS_URL (https://data.forgejo.org by default, see note below)", that "the instance administrator may change the DEFAULT_ACTIONS_URL", and that "it is strongly recommended to choose fully qualified URLs to avoid ambiguities".

An agent writes actions/checkout@v4 because it has seen that string ten thousand times. So do I. The line reviews as familiar, and where the code comes from is decided by a setting on a server, in a file that is not this one.

The layer where both platforms fail identically

Everything above is platform detail. This part holds wherever you run.

On your laptop, the thing standing between a repository you just cloned and your machine is a dialog. You accept workspace trust, or you do not, and until you do, the repository's own grants sit inert. A pipeline is defined by having nobody to show a dialog to.

Anthropic documents exactly what falls out of that. In Claude Code's permissions reference, a table called "What runs before you trust a folder" has a column for claude -p or the SDK in a folder that was never trusted, which is every checkout in every CI job. Three rows carry it:

What the repository suppliesUnder claude -p, folder never trusted
Hooks, the env block, apiKeyHelper, a skill's allowed-tools"Used"
permissions.allow and additionalDirectories"Not used", with a warning to stderr
Servers in .mcp.json"Connected without asking, approved or not"

Both the CLI and the SDK load those servers by default. Both can be told not to, with --setting-sources user or a settingSources that leaves project settings out, and neither will do it on your behalf.

The restrictions you reviewed and committed are dropped. The code the repository supplies runs. Every control that ends in a human approving something evaluates to nothing in a pipeline, and every control that runs on its own keeps running, so what survives the trip is precisely the executing half.

I have written before about the strange arithmetic of a config file five people share, where that dead allowlist is one line of a larger story. Here it is the whole story, because in CI the security model looks present and is absent.

What my runner actually holds

The standard warning about a self-hosted runner is that it holds deploy credentials, registry tokens and cloud roles. Mine holds none of them. There is not one secrets. reference in the file, because deploying happens on the proxypilot host through a webhook and was never a pipeline step, so anyone who owned my runner tomorrow would find no credential worth taking. That is the same principle as swapping a real secret for a sentinel the process never sees, arrived at by a duller route: the value cannot leak from a machine it was never copied to.

They would still have a very good afternoon.

Line 32 of that workflow is npm ci, which runs the install scripts of every transitive dependency, because npm's ignore-scripts defaults to false. The second job talks to a Docker daemon to build an image and run a container. The whole thing sits inside my network. Everything I would reach for on a laptop to bound that, the boundary that holds when the instructions fail, has to be rebuilt here by hand, because a runner ships with none of it.

Forgejo's admin documentation puts it without decoration: "Forgejo Runner performs remote code execution. That poses significant security threats for the host and network that it operates upon." GitHub says the same thing about its own: "We recommend that you only use self-hosted runners with private repositories. This is because forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow."

The cPanel campaign is the proof that this is the real threat model. Those 583 workflow files were not hunting a vault. They wanted the compute, the bandwidth and the clean IP address, and a runner holding nothing at all serves that purpose perfectly.

What to actually do

  • Decide what the job may reach before what it may do. Egress, the Docker socket and the secret scope are three separate decisions, and only the third is the one everybody argues about.
  • Keep the deploy credential off the box. A webhook the runner cannot call, on a host the runner cannot reach, is worth more than any secret-scanning step.
  • Run agents with --bare. It skips the repository's hooks, skills, plugins, MCP servers, auto memory and CLAUDE.md. Anthropic calls it "the recommended mode for scripted and SDK calls" and says it "will become the default for -p in a future release". Until then it is opt-in, and the default is the permissive one.
  • Pair it with --permission-mode dontAsk and an explicit --settings. In that mode Claude Code "denies anything not in your permissions.allow rules or the read-only command set, which is useful for locked-down CI runs". The rules have to arrive by flag, since the repository's own copy is dead here.
  • Never combine pull_request_target with a checkout of the pull request's head. This is the AsyncAPI bug. The trigger shipped in 2020, GitHub Security Lab documented the pattern a year later, and the tooling finally caught up this summer: since 18 June 2026 actions/checkout v7 "is generally available and refuses common pwn request patterns by default", declining to fetch fork pull request code unless you add an opt-out GitHub named allow-unsafe-pr-checkout, "intentionally named to be easy to spot in code review and static analysis". Enforcement for backported versions landed on 20 July, six days after AsyncAPI. So the default protects you now, the opt-out is one line, and an agent that meets that line in a search result will paste it without comment.
  • Write actions as fully qualified URLs, pinned. On Forgejo because the name genuinely does not identify the host, on GitHub because a tag is mutable and 2026 has been proving it all year. Note the tension with the previous bullet: GitHub shipped that checkout protection to floating major tags, so pinning to a digest is also how you opt out of receiving it. Pin, and then own the upgrade.
  • Decide what may run install scripts. Add --ignore-scripts wherever the build survives it, and treat the places it does not as a known, written-down exposure. npm 11 also ships an allowlist, the allowScripts field in package.json, but strict-allow-scripts defaults to false, so an unreviewed script still runs and merely says so.

The case for running your own forge got stronger this year, on reliability and on blast radius both, and I moved for reasons that still hold.

What moved with me was the assumption that somebody else had already thought about the runner. Nobody had. Part of that is mine to fix now that it is my box, and the rest would have followed me onto any platform: the one control I have relied on for two years, a human looking at a dialog and deciding, is the exact control a pipeline cannot offer.

(7 of 5)