Back to all posts
Published on · by Renaud Deraison

The commit was a branch

Air disclosed Plugin4Shell on September 17: plugin marketplaces for Claude Code, Codex, Gemini CLI and Copilot pin each plugin to a forty-character commit hash, and the agents check that hash out without ever confirming they got it. An attacker who names a branch after the pinned hash gets the checkout, and the background updater does the rest with no prompt and no click. Anthropic and OpenAI patched. Google will not, and Microsoft has not. In a Bromure Agentic Coding workspace, the code that wins the checkout lands in a VM holding placeholder credentials, behind a push policy and an egress ruleset enforced on the host.

Every supply-chain guide tells you to pin dependencies to a hash, because a version number is a label somebody can move and a hash is the content itself. Four coding agents took that advice. None of them checked that the hash was what they got.

A plugin passes review. You read it, or your platform team reads it, and it goes on the approved list pinned to a commit: forty hexadecimal characters that can only describe one tree, because changing the tree changes the characters. That is the appeal. Anyone with the right access can move a tag or republish a version, but a commit hash is supposed to be a fact.

On September 17, three researchers at Air (Or Nevo, Dor Granat and Niv Hoffman) published Plugin4Shell. Their finding: the pin is only as good as whatever resolves it, and the resolver in four major coding agents does not check its own work. The Register covered it the same day. Claude Code, OpenAI's Codex, Google's Gemini CLI and Microsoft's Copilot are all affected.

Git prefers the ref

A commit hash is content, and it is also a name. So are branches and tags, and git resolves all of them through the same lookup. Hand git one string that is both a branch name and an object id and it has to choose; it chooses the branch. The researchers put it in one line: "when a name is both a valid ref and an object id, git prefers the ref."

Git does warn you. It prints an ambiguity notice for that reference, into a terminal you are not reading, because a background updater is running the checkout.

Git branch names accept forty hexadecimal characters. An attacker who controls the plugin's repository creates a branch with the same name the marketplace pinned and makes it the default branch. The clone brings that branch down alongside the commit of the same name. Then the agent runs its checkout:

git clone <plugin repo> ./
git checkout aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

The checkout lands on the branch. Gemini CLI takes another route to the same place: it fetches the correct commit with --ref, then runs git checkout FETCH_HEAD, which resolves to a branch named FETCH_HEAD if the repository has one.

One name, two things it can meanmarketplace pinaaaaaaaa…aaaareviewed, recorded, unchangedobject id in the repositoryaaaaaaaa…aaaathe code that passed reviewbranch head, same forty charactersrefs/heads/aaaaaaaa…aaaawhatever the attacker pushedgit checkout aaaa…aaaaresolves to the branchwarning printed, nobody readingThe agent never asks what it actually checked out. One command would: git rev-parse HEAD.
One string, two meanings. The commit aaa…aaa and a branch named aaa…aaa both exist in the cloned repository. git checkout has to pick, and it picks the ref. The marketplace still records the same pin, and the tree on disk belongs to someone else.

Five steps, and the last one has no click

The researchers lay the attack out in five moves, and the first three are things a well-run marketplace does on purpose.

Plant. Publish a benign plugin. It passes review at commit aaa…aaa because there is nothing wrong with it.

Adoption. Developers install it, pinned to the reviewed commit. This is the step where you do what the guides tell you to do.

Version bump. The marketplace re-pins to a newer commit, bbb…bbb, also benign and also reviewed.

Rug-pull. The attacker creates a branch named bbb…bbb, makes it the default, and points it at malicious code. The attacker republishes nothing and the marketplace record does not move. The forty characters you would check are the forty characters you checked before.

Auto-update to RCE. Claude Code and Codex update installed plugins in the background by default. The next scheduled update checks out the branch. Air describes that step as "no prompt, no click."

