The Morning Wire

AI NEWS REPORT

EXPLAINER · FRIDAY, SEPTEMBER 18, 2026

Plugin4Shell explained: how a git branch named like a commit hash beats plugin pinning in Claude Code, Codex, Copilot and Gemini CLI, with a working reproduction

AIR's post explains what its researchers found and who has patched. This page shows the git behavior underneath it in a scratch repository you can rebuild in two minutes, shows the warning git prints that no agent reads, gives the one-line checkout form that is immune, and checks the 'auto-update is the default' claim against Claude Code's own docs.

This explains reporting by AIR, September 17, 'Plugin4Shell - Zero Click RCE Vulnerability found in top 4 most popular coding agents' by Or Nevo, Dor Granat and Niv Hoffman.
Read the original first: https://www.air.security/blog-posts/plugin4shell

In one minute

What AIR found

A plugin marketplace for a coding agent is a catalog. Each entry points at a git repository and, in the careful ones, a commit hash. The hash is the pin. The idea is that a reviewer looked at exactly that commit, so whatever the agent installs later is exactly what was reviewed.

AIR's researchers found that all four major agents did the pin check the same lazy way. They cloned the repo and ran a checkout of the hash. They confirmed the hash existed. They never confirmed that the working tree they ended up with was that hash. AIR's phrasing: the agents 'check out the pinned commit without verifying the checkout landed there.'

That gap is only exploitable because of a git behavior most people never hit. Git lets you name a branch with 40 hex characters. And git's own documentation says that when a name is both a valid ref and an object id, git prefers the ref. So a branch named after the good commit, pointing at a bad commit, wins.

Three of the four agents ran 'git clone' then 'git checkout <sha>'. Gemini CLI did 'git fetch origin <sha>' then 'git checkout FETCH_HEAD', which fails the same way if the default branch is literally named FETCH_HEAD.

The reproduction, in a scratch repo

This is the whole thing on a Mac with git 2.49.0. No agent involved. The 'remote' is a local directory, which is enough because the bug is in the client's checkout, not in transport.

mkdir -p p4s/remote && cd p4s/remote && git init -q
git commit -q --allow-empty -m good
GOOD=$(git rev-parse HEAD)
echo EVIL > plugin.txt && git add plugin.txt && git commit -qm evil
EVIL=$(git rev-parse HEAD)

# the attack: a branch NAMED after the good hash, POINTING at the evil commit,
# and made the repository's default branch
git branch "$GOOD" "$EVIL"
git symbolic-ref HEAD "refs/heads/$GOOD"

# the agent's side
cd .. && git clone -q remote work && cd work
git checkout "$GOOD"
git rev-parse HEAD
ls

Output, trimmed:

warning: refname '0a687e51e27907e29c5af41ade49da16dadea8e5' is ambiguous.
Git normally never creates a ref that ends with 40 hex characters
because it will be ignored when you just specify 40-hex. These refs
may be created by mistake. ...
0e80e7ee86863f1a216dbdcb306fcf23fa25ca74
plugin.txt

The pin was 0a687e51. HEAD is 0e80e7ee. The working tree holds plugin.txt, the file that only exists in the evil commit. The checkout exited 0. Git told us in plain English that the name was ambiguous, on stderr, and then did the wrong thing anyway.

That last part is why this is an agent bug and not a git bug. A human at a terminal would see the warning. An agent's installer sees an exit code of zero and moves on. Warnings on stderr are not part of the contract these tools were written against.

Why the branch has to be the default branch

Our first attempt did not reproduce. We created the hash-named branch but left the default branch alone. After the clone, 'git checkout <hash>' landed on the right commit and the pin held.

The reason is what 'git clone' creates locally. It creates one local branch, the remote's default, and tracks everything else as 'origin/<name>'. A remote-tracking ref named 'origin/0a687e51...' does not collide with the bare hash, so git resolves the hash to the commit. Only when the hash-named branch is the default does a local branch with that exact name exist, and only then does the ref win.

