Agents get GitHub tokens from a self-hosted octo-sts, scoped per repository and role, and two rulesets confine their App to agent branches and no tags
Status: Accepted Date: 2026-09-26 Deciders: Smana (Platform Owner) Related Spec: SP1 — Agent runtime & identity
Context
An implementer run pushes one branch and opens one PR; reviewer, tester and triager runs must never
write (C6). main requires zero approving reviews (programme D4), so nothing but policy stops a
token that can push to main from merging. Tokens must be short-lived, scoped to the run’s repository,
and never a human’s.
Decision Drivers
- ≤ 1 h tokens, one repository, permissions by role
- Authorisation that lives in the target repository and is reviewed like code
- No long-lived credential in the sandbox
- Works for the user-owned
Smanaaccount
Considered Options
Option 1: Self-hosted octo-sts with the agents’ GitHub App
The run presents a token that lives until its deadline (R2), with audience octo-sts/<owner>/<repo>/<role>,
to agent-router’s sts listener (ADR-0042).
The listener verifies it against this cluster’s exact issuer and JWKS and routes /sts/exchange to
octo-sts. octo-sts checks it against .github/chainguard/agent-<role>.sts.yaml on the default branch
and returns an installation token with that policy’s permissions.
Pros:
- Trust policies are files in the repository, on a gate path
- Resolves installations by account login, so a user-owned installation works
- Every exchange is recorded outside the sandbox. agent-router’s
stsaccess log holds the verifiedsuband time, and octo-sts logs the requested repository and trust policy. What the token then does is in GitHub’s own record: the repository’s activity and each PR’s timeline, where every push, PR and comment is attributed to the App’s bot, never to a human
Cons:
- The trust policies’ issuer has two alternatives. gcp-0’s GKE issuer is fixed by project, location
and cluster name, so it is matched exactly. aws-0’s EKS issuer changes on every rebuild, so it is
matched by pattern (OD-5). That pattern alone accepts a token minted in any eu-west-3 EKS cluster, an attacker’s included, with a ServiceAccount
named like a run’s. A prompt-injected sandbox that could reach octo-sts could present one. So
octo-sts admits ingress only from agent-router’s data plane, whose
stslistener pins this cluster’s issuer (Flux-substituted) and JWKS. The pattern is safe only behind that check (owner decision, 2026-09-26) - One more service in
agent-system, and GitHub tokens depend on agent-router being up
Option 2: Personal access tokens
Cons:
- A human’s credential, long-lived, not scoped per run (D3)
Option 3: External Secrets GitHub generator, or the OpenBao GitHub plugin
Cons:
- The token lands in a Kubernetes Secret or needs the run to authenticate to OpenBao; permissions are set in cluster config, not in the repository
Option 4: A git proxy holding the credential
Cons:
- A programme non-goal: the target repositories are public, and a proxy is a new component to build
Decision Outcome
Chosen option: “Self-hosted octo-sts with the agents’ GitHub App”, plus two rulesets
with one bypass list: agent-branches confines every non-bypass actor to refs/heads/agent/**, and
agent-tags refuses every tag write. The bypass list is every human role that can push (admin, maintain, write), Renovate and the factory’s App (OD-7). A
GitHub App is bypassed only when named, never through a role, so the agents’ App is the one confined
actor, and collaborators and App Wizard pushes made with a user’s token are not.
Rationale: Short-lived, per-repository, per-role tokens whose authorisation is reviewed in the repository it grants.
Consequences
Positive
- A stolen implementer token can push to
agent/**of one repository for ≤ 1 h; a reviewer’s cannot push at all - The App has no
workflowspermission, so no agent PR can change a workflow file. PR CI still runs repository scripts the agent can edit, without secrets, andrender-diff’sGITHUB_TOKENcan write PR comments and labels
Negative
- All runs share one App and the ruleset is
agent/**-wide: a run can push another task’s agent branch (R9). SP3’s gate checks the head commit’sAgent-Runtrailer - A copied octo-sts audience token verifies until
exp, the run’s deadline (R2) - No record ties an installation token to a run. octo-sts 0.10.0 logs no subject or token hash: they
are in its exchange event, which needs
METRICS=trueand a CloudEvents sink (EVENT_INGRESS_URI), and neither is set. A push is traced to its run by time against thestsaccess log and by the commit’sAgent-Runtrailer contents: writealso lets the implementer create tags and releases and sendrepository_dispatch, which a branch ruleset does not cover. Theagent-tagsruleset, with the same bypass list, refuses every tag write, so a release that needs a new tag fails too. A release on an existing tag andrepository_dispatchremain open; no workflow triggers on either today- The App’s private key is the strongest credential here: it mints implementer-level tokens for every
installed repository without octo-sts’s per-role scoping, and only the ruleset still bounds its
pushes to
agent/**. It sits on theagentsmount, whichopenbao-platformcannot read (see the 2026-09-29 amendment below) - Dependabot is off on this repository (no
dependabot.yml, security updates disabled, checked 2026-09-26). Enabling it means adding its App to the bypass list, or its branches are refused - The trust policies’ EKS alternative is a pattern (any EKS cluster in eu-west-3, because aws-0’s
issuer ID changes on every rebuild); the GKE alternative is gcp-0’s exact issuer. That is safe only behind this self-hosted octo-sts, whose only caller,
agent-router’s
stslistener, has already verified the token against this cluster’s issuer. Chainguard’s hosted octo-sts App (octo-sts, app id 801323) reads the same.github/chainguard/*.sts.yamlfiles with no such check: installed on a repository that carries these policies, it would mint that repository’s tokens for anyone with an eu-west-3 EKS cluster. Confirm it is absent (github.com/settings/installations) before a repository opts in - The ruleset’s
updaterule coversmaintoo, since that is what stops the App merging its own PR, so every human merge tomainis a bypass that GitHub asks for explicitly: the “bypass rules” checkbox, orgh pr merge --admin. A plain merge is refused (seen on #2113). Branch protection still applies (enforce_admins), so the bypass waives only this ruleset. GitHub auto-merge is untested against it. Renovate is on the bypass list for the same reason
Neutral
- A repository opts in three times, in this order: the ruleset, its trust policies, the App’s
installation. The ruleset comes first because
mainneeds no approval, so until it exists nothing stops the implementer from merging its own green PR
Implementation Notes
security/base/octo-sts/ (its only route in is httproute.yaml, on agent-router’s sts listener),
.github/chainguard/agent-*.sts.yaml, .github/rulesets/agent-branches.json and .github/rulesets/agent-tags.json, both applied by
task ops:github:agent-branch-ruleset. The App key is github-app on the agents kv-v2 mount. octo-sts reads
it once at startup, so a rotated key needs kubectl rollout restart deploy/octo-sts -n agent-system.
Amended 2026-09-29. The App key moved from platform/agents/github-app to github-app on the agents kv-v2
mount, which only agents-secrets and secrets-admin name. A mount of its own was chosen over a
namespaceSelector on openbao-platform: external-secrets reads all of platform/, and a
selector would still let every namespace it admits read the key.