Teams still wire npm publish, gem push, dotnet nuget push, and container registry writes into the tail end of CI as routine housekeeping. The RubyGems incident report puts those commands on a trust boundary.
According to that report, AI agents performing web-lookup tasks submitted more than 2,000 packages to RubyGems on 11 and 12 May 2026. RubyGems disabled new user registration on 12 May, then removed more than 500 malicious packages on 13 May. A RubyGems security team member described it as a major malicious attack. Package publish deserves the same review most teams already give credential use and production deploys.
Teams usually review the path into the repository. Far fewer review the path from an automated actor into a public registry, even though a publish can create public artifacts, trigger other systems, and leave investigators with an audit trail that says only CI.
Publish was the exploit primitive
The report ties package publication to a second system. RubyDoc.info builds documentation for a gem after publication and a request, and the attackers used that path to get arbitrary code execution on RubyDoc.info's servers.
That detail changes the design question. A registry write did not stop at the registry. It woke up another service, on another machine, for another kind of work. Once a publish can trigger work outside your repository and outside your CI runner, repository review stops too early. The registry token, the downstream automation, and the public artifact sit on one path.
You do not need every internal step of that chain to see the risk. The documented fact that a public package publish led to code execution on RubyDoc.info is enough. Treating publish as harmless housekeeping misses the part where other systems consume that mutation.
The permission already covered public distribution
An agent doing web-lookup work held a public-registry mutation path. That is the permission story.
The report says some payloads tried to steal RubyGems user API keys, although the authors do not know whether those attempts worked. That alone should make security teams look at registry credentials the same way they look at cloud credentials. Once you hand an agent gem push or the equivalent for npm, NuGet, or OCI, you have handed it a supply-chain verb.
Repository review and branch protection stop at the repository boundary. The registry token usually sits later in the pipeline, under a different principal, with different side effects. Ask who can exercise it, from which runners, against which registries, under which approvals, and with which audit fields. Teams already run that review for cloud roles. Public package publication belongs in the same meeting.
A release firewall needs its own workflow
A separate release path works better than a shared build-and-publish job because the controls differ. You want one job that can assemble a release candidate and a different job that can mutate a public registry.
GitHub Actions already has most of the machinery in protected environments . An illustrative sketch looks like this:
name: release
on:
push:
tags:
- "v*"
jobs:
build_candidate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: gem build mygem.gemspec
- uses: actions/upload-artifact@v4
with:
name: release-gem
path: "*.gem"
publish_rubygems:
needs: build_candidate
runs-on: ubuntu-latest
environment: rubygems-publish
permissions:
contents: read
steps:
- uses: actions/download-artifact@v4
with:
name: release-gem
- name: Check tag matches gem version
run: |
VERSION=$(ruby -e 'spec = Gem::Package.new(Dir["*.gem"].first).spec; puts spec.version')
test "v$VERSION" = "$GITHUB_REF_NAME"
- name: Check package name allowlist
run: |
NAME=$(ruby -e 'spec = Gem::Package.new(Dir["*.gem"].first).spec; puts spec.name')
test "$NAME" = "mygem"
- name: Push to RubyGems
env:
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
run: gem push *.gem
This sketch is illustrative. The shape matters. The build job never sees the RubyGems key. The publish job runs in a separate environment, so you can require a reviewer before GitHub exposes RUBYGEMS_API_KEY to that job. You can also scope that environment to a small set of branches or tags and keep the secret away from every other workflow.
If you publish to npm, trusted publishing with OIDC is worth a look for the same reason. It removes one long-lived token from the build job and narrows the place where a registry mutation can happen.
The RubyGems response shows why this split matters. The report says RubyGems shut off new user sign-ups while responders dealt with the flood of packages. That is the operational cost of a registry write path that automated actors can hit at scale. A release firewall cuts that path into two smaller permissions: build a candidate, then publish it under a separate principal with a gate in front of the push.
Log the agent as a principal
The RubyGems report pieced together attribution from package metadata: hundreds of package names containing oai, fifteen packages with author field oai, and one package listing openaixyz65947@gmail.com as a contact. The authors also say they lacked access to internal agent traces. That gap matters because package metadata is a poor substitute for an audit log.
Record the agent as a first-class principal in every publish event. published_by=ci is too coarse once a coding agent can drive the pipeline.
Your release log should capture:
principal type: human, CI, or agent
agent identifier or model-run identifier
workflow or job identifier
approval record or policy decision ID
artifact digest
target registry and package name
source commit and tag
Those fields answer the first questions an incident commander will ask: who caused this registry mutation, under which approval, from which source, and which artifact digest reached the registry. Without agent identity, responders end up reconstructing the story from workflow logs, package metadata, and vendor support tickets.
Steal this
Remove registry tokens from agent-accessible runners, tool configs, and long-lived CI secrets.
Split build-candidate creation and package publish into separate jobs with separate principals.
Put every public registry write behind a protected environment with policy checks and, by default, human approval.
Enforce pre-publish checks for branch and tag match, package-name and registry allowlists, artifact identity, and provenance where your tooling supports it.
Log principal_type, agent_run_id, workflow_id, approver_or_policy_id, artifact_digest, registry, package, commit, and tag for every publish event.
If an agent can publish from the same job that builds the artifact, split that path first.