AIR's attack chain says the attacker makes the malicious branch the repository default. Our test confirms that step is load-bearing, not cosmetic. It also tells you something useful for defense: if a marketplace's install path fetches a single ref instead of cloning, the exposure is different, which is exactly the Gemini CLI variant with FETCH_HEAD.

Why GitHub is safe and Bitbucket is not

GitHub rejects branch names that are 40 hex characters. You cannot push one. So a plugin marketplace hosted on GitHub cannot carry this trap, no matter how the agent checks out.

Bitbucket and ordinary self-hosted git servers accept the name. Git itself accepts it too. 'git check-ref-format --branch <40-hex>' passes. AIR points out that Anthropic's documentation lists Bitbucket and self-hosted servers as supported marketplace sources, and we confirmed the current docs still say: 'Git URLs: any git repository URL, including GitLab, Bitbucket, and self-hosted servers.'

So the exposed population is narrower than 'everyone with plugins.' It is everyone whose agent installs plugins from a marketplace that is not on GitHub, on an unpatched agent version. That is still a lot of enterprises, because the whole point of a self-hosted marketplace is internal review and control.

The zero-click part: what AIR says versus what the docs say

AIR's headline claim is that no click is needed because agents update installed plugins in the background, and 'in Claude Code and Codex this is the default.' The chain: publish a clean plugin, get it pinned, wait for the marketplace to re-pin to a new commit, then create the hash-named branch pointing at malware. The next background update pulls it.

Claude Code's marketplace docs say something more specific. 'claude-plugins-official, most other official Anthropic marketplaces, and marketplaces added from claude.ai have auto-update enabled by default. Other third-party marketplaces and local development marketplaces have auto-update disabled by default.' Anthropic's official marketplaces live on GitHub.

Both statements can be true at once, and the overlap is the actual danger zone: a third-party marketplace, hosted on Bitbucket or a self-hosted server, where someone turned auto-update on, or where an administrator set autoUpdate to true in extraKnownMarketplaces for the whole organization. That last one is a normal thing for an enterprise to do. It is one JSON key.

We could not check Codex's default from its docs on this pass, so for Codex we are relying on AIR's statement. For Copilot, AIR says the plugin path is exposed and Microsoft has not shipped a fix. For Gemini CLI, Google told AIR on August 4 that the product is deprecated and will not be patched, and AIR suggests moving to Antigravity.

The fix, and the two lines that would have prevented it

AIR is right that no marketplace can enforce this. A marketplace can only publish a hash. Honoring it is the client's job. The patched agents now verify the checkout result.

If you write anything that installs code from a pinned git commit, this is the immune form:

git checkout --detach "${PIN}^{commit}"
[ "$(git rev-parse HEAD)" = "$PIN" ] || { echo 'pin mismatch'; exit 1; }

The ^{commit} suffix tells git to peel the name to a commit object, and in our test it resolved to the pinned commit even with the hostile branch in place. Git still printed the ambiguity warning, which is fine. The second line is the one that matters. Compare what you got to what you asked for. If they differ, stop.

You can also refuse the trap at the door. 'git ls-remote --heads <url>' lists remote branches. A branch name matching ^[0-9a-f]{40}$ has no honest reason to exist. Reject the marketplace.

Timeline

Claude Code releases this week are in the 2.1.27x range, so anyone on a current build is past the fix. The check still matters for pinned enterprise installs.

Who is affected

CaseStatus
Claude Code with plugins from a marketplace on Bitbucket, GitLab or a self-hosted serverFixed in 2.1.179 and later. Check claude --version. GitHub-hosted marketplaces were never exposed.
Codex with plugins from a non-GitHub marketplaceFixed in 0.146.0 and later. Check codex --version.
GitHub Copilot plugins from a non-GitHub marketplaceNo fix shipped as of September 18. Turn off background plugin updates and review marketplace sources.
Gemini CLIDeprecated. Google will not patch. Move off it.
Any marketplace hosted on GitHubNot exposed. GitHub blocks 40-hex branch names.
Your own scripts that install code from a pinned git commitExposed if they run 'git checkout <sha>' without comparing HEAD to the pin afterward.

What to do

What is still unknown

Sources

Today's full edition: AI News Report · every headline, every morning.