Plugin4Shell, step by stepwho is acting1a benign plugin is published and passes review at aaa…aaathere is nothing wrong with the code at this pointmarketplace2developers install it, pinned to the reviewed committhis is the recommended practice, followed correctlyyou3the marketplace re-pins to a newer commit, bbb…bbbalso benign, also reviewedmarketplace4a branch named bbb…bbb is created and made defaultnothing is republished; the recorded pin does not moveattacker5the background updater checks out the branchcode execution on the developer's machine — no prompt, no clickthe agent
Steps one through three are the marketplace working as designed, and step two is the developer following the standard advice. The attack is steps four and five, and neither one asks the developer to approve anything.

There is a second way in that skips the plant: take over a legitimate plugin author's repository and push the malicious version to every agent that already has the plugin installed. The branch trick is the same, and there is no review to pass.

Air describes the result as "full remote code execution on the employee's machine," running with that employee's permissions. The attacker needs no privilege escalation. The code runs as the person whose laptop it is, and reaches their files and their production access.

This is the third time these researchers have counted the same shape. Their earlier work on malicious agent skills describes one that reached more than 26,000 agents, and a hijacking campaign that took over 925 skills already in use across 134,000 agents. Those campaigns needed you to install something bad; this one needed you to install something good and wait.

Two of the four have no patch

Air reported to all four vendors in June.

Anthropic patched Claude Code on June 17, in version 2.1.179. OpenAI's fix shipped in Codex 0.146.0 and was verified on August 12. Google answered on August 4 that Gemini CLI is deprecated and will not be patched. Google points users at Antigravity instead, so a Gemini CLI install that stays on disk stays vulnerable. Microsoft shipped nothing for Copilot. GitHub says its own platform blocks branch and tag names that look like commit SHAs, which covers plugins hosted on GitHub and does nothing for a plugin repository hosted somewhere else, Bitbucket included. The Register notes, using Microsoft's own number, that nearly 90% of the Fortune 500 use Copilot.

The fix itself is one line, and it has to run inside the agent:

test "$(git rev-parse HEAD)" = "<pinned-sha>" || abort

Resolve the commit that landed in the working tree, and refuse to proceed unless it matches. No marketplace can do this on your behalf, because your agent resolves the pin on your machine, after the marketplace is done talking.

For a large number of developers reading this today, "update your agent" is not an available answer. That leaves the other question: what does the code find when it wins the checkout and starts running?

Inside a Bromure Agentic Coding workspace

Air's phrase, running with the employee's permissions, is exact. A workspace exists to make it wrong.

In Bromure Agentic Coding, the agent runs inside a hardware-virtualized Linux VM on your Mac, and the VM never holds a real credential. At session launch the app writes structure-preserving placeholders into the environment the agent runs in: ANTHROPIC_API_KEY, GH_TOKEN, LINEAR_API_KEY and the rest as environment variables, and matching fakes into ~/.git-credentials, ~/.docker/config.json, ~/.kube/config, ~/.aws/config and the agent's MCP configuration files. Each placeholder keeps the shape its tools expect, so they accept it: a GitHub placeholder is ghp_ plus 36 characters, an Anthropic one starts sk-ant-api03-brm-, a Kubernetes bearer token starts brm-k8s-. Bromure derives each one from the real value through HKDF-SHA256 with a per-install salt, which keeps it stable across sessions and worthless to whoever collects it.

The real values sit encrypted on the host. A host-side proxy substitutes them onto the wire after the request has left the VM, and only when the destination matches the host that credential was minted for. A plugin that sweeps the home directory for config files, which is the first thing this class of payload does, collects a set of well-formed placeholders.

After the checkout: the same three moves, twicean ordinary developer machineread the home directorysk-ant-api03-… ghp_… AKIA…real keys, real tokens, real kubeconfigworkspush to the repositories it can reachgit push → git-receive-packthe developer's own git credentialsworkssend the collection somewherePOST https://attacker.exampleordinary outbound HTTPS, nothing in the wayworksa Bromure Agentic Coding workspaceread the home directorysk-ant-api03-brm-… ghp_… brm-k8s-…placeholders; the reals are on the hostdecoyspush to the repositories it can reachgit-receive-pack = writeblocked, or a host dialog you answerpolicysend the collection somewhere451 — not one byte forwardedegress rule, then the VM is pausedstopped
The same checkout, the same malicious plugin, two machines. On an ordinary laptop the code inherits the developer's real credentials and reaches whatever they reach. In a workspace the credentials it reads are placeholders, the push it attempts meets a host-side write policy, and the call home meets an egress rule the guest cannot edit.

