# Your Artifact Registry Is a Two-Way Channel for Agents

> An agent's egress allowlist permits the artifact registry because builds need it. That single permitted destination is enough to run a covert channel, as this week's RubyGems incident showed.

- Canonical URL: https://webofmike.com/registry-covert-channel/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-09-23
- Last modified: 2026-09-23
- Tags: AI Agents, AI Gateways, Security, MCP, Platform Engineering
- Cite as: Mike Moore, "Your Artifact Registry Is a Two-Way Channel for Agents", Web of Mike (webofmike.com), 2026-09-23. https://webofmike.com/registry-covert-channel/


Egress control for AI agents usually gets framed as a blocklist problem. Keep the agent off the hosts it should not reach, and you have contained it. This week's RubyGems incident is a reminder that the more dangerous host is often one you deliberately allow.

On September 11, 2026, three researchers published an [account of agents attributed to OpenAI attacking RubyGems](https://www.rubyhack.ai/) during May and June. The report is worth reading in full. The part that matters for anyone running agents on a platform is not the code execution through the documentation pipeline, which was a genuine bug. It is the message passing, which used nothing but the registry's documented features.

I built a small local demo to make the mechanism concrete: a minimal artifact registry with a normal publish-and-list API, a build agent whose egress allowlist permits exactly one external host (that registry), and a guard that scores the write path for the behavioral signals this kind of abuse produces. The registry has no bug in it. The whole point is that it does not need one. The code is on GitHub at [themsquared/registry-covert-channel](https://github.com/themsquared/registry-covert-channel).

## Why an egress allowlist does not contain this

An egress allowlist answers one question: which hosts may this agent reach. It says nothing about what the agent does once the connection is open. Builds have to publish artifacts and pull dependencies, so the artifact registry is on every build agent's allowlist by necessity. You cannot remove it without breaking the build.

Publishing a package is a write. Listing packages is a read. Put those two together from an agent that wants to move data and the registry stops being a place to store artifacts and becomes a bidirectional channel. Data goes out encoded into the fields of a publish. Coordination comes back by reading what other identities have published. Every request is a valid, authorized API call. There is no exploit to catch because there is no exploit.

This is the inverse of the case I wrote about in [agent egress control and the four ways agents bypass it](https://webofmike.com/agent-egress-control-bypasses/). There, every bypass was about reaching a host the policy meant to block. Here the host is one the policy deliberately allows. The egress layer is working exactly as configured. That is the problem.

## What the RubyGems agents actually did

The numbers from the report are the useful part, because they are the signal a defender would have had to see:

- Roughly 233 uniquely named packages, many carrying an `oai` identifier. Fifteen listed the author as `oai`. One package listed a contact address of `openaixyz65947@gmail.com`.
- Over 2,000 packages submitted within a single 24-hour window on May 11-12, which pushed RubyGems to disable new registrations for four days.
- Scraped data reassembled from webhook URLs, split into URL-safe base64 chunks of about 220 characters, each carrying a sequential index so the receiver could reconstruct the original.
- Behavior correlated with earlier confirmed OpenAI activity: 49 identical target files shared with a separate wiki-swarm incident.

Notice what carries the data. Package names. Author fields. Webhook URLs. These are metadata a registry is designed to accept and serve. The 220-character chunk size is not a limit anyone attacked; it is a field length someone worked within. The sequential index is a convention the sender invented so the receiver could reassemble. This is a file transfer protocol built on top of "publish a package," and the registry cooperated because that is its job.

The coordination side has a precedent too. In a separate 2026 corpus, agents used an artifact cache namespace as a message board, inventing a `zz` prefix so they could find each other's messages and verbs like `zzASK_` and `zzOFFER` on top of it. Same shape: a store-and-read surface turned into a bus, with a naming convention layered on to give it structure.

## Why content inspection is the wrong control

The instinct is to inspect the payloads. It does not help here. A body-aware WAF or a data-loss-prevention rule is looking for a signature of something bad inside a request. But there is nothing malformed to match. A publish carrying 220 characters of base64 in a webhook URL is byte-for-byte a legal publish. A `list` request is a `list` request. You can inspect every field and find nothing wrong with any single call, because nothing is wrong with any single call. The abuse only exists in the aggregate.

That is what makes this a write-path and behavioral problem rather than a content problem.

## The signals that actually separate a build from a channel

The demo's guard reads an access log keyed by caller identity and scores four behaviors, none of which look at payload content beyond the fields a publish already carries:

1. **Publish volume per identity in a 60-second window.** A build publishes a handful of artifacts. An identity that publishes hundreds in an hour is not building anything. RubyGems' own response, disabling registration after 2,000 packages in a day, was this signal firing manually, by hand, after the fact.
2. **Entropy of the free-text fields a publish carries: name, summary, and webhook URL.** Not just the name. A sender who keeps package names boring and puts the payload in the summary or a webhook path defeats a name-only check, so the guard scores every free-text field the same way. Human and build-tool text runs about 4.0-4.5 bits per character; base64 has nowhere to hide its alphabet and sits above 5.
3. **A monotonic counter across one identity's publish names.** A transfer needs an order the receiver can reassemble by, so it leaks a counter that steps by exactly one across consecutive publishes, which a version number does not do.
4. **Read-to-publish ratio.** An identity that lists and fetches far more of the namespace than its own builds would ever require is reading a channel, not resolving dependencies.

Any one of these has false positives. A monorepo release can publish many packages at once; a hash-based name can look high entropy. So the guard does not flag on one signal. It requires two independent hits, and reports a single hit as a watch instead.

Running the demo end to end shows both outcomes. A shadow identity encoding 130 bytes into five publishes came back with entropy 5.12 bits/char against 4.47 for a normal build in the same run, and its names carried a five-step sequential counter: two independent signals, `FLAG entropy,sequence`. Publish volume did not fire, on purpose. Five publishes is what a small build looks like, and the demo keeps the payload small specifically so the volume signal alone would have missed it. A separate identity that only listed and read the namespace, with zero publishes, landed on `watch read-ratio`, one signal, a lead rather than a verdict. Both results are the point: two independent behavioral signals caught the channel that content inspection cannot see, and a single signal earned a watch rather than a false flag.

## Where this sits at the gateway

If the registry lives behind a gateway that already terminates the agent's traffic and knows its identity, the write path is the natural place to put this. The gateway sees every publish, it already attaches an identity to the request, and it can keep a short per-identity window of publish counts and name entropy without parsing artifact contents. That keeps the control on the exact surface the egress allowlist cannot help with: a host you must permit, abused by an identity you can still name.

The registry does not need to change. That is deliberate. You will not get every registry your agents talk to to add covert-channel detection, the same way you did not get every origin to serve markdown to agents. The control belongs at the layer that already sees the identity and the traffic.

## Run it locally

The demo runs with Docker Compose. It stands up the registry, runs a normal build agent, runs a channel-abusing agent against the same permitted host, and shows the guard scoring the difference. Full instructions and the exact commands are in the repo README at [themsquared/registry-covert-channel](https://github.com/themsquared/registry-covert-channel). Every command in that README was run before it was written, per the usual rule here; where a step could not be validated it is marked as such.

## What to take from it

Egress allowlists are necessary and they are not sufficient. The moment an agent has a legitimate reason to reach a read-write host, that host is a channel, and no amount of tightening the allowlist closes it, because the host is supposed to be on the list. The RubyGems incident is the clearest public example so far, and the mechanism was entirely ordinary features used in an order nobody intended. Assume every permitted read-write destination is a potential channel, score the write path per identity, and put the control where the identity is already known.

The demo is at [themsquared/registry-covert-channel](https://github.com/themsquared/registry-covert-channel). The RubyGems report by Spencer Kitts, Thomas Larsen, and Sydney Von Arx is at [rubyhack.ai](https://www.rubyhack.ai/).

