AI Blog

The pin was a name, not a digest

Four coding agents pinned plugins to a 40-character commit SHA and none of them checked what they got, because a 40-hex string is also a legal branch name. The interesting part is the split response: two vendors added the missing one-line comparison, two pointed at their git host’s naming rules — which is a real defence owned by someone else, invisible in your manifest, and gone the first time a plugin is mirrored.

By Agentic AI Wiki 11 min read

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.

AgentWhat the manifest pinnedWhat it verifiedResponse
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
Where each vendor left the plugin-pin check A four-by-three matrix. Rows are Claude Code, OpenAI Codex, GitHub Copilot and Gemini CLI. Columns are a client-side commit check, reliance on the git host forbidding forty-hex ref names, and retiring the client. Claude Code and Codex added the client-side check; Copilot relies on the host rules; Gemini CLI was deprecated. Which layer holds the check, per vendor Commit checked in the client Host forbids the ref name Client retired Claude Code Yes — 2.1.179 not relied on — OpenAI Codex Yes — 0.146.0 not relied on — GitHub Copilot No Yes — stated defence — Gemini CLI No not stated Deprecated Check lives in the client — inspectable, travels with the artefact Defence owned elsewhere — real where it applies, invisible in your manifest Absent or not applicable
Two vendors moved the check into the client. Two left it with the host — which is a real defence, owned by someone else.

What actually happened

How a pinned commit SHA resolves to a branch A flow from a marketplace manifest holding a pinned forty-hex commit SHA, through a shallow clone and a checkout of that string, to git's ambiguous resolution: either the commit object the reviewer approved, or a branch created with the same forty-hex name. Both paths reach the working tree, and the verification step comparing HEAD against the pin is marked as missing before the plugin executes with the developer's permissions. Marketplace manifest sha: 3f9c…a71b Shallow clone git clone --depth 1 Checkout the pinned string git checkout 3f9c…a71b git resolves an ambiguous 40-hex name The commit object the snapshot the reviewer approved A ref of the same name refs/heads/3f9c…a71b Working tree the agent loads rev-parse HEAD == 3f9c…a71b ? this box was not in any of the four clients Plugin executes with the developer's permissions
Everything here is correct except the box that is not there.

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.
Three places the pin check can live Three columns — verify in the client, forbid the ref name at the git host, and retire the client — each showing what it buys, who owns it, and where it stops holding, with a closing note that only the client-side check is a property of the artefact you can inspect. Same attack, three defences, three owners Verify in the client Forbid the name at the host Retire the client Buys: a pin that is binding everywhere, for one line of code Buys: protection for every unverified client on that platform Buys: nothing until the last install is gone Owned by: you, or your agent vendor Owned by: GitHub, GitLab — as a usability guard Owned by: whoever still has it installed Stops holding: never Stops holding: mirrors, self-hosted forges, older instances Stops holding: immediately, on every unupgraded laptop Only the first column is a property of the artefact you can inspect.
Only the first column is a property of the artefact you can inspect.

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 HEAD comparison. 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:

Sources: