DEV Community

Cover image for 4 Coding Agents Pinned Plugins to a Commit SHA. A Branch With the Same Name Won
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

4 Coding Agents Pinned Plugins to a Commit SHA. A Branch With the Same Name Won


A marketplace reviews a plugin at commit bbb…. Your coding agent installs it, reports a clean install at bbb…, and runs code from a different commit. The only trace is one warning from git: refname is ambiguous.

That is Plugin4Shell, published on September 17, 2026 by Or Nevo, Dor Granat and Niv Hoffman of Air Security. It hit the plugin installers of Claude Code, Codex, GitHub Copilot and Gemini CLI. Air calls the root cause "one missing check": the agents checked out the pinned commit and never verified that they landed on it.

You can watch the pin lose in a throwaway repo on your own machine, in about a dozen commands. Then the fix is one line.

What the installers ran

Air's write-up gives the commands. For Claude Code, Codex and Copilot:

git clone <plugin repo> ./
git checkout <pinned 40-hex SHA>
Enter fullscreen mode Exit fullscreen mode

For Gemini CLI:

git clone --depth 1 <plugin repo> ./
git fetch origin <pinned 40-hex SHA>
git checkout FETCH_HEAD
Enter fullscreen mode Exit fullscreen mode

A 40-character hex string looks like it can only mean one thing. It doesn't. A branch can have that exact name, and the attack Air describes uses one:

  1. Publish a harmless plugin. It passes review and the marketplace pins commit aaa….
  2. Users install it at the reviewed commit.
  3. Ship another harmless update. The marketplace re-pins to bbb….
  4. Create a branch named bbb…, point it at malicious code, and make it the repository's default branch. The reviewed commit stays in the repo, untouched.
  5. The changed pin triggers a background plugin update. Git resolves the name to the branch, and the code runs with no prompt.

Gemini CLI's variant swaps step 4 for a default branch named FETCH_HEAD.

A pinned commit node on a git line, with a branch tag draped over the pin and a second line curving off to a different node
The pin is still there. The name on top of it points somewhere else.

Reproduce it in a local bare repo

Nothing here touches a network or a real plugin. It builds a "plugin" repo with a reviewed commit, adds a second commit on a branch named after the reviewed commit's SHA, hosts it as a bare repo, and then does what the installers did.

mkdir p4s && cd p4s

# The plugin, with the commit the marketplace reviewed
git init -q plugin && cd plugin
echo 'echo "reviewed code"' > run.sh
git add run.sh && git commit -qm "reviewed"
PIN=$(git rev-parse HEAD)

# Different code, on a branch named after the pin
git checkout -q --orphan swap
echo 'echo "NOT the reviewed code"' > run.sh
git add run.sh && git commit -qm "swap"
git branch -m swap "$PIN"
cd ..

# Host it, with that branch as the default
git clone -q --bare plugin plugin.git
git -C plugin.git symbolic-ref HEAD "refs/heads/$PIN"

# Install it the way the agents did
git clone -q plugin.git installed && cd installed
git checkout "$PIN"
cat run.sh
Enter fullscreen mode Exit fullscreen mode

Air reports that in this situation git "prefers the ref and only prints a refname is ambiguous warning". The string in git's source is refname '%.*s' is ambiguous., followed by an advice paragraph about refs made of 40 hex characters. cat run.sh should print the swapped code. Now ask git two questions about the same 40 characters:

git rev-parse "$PIN"   # the reviewed commit
git rev-parse HEAD     # what is actually on disk
Enter fullscreen mode Exit fullscreen mode

They shouldn't match. The reviewed commit sits in the repository the whole time and never reaches the working tree. I haven't run this myself. It uses only standard commands, and what it should show follows from Air's write-up and from how rev-parse and checkout each parse the name.

Why the branch beat the pin

Git reads that string two different ways, depending on the command.

git rev-parse goes through get_oid_basic in object-name.c. When the input is exactly 40 hex characters, it returns that object ID. If a ref with the same name exists, it prints the warning and still returns the object. The pin wins.

git checkout goes through setup_new_branch_info_and_source_tree in builtin/checkout.c. It parses the argument, then checks whether refs/heads/<argument> exists. If it does, it replaces the commit with the branch's commit and switches to the branch. The branch wins.

Git's own advice text even says these refs "will be ignored when you just specify 40-hex". That holds for revision lookup, not for checkout.

A fork in a path with one signpost carrying two identical arrows that point in opposite directions
Same 40 characters, two answers: rev-parse takes the commit, checkout takes the branch.

The branch has to be the default, and the reason is how git clone works. A clone creates a local branch only for the remote's default branch. Every other branch arrives as a remote-tracking ref under refs/remotes/origin/, so checkout finds no local branch with that name and falls back to the commit. Air confirms that a non-default branch doesn't work for the attack.

The Gemini CLI path fails the same way with a different name. FETCH_HEAD is normally a file that git fetch writes inside .git/, holding what it just fetched. If the clone's default branch is called FETCH_HEAD, a local branch with that name exists, checkout prefers it, and the fetched pin never reaches disk.

So a different ref name is no defence. What works is checking what landed after the checkout.

How far it reaches

The scope is narrower than "zero-click RCE in four agents" suggests.

  • GitHub blocks the branch name. GitHub's docs say it doesn't allow names that look like Git object IDs, 40 characters of 0-9 and A-F. A GitHub spokesperson told The Register this "ensures the reported vulnerability cannot be exploited on GitHub." GitLab runs a similar check on push (issue 346478).
  • Other hosts don't. Air says Bitbucket and self-hosted git servers accept SHA-shaped branch names. Claude Code's docs list GitLab, Bitbucket, GitHub Enterprise Server or another git host as places to host a marketplace.
  • FETCH_HEAD isn't clearly covered. The Hacker News notes GitHub's rule "does not clearly block that name".
  • Zero-click depends on auto-update. Air says Claude Code and Codex update installed plugins in the background by default. Anthropic's docs are narrower: background auto-update is off for a third-party marketplace until a user or an admin turns it on. The Hacker News read Anthropic's and GitHub's docs the same way: on by default only for each agent's own built-in marketplace, off or optional for outside ones.
  • No CVE yet. As of September 18, The Hacker News found no CVE and no advisory from any of the four vendors, and no sign of use in a real attack.

So the realistic target is a team on a self-hosted or Bitbucket marketplace, with auto-update on, or anyone who updates plugins by hand from one. That is a smaller group than the headline suggests, but it is the group most likely to run a private marketplace in the first place.

One question stays open. The Hacker News points out that nobody has said whether updating an agent removes a plugin that was already swapped, or only blocks future swaps.

The fix, and who shipped it

Air's recommended check is one line, run after the checkout:

test "$(git rev-parse HEAD)" = "$PIN" || exit 1
Enter fullscreen mode Exit fullscreen mode

It compares what landed against what was approved, whatever git made of the name. OpenAI's patch, PR #34644 "Verify Git plugin SHA checkouts", merged July 22, 2026, does the same thing in Codex. Its description reads: "Git can interpret a requested commit SHA as a branch name when the remote's default branch has the same name." It resolves HEAD after the checkout and rejects the source when it doesn't match. It shipped in Codex 0.146.0.

Where each agent stands, from Air's timeline and the coverage:

Agent Status
Claude Code Fixed in 2.1.179, which Anthropic confirmed on June 17, 2026. The release notes don't mention it, per The Hacker News.
Codex Fixed in 0.146.0, which Air verified on August 12, 2026.
GitHub Copilot No fix shipped, per Air. Microsoft didn't immediately respond to The Register.
Gemini CLI No fix coming. Google confirmed on August 4, 2026 that it won't ship one.

Gemini CLI is deprecated and still around. Google stopped serving it to Google AI Pro, Ultra and free individual accounts on June 18, 2026. It stays available through paid API keys and Gemini Code Assist Standard or Enterprise, and those are the installs that keep this bug. Google points users to Antigravity, which TNW reports has no plugin pinning to bypass.

Running several agents: update every CLI

I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent's own interface in a real terminal, as it would anywhere else. That is why a vendor's new commands work in Hermes the day they ship. It also means Hermes doesn't change how an agent installs its plugins. Each agent's installer, with its bugs and its fixes, is the one you get.

If you run three agents, you have three plugin installers, and updating one of them leaves the other two as they were. In Hermes, press ⌘N (Ctrl+Shift+N on Windows and Linux) and open the agent picker. It lists each supported agent, whether it's installed, and which version.

The Hermes new task sheet with its agent picker open, showing Claude Code installed at 2.1.299, Codex installed at 0.145.0, and the other supported agents marked Not installed
The ⌘N agent picker: Claude Code (installed, 2.1.299) selected, Codex installed (0.145.0), the other supported agents marked Not installed, plus Custom agent.

In that screenshot, Codex is at 0.145.0, older than the 0.146.0 fix. That's the line to look for. In a terminal:

claude --version   # fixed from 2.1.179
codex --version    # fixed from 0.146.0
claude update
npm install -g @openai/codex@latest
Enter fullscreen mode Exit fullscreen mode

Update Codex the way you installed it if that wasn't npm. For Copilot, keep plugin sources on GitHub, where a SHA-named branch can't be pushed. For Gemini CLI, GitHub's rule doesn't clearly cover a FETCH_HEAD branch, so move off it if you still have it. Hermes also runs Antigravity CLI as a built-in agent, next to Claude Code and Codex.

Hermes is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI, Google, Microsoft or any agent vendor. The code is on GitHub.


If this was useful

The pattern under Plugin4Shell is a system that reported one thing ("installed at the pin") while the disk held another. Checking what an agent actually did, not what it reported, is what my LLM Observability Pocket Guide is about: traces, evals, and how to pick tools that show you the run as it happened. And if you juggle several agent CLIs, Hermes IDE puts them in one window.

LLM Observability Pocket Guide: Picking the Right Tracing & Evals Tools for Your Team

Top comments (2)

Collapse
 
reidmarlow profile image
Reid Marlow •

The dangerous part of git checkout <sha> in automated runners is that git prints warning: refname '...' is ambiguous to stderr without exiting non-zero. If the tool runner just checks the process exit code rather than verifying git rev-parse HEAD equals the target SHA, it proceeds as if the checkout succeeded cleanly.

Collapse
 
vishisht_choudhary_a12d08 profile image
Vishisht Choudhary •

A branch named like a SHA is git's fake ID that just says 'ID'.