You review a plugin, you pin it to the exact commit you read, and from then on that commit is what runs. That is the deal SHA pinning offers, and it is a good deal.
On 17 September Or Nevo, Dor Granat and Niv Hoffman of Air showed that Claude Code, Codex, GitHub Copilot and Gemini CLI all honoured the pin on paper. They asked git for the pinned commit, and none of them checked what the checkout actually produced.
Air calls it Plugin4Shell: zero-click remote code execution through a plugin you already trusted.
Why pinning was the right idea
Pinning answers the rug pull. A plugin passes review, people install it, and later the author, or whoever took over the author's repository, swaps in something else. A version tag can be moved, and a branch moves by design. A 40-character commit hash names one commit and its exact tree of files, forever.
So marketplaces pin. Anthropic's official marketplace pins its git sources to a commit, and I've told you before to pin the version of anything you hand to an agent. The idea holds up. The step after it is where four agents went wrong.
The missing line
An agent installs a pinned plugin roughly like this:
git clone <plugin repo> .
git checkout <pinned-sha>The attacker controls the plugin's repository. They create a branch whose name is the pinned hash, point it at their own code, and make it the repository's default branch, so the clone brings it down. Now git checkout <pinned-sha> has two candidates with the same name: the commit and the branch. Git picks the branch.
I rebuilt that in a scratch repository with git 2.43 to see it happen:
$ git checkout 30db431387e58014d7bcdf4b9d3af73414d25921
warning: refname '30db431387e58014d7bcdf4b9d3af73414d25921' 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. [...]
Already on '30db431387e58014d7bcdf4b9d3af73414d25921'
$ git rev-parse HEAD
57ec4c8f51b04d7d33ec7e422a3953381c83171c
$ cat run.sh
echo "attacker code"Read that warning again. Git says such a ref "will be ignored when you just specify 40-hex", and in the same breath it checks the ref out.
The warning is right about one command. git rev-parse 30db431… returns the commit, because rev-parse prefers the object, while git checkout prefers the branch. So the check you would write first, resolving the pin before you check it out, passes every time and proves nothing. The only check that catches it runs afterwards and asks what actually landed:
test "$(git rev-parse HEAD)" = "$PINNED_SHA" || abortThat line is Air's fix, and its absence is the whole bug. The Gemini CLI variant gets there by another route: it fetches the pin and then runs git checkout FETCH_HEAD, and a default branch named FETCH_HEAD wins that checkout the same way. I reproduced that one too.
Two conditions make it work. The hash-named branch has to be the default, otherwise a plain clone only fetches it as a remote-tracking ref and the checkout falls back to the commit. And the host has to accept a branch named like a hash: GitHub rejects them, a spokesperson told The Register. According to Air, Bitbucket and self-hosted git servers accept them, and those are supported marketplace backends.
Auto-update turns it into zero-click. The attacker ships a routine, harmless update, the marketplace moves the pin to that new commit, and only then does the attacker create a branch named after it. Claude Code and Codex update installed plugins in the background by default, so the swap reaches everyone who already has the plugin without anyone installing anything.
Who fixed it
Air found it in May and reported it to all four vendors in June.
- Claude Code fixed it in 2.1.179. Anthropic confirmed the fix to Air on 17 June.
- Codex fixed it in 0.146.0.
- Gemini CLI will not be fixed. Google deprecated it and points users at Antigravity.
- GitHub Copilot has no fix. Air told The Register it never got a response from Microsoft, and Microsoft did not immediately respond to The Register either.
I tested the Claude Code fix on 2.1.276, in an isolated config directory, with a marketplace entry pinned to a commit in a local repository that carried the hash-named default branch:
✘ Failed to install plugin "demo@gitpin": SHA pin verification failed:
expected HEAD to be ae578a9f72594f3b82f572671b6fa45ac3044476,
got 5f056ba526ee39cc352f122706bcf6105cb8a592. The pinned commit may
have been removed upstream, or a ref with the same name exists.
Refusing to install.The same pin from a clean copy of the repository installed normally. That is the check doing its job, and it asks the same question as Air's one-line fix.
Check what you received, not what you asked for
Claude Code also takes plugins as a zip archive pinned by sha256. That design never had this problem, because a hash of the bytes you received cannot be satisfied by a branch name. I pointed an isolated config at a real archive with a wrong hash:
✘ Failed to install plugin "decompose@pintest-bad": Plugin archive
integrity check failed [...]: expected sha256 0000[...]0000,
got 9c648375659df3ba5003c07425e728d2cab77f522311af7a604825ce53d993d3.
The archive was not installed.Both checks now ask the same question: what arrived? Anything you pin, plugin or not, is only as safe as the answer to that question.
It was in my CI too
I run my own Forgejo with its own runner, and my workflows pin every action by commit hash. So I built the attack against that setup, locally, with the same Forgejo version I run in production.
Forgejo 16.0.4 accepts a branch named like a hash and lets you make it the default. Setting it through the API took two attempts: the first left the repository's HEAD on main, the second moved it. That is a quirk, and nobody should count on it as a defence. With the branch in place, a workflow step uses: <action>@<pinned-sha> behaved like this:
| runner | result |
|---|---|
Gitea act_runner 0.3.1 | ran the attacker's action, job succeeded, no warning |
forgejo-runner 13.1.0 | refused: "an ambiguous git reference", job failed |
| both, without the hash branch | ran the reviewed action |
The first row was my production runner. I had updated the Forgejo server and assumed the runner had come along with it. It hadn't: it was a different project with its own version number, still at 0.3.1. As of today, production runs forgejo-runner 13.1.0.
Nobody attacked me. Exploiting it would need control of the repositories those actions come from. But my pins would not have stopped it, and I only found that out by testing.
One level up, some pins were missing altogether. My own plugin marketplace pinned its skill archives by sha256, but the plugins that live in their own GitHub repositories carried no pin at all, so every update followed whatever the default branch said, until I pinned them to their release commits. While checking that the update landed, I found one more thing to distrust: Claude Code did not refresh the gitCommitSha it records for an updated plugin, so that field still named the old commit. I compared the installed files against the pinned commits instead.
What to do
- Update the agent. Claude Code 2.1.179 or later, Codex 0.146.0 or later. On Copilot there is nothing to update to yet. On Gemini CLI there never will be.
- Know which of your plugins are pinned at all. An unpinned git source follows the default branch on every update. That is the rug pull pinning exists to stop, without any cleverness required.
- Check your runner as well as your forge. They are separate installs with separate versions.
forgejo-runner13.1.0 refuses the hash-named branch. The Giteaact_runner0.3.1 I had did not. - In your own scripts, check after checkout. Use
git checkout --detach "$SHA^{commit}"and then comparegit rev-parse HEADwith the pin. The^{commit}is what makes the difference:--detachon its own still takes the branch, and so does passing the output ofrev-parseback intocheckout. A check before the checkout is the one that always passes. - Prefer a hash of the bytes. Where you control the format, pin the hash of the artefact you download.
Pinning held up. Asking for a commit and getting it are two steps, and four agents stopped after the first. The fix is one line. It took three researchers and a branch named like a hash to get it written, and two of the four agents still do not have it.