← Back to all posts
Published on · by Renaud Deraison

The agent's token was the owner's

On October 23, LiteLLM published the root cause of its poisoned 1.107.0 release. The first stolen credential didn't come from a log. It came from LiteLLM's own agent platform, whose API handed back the GitHub token its cloud agents used to open pull requests: an organization owner's token, not scoped down. The key to that API had sat in a public config file for four and a half months. In Bromure Agentic Coding, an agent that opens pull requests holds a stand-in, the real token stays encrypted on the Mac, and no API anywhere returns it.

LiteLLM's agents needed a GitHub token to open pull requests. They got one that belonged to an organization owner. One API call with a leaked key returned it.

On October 23, LiteLLM published the attack chain behind its 1.107.0 release, the version that went out on October 21 with a credential stealer inside. The post calls itself "the canonical source of truth" and corrects LiteLLM's first theory. The stolen GitHub tokens did not come from logged model traffic. The first one came from an experimental service LiteLLM ran for its own coding agents.

We covered the release itself in The release was two hours old, and followed the first theory there. The final report starts in a different place. The first credential the attacker took belonged to the agents.

An agent platform with an owner's token

LiteLLM Agent Platform was, in LiteLLM's words, "an experimental project for self-hosted cloud agents to file PRs to GitHub repositories." To open pull requests, the platform held a GitHub token. That token "was not associated with a service account and was not scoped down." It was the GitHub CLI OAuth token of one of the organization's two owners.

The report's timeline, in UTC:

  • May 27, 18:53. An opencode.json file is committed to the public LiteLLM-Labs/lite-harness repository. It's the configuration for an agent harness, and it contains the master key to a deployment of the Agent Platform. The team spots it, force-pushes over the commit and marks the exposed keys for revocation. "Key revocation did not occur for the master key."
  • October 10, 21:27. From 69.48.229.143, the attacker calls the platform's API with that key. GET /api/v1/managed_agents/agents?limit=100 returns the configuration of every managed agent, and with it the owner's token.
  • October 10, 22:11. With the owner's token, the attacker pushes a throwaway branch, ci-tmp-a351cc, to BerriAI/litellm. Its workflow posts six repository secrets to webhook.site. One of them, GH_TOKEN, is a second maintainer's GitHub CLI token. Twenty-four seconds later a second branch in BerriAI/litellm-auto posts a third maintainer's token to the same endpoint. Both runs are deleted within three minutes.
  • October 12. More workflows on private repositories post every repository secret they can reach.
  • October 21, 10:08. A push to the release repository with the second maintainer's token is rejected by branch protection. Five seconds later the owner's token disables the protection and force-pushes the change, which points the release workflow at the lookalike Berri-AI/litellm.
  • October 21, 11:11. The second maintainer's token approves the production gate. A rule that required approval from an account locked to a device in LiteLLM's office had been relaxed that week, so any two engineers could release.
  • October 21, 11:39 to 11:46. Seven wheels reach PyPI, then the Docker Hub and GHCR images.
  • October 21, 11:53. The third maintainer's token deletes the release run records. At 12:24 the owner's token force-pushes main clean.

LiteLLM's own summary: "This incident has provided a strong illustration of the risks associated with failing to rotate a potentially exposed key." It is now revoking every token tied to every internal system and vendor platform.

Every agent fleet holds a token like this

LiteLLM runs a capable security team, and its setup was a common one. An agent that opens pull requests needs a token that can push. The quickest token to hand is your own gh login, and that is the one LiteLLM's agents were given.

Then the token has to live somewhere the agents can read it: an environment variable, a config file, a database row behind an API that configures the agents. Whoever can read that place has the token. Here an API key that leaked once, and was never rotated, was enough.

GitHub CLI tokens make this worse. LiteLLM notes they "are post-2fa and not subject to GitHub's 'super-user' 2fa reauth flows associated with sensitive organization or repository actions." With one, the attacker could switch off branch protection, force-push, approve a release and delete the evidence.

Look at your own setup for the same parts: a GH_TOKEN in the agent's environment, an MCP config with a bearer token in it, a harness config that someone will one day commit. LiteLLM's opencode.json held more than the platform key. It also carried bearer tokens for its GitHub, Linear and Slack MCP servers and the key for its model gateway, all in plain text.

The same fleet on a Bromure machine

Bromure Agentic Coding runs Claude Code, Codex, Grok, Kimi and omp in Linux VMs on your Mac. Its automations do what LiteLLM's agent platform did: an agent fires on a schedule or on a new GitHub issue, works in a fresh worktree, and pushes a branch or opens a pull request. Run LiteLLM's chain against a machine running those automations.

The agent holds a stand-in

Add the GitHub token to the machine under Credentials. At boot, Bromure writes a fake into the VM: a ghp_ value with the right length, in GH_TOKEN, in gh's configuration and in ~/.git-credentials. The agent's gh pr create and git push work as usual. When a request leaves the VM for github.com, the proxy on the Mac swaps the fake for the real token, and only for that host.

The real token lives encrypted on the Mac, under a key held in the macOS keychain. It is never written into the VM, in any file or variable. The manual says of the channel between host and VM: "There is no request by which the VM can ask for a real token back."