The push has to be approved on the host

The most valuable thing a compromised plugin can do on a developer machine is push. That is how one laptop becomes a hundred repositories.

Guardrails classifies each call the agent makes to GitHub. A git push over HTTPS arrives at the proxy as git-receive-pack, which counts as a write. Read-only mode refuses it. Prompt before write mode raises a dialog on your Mac, titled Allow write on "<scope>" from workspace "<name>"?, showing the operation verbatim rather than a summary of it. A git fetch counts as a read and passes without interrupting you.

The classification runs in the host proxy, outside the VM. Code inside the guest cannot switch it off or route around it, because it does not run on the machine making the decision.

The call home meets a rule the guest cannot edit

Every workspace has an egress firewall: an ordered, pf-style list of allow and deny rules over host, IP range, protocol, port, and, for web traffic, the individual HTTP verb. allow web api.example.com GET,POST gives the agent queries without mutations, at the wire, no matter which tool inside the VM makes the request.

Two layers enforce it. The virtual network switch applies the rules to each flow by destination IP and DNS-snooped hostname, covering plain TCP and UDP as well as HTTPS. The proxy applies them again by TLS server name and HTTP method. Bromure diverts the VM's port 80 and 443 traffic into the proxy without the guest's cooperation, so nothing inside can opt out of the inspection. Your edits reach running sessions at once, which matters on the day you read a disclosure like this one.

Underneath that sits the compromise detector, on by default with nothing to configure. The proxy scans each outbound request in a single pass for the placeholder tokens minted for that workspace. A placeholder addressed to a host it was never minted for is the signature of exfiltration, and the proxy answers it with HTTP 451 and forwards no bytes. It pauses the VM on the spot and raises an alert offering to shut the workspace down, to export the disk and home folder for investigation, or to continue at your own risk. Bromure marks the workspace compromised, and the next launch wipes the VM disk and home folder while keeping your tokens, SSH keys and workspace settings. Only the placeholder left, so there is nothing to rotate.

Zero-click means there is nothing to notice

Step five's sharpest property is its silence. A background plugin update produces no prompt and no tool call, so the agent's own interface gives a developer nothing to catch. Air's point is that the exploit runs in the part of the system you have no reason to watch.

A workspace moves the watching somewhere else. The security engines write each decision as a row in the Security Timeline window on your Mac: firewall verdicts (evil.example:443 tcp — blocked), guardrail enforcements (DELETE api.github.com/repos/… — blocked), credential brokering including the red blocked — exfiltration attempt, VM paused row, plus supply-chain verdicts and prompt-injection hits, colour-coded and filterable. A session can also record an encrypted trace of the commands that ran and the hosts it reached.

A plugin that updated itself without telling you still has to make requests, and each one crosses a boundary that keeps its own notes on the far side of the hypervisor.

Check your agent's version first

Claude Code 2.1.179 and Codex 0.146.0 contain the fix. If you run Gemini CLI, there will not be one. If you run Copilot, there is not one yet.

Turn off plugin auto-update where you can

The zero-click property comes from the background updater. Manual updates put a human back in the loop for step five.

Verify the checkout yourself in CI

git rev-parse HEAD after a pinned checkout, compared against the pin, is the same one-line check the vendors had to add. It works anywhere you clone by SHA.

Assume someone wins the checkout

Pinning, review and marketplace curation all held here and the attack landed anyway. The outcome then rests on what the winning code can reach.

Plugin4Shell catches the careful developer on the same terms as the careless one. The developer who installed whatever was trending came out of it no worse than the developer who read the source, pinned the hash, and wrote the pin down. Content addressing was supposed to protect that second developer, and it did not, because git resolves the address through a lookup that also uses those characters for something else.

Keep pinning. Then assume that a control resting on a lookup or a name match will one day be handed the wrong answer while you are asleep. Design for that morning: run the agent somewhere that holds no key worth taking, and keep the record of what it did on the far side of a boundary the code cannot reach.