Four coding agents pinned their plugins to a 40-character commit SHA, and none of them checked what they got. Plugin4Shell, disclosed on 17 September 2026, is not a clever exploit — it is the discovery that the industry's standard supply-chain control was implemented as a request rather than a check, in Claude Code, Codex, Copilot and Gemini CLI simultaneously. The durable lesson is in the vendor responses, two of which added the missing comparison and two of which pointed at their git host's naming rules: when a client does not verify, the security property lives at the registry, invisible to your lockfile, and it does not survive a mirror.
At a glance
One omission, four products, four different theories of whose job it was.
| Agent | What the manifest pinned | What it verified | Response |
|---|---|---|---|
| Claude Code | A 40-hex commit SHA | Nothing, before the fix | Patched in 2.1.179 |
| OpenAI Codex | A 40-hex commit SHA | Nothing, before the fix | Patched in 0.146.0 |
| GitHub Copilot | A 40-hex commit SHA | Nothing | No client fix; relies on GitHub's ref-name rules |
| Gemini CLI | A 40-hex commit SHA | Nothing | No fix; product deprecated in favour of Antigravity |
What actually happened
The mechanism
Agent plugin marketplaces pin each entry to a commit hash — a 40-character hexadecimal string naming one exact snapshot of a repository. The agent clones the plugin repo and checks out that string. Per the AIR Security disclosure, credited to researchers Or Nevo, Dor Granat and Niv Hoffman, none of the four agents then asked git which commit it had actually landed on.
That matters because a 40-hex string is not only an object ID; it is also a syntactically legal ref name. Git's own revision resolution treats an ambiguous name by preferring a ref that matches it, and warns about it. So a repository owner who creates a branch named with exactly the pinned SHA — and makes it the repository's default branch — can serve entirely different code to a client that asked for the pin and never confirmed it. The manifest entry does not change. The review that approved the plugin does not change. The user clicks nothing.
The fix is a single comparison, and its absence is the whole vulnerability:
# what the agents did git clone --depth 1 "$REPO" plugin git -C plugin checkout "$PINNED_SHA" # what makes the pin binding — one line, no network call test "$(git -C plugin rev-parse HEAD)" = "$PINNED_SHA" || exit 1
Why it is zero-click and why it is severe
Plugins and extensions for coding agents are not sandboxed sidecars. They run with the permissions of the developer running the agent, which in practice means the working tree, the SSH keys, the cloud credentials in the environment, the internal package registries and whatever production access that laptop has. And because the swap happens at update rather than install, an approved plugin becomes an attacker-controlled one with no prompt, no reinstall and no diff for anyone to read.
The interesting part is the two vendors who did not patch
It would be easy to read the split response as two responsible vendors and two negligent ones. That reading is wrong, and the correct one is more uncomfortable.
GitHub's position is that naming restrictions on its hosted repositories limit the attack surface, and that is factually true. GitHub rejects any push whose branch or tag name is 40 hex characters, with a dedicated error — GH002: Sorry, branch or tag names consisting of 40 hex characters are not allowed. GitLab blocks the same shape in a pre-receive hook. Git itself has long avoided creating such refs precisely because of the ambiguity. On those platforms, the attack is stopped before it starts.
So the honest description of the pre-disclosure state of the world is not "four broken clients". It is: a security property that everyone relied on, that nobody implemented, and that was in fact being provided for free by two git hosts as a usability guard. That is a fine outcome when it holds. The problem is everything that moves an artefact out from under it.
What breaks the host's guarantee
- Self-hosted forges. A Gitea, Forgejo or plain bare-repo remote does not necessarily enforce the rule, and internal plugin repos are exactly where they live.
- Older or differently configured enterprise instances. The check is a platform policy, not a protocol rule; an instance behind on upgrades is a different security posture with the same manifest.
- Mirrors and vendoring. Air-gapped rehosting, pull-through caches and acquisition migrations all move repositories between namespaces with different rules, and nobody's migration checklist mentions ref-name policy.
- Product decisions. The rule exists because a company chose it, is documented as a convenience, and can be relaxed without a CVE.
Read down those columns and the asymmetry is the point. A client-side comparison is cheap, local, needs no cooperation and shows up in code review. A host-side prohibition is strong where it applies and completely invisible in the thing you shipped: your plugin manifest looks identical whether the rule is in force or not, so no audit, diff or SBOM can tell you which world you are in.
What to change this week
- Upgrade, and then inventory by host. Claude Code 2.1.179 and Codex 0.146.0 close it. For anything still relying on the platform, the question is not "are we patched" but "where do our plugin repos live" — list them by host and flag every one that is not on a platform enforcing the rule.
- Verify after fetch, yourself, wherever you control the fetch. If your team ships internal plugins, skills or prompt bundles through any kind of installer, add the
rev-parse HEADcomparison. Four vendors shipped without it; your two-hundred-line install script almost certainly did too. - Stop treating a version string as a pin. A lockfile line with a version and no hash is a request. Prefer identifiers that are the content — digests, not names — and where only a name exists, record the resolved digest at first fetch and compare on every subsequent one.
- Review Markdown as code. Skills, plugin instructions and prompt bundles steer tool calls, so a silent change to one is a privilege change. Put them under the same approval as a dependency bump; see agent skills for why the file extension is misleading.
- Reduce what a plugin can reach. The reason this was critical rather than annoying is that plugins inherit everything the developer has. A coding agent running against scoped credentials in a container is a smaller incident than one running on a laptop with production keys — the blast radius argument, in the one place it is most often skipped.
The part that generalises past plugins
Strip the git specifics and Plugin4Shell is a shape you will meet repeatedly, because agents extended the dependency graph into places classical packaging never covered. Model versions are fetched by alias. Tool schemas arrive fresh on every MCP connection and their descriptions are part of your prompt. Skills and prompt bundles are pulled by name from registries that are months old. Almost none of it has a lockfile, and where a pin exists it is usually a name resolved through a namespace somebody else owns.
The test that catches this class in a design review takes one sentence: can we detect a substitution on our own, with no network call and no trust in the server? If the answer is yes, you have a digest and a check. If it is no, you have a preference — and you have quietly delegated the security of your supply chain to a company that never promised it. See pinning and verification for the general form, and agent supply-chain security for the rest of the surface.
FAQ
Am I safe if my plugins are all on GitHub?
Against this specific trick, largely yes — GitHub rejects 40-hex ref names outright. But that is a platform policy protecting an unverified client, so it covers only repositories on platforms that enforce it, and it stops covering you the moment a plugin is mirrored, vendored or moved to an internal forge.
Was this exploited in the wild?
No public evidence of exploitation has been reported. Note what the attack requires: control of a plugin repository that someone has already approved, which is also the position from which you could simply ship a malicious commit — the pin bypass is what makes it invisible to review rather than what makes it possible.
Why did Google deprecate Gemini CLI instead of fixing it?
The deprecation was already in motion toward Antigravity; the disclosure landed on a product being retired. Operationally the distinction does not help you — an unpatched client on a developer machine is unpatched regardless of the roadmap, so treat the deprecation as an upgrade deadline.
Does SHA pinning still make sense?
Yes, and more than before. A commit ID is a content-derived identifier, which is exactly the right kind — it was the missing comparison, not the identifier, that failed. Pin to the SHA and then check the SHA.
How do I audit my own installers for the same bug?
Grep for every place you check out, download or unpack something by an identifier and look for the comparison immediately afterwards. The pattern to search for is a fetch whose result is used without a hash check — in shell scripts, CI jobs, Dockerfiles and plugin loaders alike.
Further reading
On this wiki:
- Pinning and Verification — names versus digests, and why only one is locally checkable.
- Agent Supply-Chain Security — the full surface this sits in.
- Agent Skills — why a Markdown bundle is code.
- Tool Catalogue Lifecycle — onboarding, review and removal for the things agents load.
- MCP Registry & Distribution — the same trust question one layer over.
Sources:
- AIR Security — Plugin4Shell, the original disclosure.
- Help Net Security on the affected versions and vendor responses.
- gitrevisions — how git resolves an ambiguous 40-hex name.