So the step that opened LiteLLM's organization has nothing to return. Dump the agent's environment, read its config, list every automation through the API: the GitHub value in all of them is the stand-in. Paste it into your own gh and GitHub rejects it, because only that machine's proxy knows what it stands for.

The committed config holds stand-ins too

The leak began with a harness config in a public repository. On a Bromure machine, the config files an agent reads are written with stand-ins. An MCP server's bearer token, set in the machine's MCP pane, reaches the agent as a brm-mcp_… value, swapped on the wire for requests to that server's host. A model key set in Preferences › Models never enters the VM; the agent sees a provider-shaped fake. Any other API key goes under Other API keys with a host filter, and the VM gets a brm_… value in the variable you name.

When an agent commits its config by mistake, or a developer copies one into a demo repository, the file carries stand-ins. Nothing in it works off that Mac, so a force-push and a revocation list nobody finishes don't decide whether you get breached.

There is no platform API to call

LiteLLM's attacker reached the platform's API from the internet. Bromure's automations run on your Mac. In the manual's words: "there is no cloud service and no inbound network surface." GitHub and Linear triggers are polled from the Mac every three minutes with the machine's stored token, which stays on the host. There is no webhook endpoint, no master key and no /managed_agents route for a leaked key to open.

A stolen stand-in trips an alarm

The October 10 workflows posted secrets to webhook.site. Picture the same move aimed at a Bromure machine: a poisoned dependency or a prompt-injected instruction reads GH_TOKEN and posts it to webhook.site.

The proxy scans every outbound request, headers and body, for stand-ins headed somewhere other than the host they were minted for. A ghp_ stand-in bound for webhook.site is blocked with HTTP 451 before a byte goes out. The VM pauses, and an alert on the Mac reads This environment may have been compromised, listing the token preview, the credential's name, the host it belongs to and the host it was sent to. You can shut the machine down, or save its disk and home folder for investigation.

With Unmatched traffic set to Deny in the machine's sandbox policy, webhook.site isn't reachable in the first place, and the attempt shows up as a red row in the Security Timeline.

A push waits for you

New Bromure machines start with GitHub's write policy set to Prompt before write. Reads pass. A git push or any other write to github.com pauses on a dialog on your Mac:

Allow write on "GitHub" from workspace "release-bot"?
POST /repos/<owner>/<repo>/git/refs

The attacker's moves were all writes: the ci-tmp- branches, the change to the release workflow, the force-push to main, the deletion of the run records. From a Bromure machine, each one would have paused for that dialog. Allow once grants nothing beyond the one write, so the next push asks again. A force-push is still a push on the wire, and Prompt before write gates it.

An unattended automation can't wait for a click, so give it the narrowest token that does its job, under a policy set for that machine. The token you add to a release-bot machine can be a fine-grained one for one repository. Your own gh login, with owner rights to everything, has no reason to be on that machine.

The record is on your Mac

LiteLLM matched token hashes in GitHub's audit log to learn which stolen token did what. On a Bromure machine, every swap of a credential onto the wire appears as a Credential brokering row in the Security Timeline, with the masked token and the host. Every write you refuse is a red Guardrails row with the method and path. On a Mac enrolled with bromure.io, the same events reach your organization, so a token used from a machine nobody expected shows up without anyone hunting through audit logs.

From a config file to a signed releaseLiteLLM, May 27 to October 211 · Key in a configopencode.json in a publicrepo, never revoked2 · API returns tokenagent config holds an orgowner's GitHub CLI token3 · Secrets posted outci-tmp branches send twomore tokens to webhook.site4 · Pushes and releaseprotection off, force-push,gate approved, logs deletedOn a Bromure machineStand-ins in configsMCP, model and API keysin the VM are fakes. Acommit leaks nothing.No token to returnno inbound API; the agentholds a ghp_ stand-in thatGitHub rejects elsewhere.Blocked, VM pauseda stand-in headed forwebhook.site gets a 451and a compromise alert.Prompt before writeevery push, force-pushand API write waits fora dialog on the Mac.
LiteLLM's attack chain, and where a Bromure machine running the same agents stops each step. Every check runs on the Mac, outside the VM.

Agents get stand-ins

GH_TOKEN, gh's config and ~/.git-credentials hold a ghp_ fake. The real token is swapped in on the Mac, only for GitHub's hosts.

No API hands out tokens

Automations run on your Mac with no inbound surface. Nothing the VM can call returns a real credential.

Leaks pause the machine

A stand-in sent anywhere but its own host is blocked with a 451, and the VM pauses while you decide.

Writes ask first

New machines prompt before every push and every write to GitHub, force-pushes included, with the exact request on screen.

Don't give the agent the token at all

LiteLLM is revoking every token and will publish its process changes next week. Rotation, narrow scopes and service accounts all help. Each still leaves a working token somewhere an agent can read it, and that is what the attacker went looking for on October 10.

On a Bromure machine, the agent does its work with a token that only works through its own proxy, toward the one host it was minted for. Whatever the agent can read, an attacker who reaches the agent can read too, and on a Bromure machine that never includes the real token.

For how the same stand-ins hold up against a stealer that targets coding-agent files, read The stealer packed the stand-ins. For the copies an agent leaves in its own transcripts, read The transcript kept the token.