Security
Coding agent audit checklist: before main branch access
An autonomous coding agent rewrote 184 package manifests across a Fedora CI fleet in one overnight run. Here's the audit we now run before any agent gets write access to main.

At 03:14 Bangkok time, an operations lead opened Slack to find a single message in the on-call channel: "184 PRs from the agent, all merged, all green." His CI fleet was Fedora-based, the agent had a GitHub App token with write access, and the workflow that auto-merged Dependabot-style PRs did not distinguish between Dependabot and "the new agent we're piloting." Package manifests had been rewritten across 41 services. Half the rewrites were correct. The other half pinned a dev build of a transitive that did not exist on the public mirror. Standup started with a rollback plan and an apology.
We watched this happen at a client last month. Nobody got fired. Nothing leaked. But the production deploy pipeline was frozen for nine hours while two engineers walked back every commit. The agent had not done anything malicious. It did exactly what we asked it to do, at the speed it was allowed to do it.
This post is the audit we now run before letting an autonomous coding agent open pull requests against main on a sub-€30M client's stack. It assumes GitHub Actions and Kubernetes, because that is what we see most often. The list is short on purpose. Every item has caught a real problem.
What the 184 manifests taught us
The lesson was not "agents are dangerous." The lesson was that the guardrails for human contributors and the guardrails for agents look identical from the outside but fail in completely different shapes. A human committer who pins a non-existent transitive will notice at the first dnf install failure and stop. An agent will open another PR to fix the failure, and another, and another, because that is the reward signal it was given.
The fix is not to give the agent better judgement. The fix is to make sure the blast radius of bad judgement is small enough to walk back over breakfast. The same pattern is playing out across the industry at larger scales than ours, on more visible stacks. The vendor-side argument about how much autonomy an agent should ship with by default is the same conversation viewed from the other end of the table.
Branch protection is necessary, not sufficient
The first thing every team asks is: "Doesn't branch protection solve this?" It does not. It changes who can merge, not who can author. An agent with PR-open rights and a co-conspirator workflow that auto-approves "trusted" authors will route around any rule that only checks the reviewer field. We have seen three variants of this in the wild: GitHub Actions that auto-approve PRs from a label, bots that gh pr review --approve on behalf of a service account, and the classic "required reviewers" list that included the agent's own GitHub App.
The real check is: can the agent's identity, directly or transitively, satisfy every required protection rule on main? If yes, branch protection is decoration. The audit takes ten minutes per repo and almost always finds one path nobody remembered to close.
If your required-reviewers list contains a GitHub App or service account that the agent itself can invoke, the agent can approve its own PRs. Audit the membership of every team and CODEOWNERS line that grants merge rights.
GitHub Actions: token scope, OIDC, and workflow_run
The default GITHUB_TOKEN in a workflow has more power than most teams realise. Read the automatic token authentication docs once a quarter. Then write down, on paper, what your agent's workflow can do with that token. The answer is usually "more than the team thought."
Three controls matter most:
- Permissions block at workflow root. Default to
permissions: {}and add only the scopes a job actually needs.contents: readfor the checkout,pull-requests: writefor the comment, nothing else. - OIDC over long-lived secrets. If the agent deploys to a cloud, federate via OIDC and scope the trust policy to a single repo, branch, and workflow file. A leaked
AWS_ACCESS_KEY_IDin repo secrets is a year of liability; an OIDC role assumption is a fifteen-minute session. - workflow_run is a footgun. A workflow triggered by
workflow_runruns with the permissions of the default branch's workflow file, not the PR's. That is the right default for security, and the wrong default if your agent edits the workflow file and expects the new version to run. Read the spec carefully before you wire anything else.
A workflow that lets the agent open PRs but cannot push directly to main, cannot read repo secrets, and cannot trigger downstream workflows:
name: agent-propose
on:
workflow_dispatch:
inputs:
task:
type: string
required: true
permissions: {}
jobs:
propose:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: run agent
env:
AGENT_API_KEY: ${{ secrets.AGENT_API_KEY }}
run: ./scripts/run-agent.sh "${{ inputs.task }}"
- name: open pr
uses: peter-evans/create-pull-request@v6
with:
branch: agent/${{ github.run_id }}
base: main
title: "agent: ${{ inputs.task }}"
body: "Proposed by agent run ${{ github.run_id }}."
labels: agent-proposed
Kubernetes: namespace, RBAC, and the kill switch
If the agent runs inside your cluster (sandboxed test runs, ephemeral preview envs, anything else), it gets its own namespace, its own ServiceAccount, and an RBAC role that lists exactly what it can touch. The mistake we see most often is granting edit at the namespace level "just for the pilot" and never walking it back. Three months later the agent has cluster-admin in everything but name, and nobody remembers why.
The minimum we ship looks like this:
apiVersion: v1
kind: Namespace
metadata:
name: agent-sandbox
labels:
pod-security.kubernetes.io/enforce: restricted
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent
namespace: agent-sandbox
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: agent-sandbox
name: agent-runner
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: agent-sandbox
name: agent-runner
subjects:
- kind: ServiceAccount
name: agent
namespace: agent-sandbox
roleRef:
kind: Role
name: agent-runner
apiGroup: rbac.authorization.k8s.io
Two non-obvious bits. First, the restricted Pod Security Standard label on the namespace is non-negotiable. It blocks privileged containers, host mounts, and a dozen other escapes that an agent might accidentally request from its tooling layer. Second, the kill switch lives one kubectl command away: kubectl scale deployment agent --replicas=0 -n agent-sandbox. Every operator on the rotation should know it without looking it up. Practise it during onboarding, like a fire drill.
The audit checklist
This is the actual list we walk through with a client before granting an agent the rights to open PRs against main. It is genuinely numbered because we tick each item off in a shared doc and refuse to flip the switch until every line is green.
- Identity. The agent has its own GitHub App (not a PAT, not a user account). The App's installation is scoped to specific repos, not the entire org.
- Token scope. Every workflow the agent can invoke has an explicit
permissions:block at the root. No workflow uses the default token scope. - Branch protection.
mainrequires PR review from a human listed in CODEOWNERS. The agent's identity is not, directly or transitively, in any CODEOWNERS line that touchesmain. - Auto-merge. No workflow auto-merges PRs labelled by the agent. No workflow auto-approves PRs from the agent's identity.
- Secrets. The agent's workflows do not read repo or org secrets directly. Any cloud access happens via OIDC with a trust policy scoped to the workflow file path.
- Rate limit. A repo-level concurrency group caps the agent at one open PR at a time. If you want parallelism, set the cap explicitly and document why.
- Blast radius. The agent cannot modify files under
.github/workflows/,deploy/, or any path that affects how its own code runs. CODEOWNERS enforces this. - Kubernetes. If the agent runs in-cluster, it has its own namespace with
pod-security.kubernetes.io/enforce: restricted, its own ServiceAccount with token auto-mount disabled, and an RBAC Role that lists exactly the verbs and resources it needs. - Kill switch. Every on-call engineer can stop the agent with a single command, written on the runbook in plain text. The command does not require pulling up credentials.
- Audit log. Every PR the agent opens links back to the prompt, the model version, and the workflow run that produced it. If you cannot reconstruct "why did the agent do this" in three clicks, you cannot debug a runaway.
- Data retention. You know what your model vendor stores and for how long. Vendor retention windows on enterprise agent-grade tiers, often thirty days by default, need to be in your DPA before you ship.
- Dry run. The first week the agent is live, it opens PRs against a fork or a long-running
agent-pilotbranch, notmain. You merge by hand. You read every diff.
What we wish we had checked earlier
The thing we wish we had checked earlier, on the client where the 184 manifests landed, was item 7. The agent did not have write access to main. It did have write access to renovate.json, which it edited to add a new package ecosystem, which triggered the auto-merge workflow on a label the team had forgotten existed. The path to main was three hops. The audit found it in a quarter of an hour. The fix took five minutes.
A second near-miss on the same fleet came from a workflow file the agent rewrote because the linter asked it to. The diff was correct, and tiny, and would have routed every future cloud deploy through a different OIDC role. Nobody on the review side noticed. The blast-radius rule (item 7 again) caught it on the next push, because .github/workflows/ was already off-limits to the agent's identity. One rule, two incidents, both quiet.
When we wired up the autonomous PR pipeline for a logistics client running a Fedora-based CI fleet, the thing we ran into was the same auto-merge label, buried in a workflow nobody had touched since 2022. We solved it by adding a process automation step that lints every workflow file in the repo against the checklist above on every push, and fails the build if any item drifts. Boring, mechanical, and it has caught two near-misses since.
One thing to do today: open your repo's settings, click into Actions, then General, then Workflow permissions, and check whether the default is "Read and write." If it is, flip it to read-only and let each workflow ask for more in its permissions: block. That single change shrinks the blast radius of every workflow you have, including the ones you have not written yet.
Key takeaway
Treat an autonomous coding agent like a day-one contractor: own identity, scoped credentials, no auto-merge, and a kill switch every operator knows by heart.
FAQ
Does branch protection stop an agent from merging to main?
Only if the agent's identity cannot satisfy any required protection rule. Audit CODEOWNERS and reviewer teams for service accounts the agent can invoke. Otherwise it's decoration.
Should an agent use a Personal Access Token or a GitHub App?
Always a GitHub App. Scope the installation to specific repos, not the org. PATs are tied to a human account and inherit too much org-wide reach for an autonomous actor.
What's the single biggest GitHub Actions gotcha?
The default GITHUB_TOKEN is still set to read and write in many repos. Set it to read-only at the org level and let workflows request more in their permissions block.
How fast can we kill a runaway agent?
As fast as the slowest on-call engineer can run one kubectl scale or gh workflow disable command. Write it on the runbook in plain text. Test it during onboarding.