The Pin Was a Label, Not a Check: How Plugin4Shell Walked Past SHA Pinning in Four Coding Agents

The Pin Was a Label, Not a Check: How Plugin4Shell Walked Past SHA Pinning in Four Coding Agents

The plugin a developer installed and the code that ran were different, and nothing in the install path noticed. A marketplace had reviewed the plugin and pinned it to a 40-hex commit. The agent ran git checkout against that exact commit, the pin looked honored, and a working tree full of attacker code was what came back. No prompt, no click, no new plugin to approve. The missing check is a single comparison, and four of the most widely deployed coding agents shipped without it.

Air Security published Plugin4Shell on September 17, 2026, calling it “a first-of-its-kind AI supply-chain attack.” The Air research team of Or Nevo, Dor Granat and Niv Hoffman found the flaw in May 2026 with a working proof of concept against all four agents, disclosed it to the vendors in June, and shipped the writeup on Thursday, which The Register covered the same day. The affected set is Claude Code, OpenAI Codex, GitHub Copilot and Gemini CLI.

It is zero-click because of plugin auto-update. Both Claude Code and Codex refresh installed plugins in the background by default, so the attacker never has to persuade anyone to install anything: the benign, already-trusted plugin is replaced underneath the user when the marketplace bumps the pin. Air frames this as the third act of a story it has been telling all year, after the malicious skill campaign that reached 26,000 agents and SkillJacking, which hijacked 925 skills already in use and affected 134,000 agents. The industry’s answer to that pattern was SHA pinning. Plugin4Shell is the story of that answer failing.

What pinning was supposed to guarantee

The security model is simple enough to state in one sentence. Review the plugin at one commit, pin that commit, and trust that the pinned commit is the code that runs from then on. A commit id is content-addressed, so it cannot be edited in place, and a marketplace that pins one is advertising a review that keeps applying after the review is over.

That is the control enterprises rely on when they go beyond a community marketplace: they vet a plugin, they pin it, and every downstream process inherits the guarantee. Air’s point is that the guarantee was never enforced by anything, because every affected agent checks out the pinned commit and never verifies that it landed there.

Two ways a pin stops being a pin

The commit hash that is also a branch

Claude Code, Codex and GitHub Copilot clone the plugin repository and check the pinned SHA out:

git clone <plugin repo> ./
git checkout aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

An attacker who controls the plugin repository can create a branch whose name is that exact 40-hex string and make it the repository’s default branch. Git allows this: git check-ref-format accepts a 40-hex branch name, and the plain git clone above lands the name-matching branch as a local branch. When the agent then checks out the pinned SHA, git resolves the identifier to the branch, not the object. Git prefers the ref when a name is both a valid ref and a valid object id, and it does not refuse: it prints a warning and continues.

I reproduced this locally to be sure the mechanism is what the writeup says it is. In a scratch repository I created a benign commit, captured its SHA, then branched a malicious commit under that same 40-hex name and made it the default:

requested pin: 50b64cd3f27d68ef4f90913a83dc156859bfdf73
resolved HEAD: 67036208ced9b6dd006a9a4c9f7da2bac4f783bb
ref it resolved to: refs/heads/50b64cd3f27d68ef4f90913a83dc156859bfdf73
resolved HEAD content: evil

The checkout reported Already on '50b64cd3f27d68ef4f90913a83dc156859bfdf73'. The agent’s own install log would say the pin was honored. The byte in the working tree that mattered came from somewhere else entirely.

There is a detail worth sitting with. Git’s warning in that run includes this advice text: “Git normally never creates a ref that ends with 40 hex characters because it will be ignored when you just specify 40-hex.” In the same invocation, git ignored its own stated behavior and resolved the ref. GitLab handled the same ambiguity as a platform bug back in 2020, tracing to HackerOne report 790634, which is why the advice text exists at all: 40-hex refs are a known footgun that tools can generate by accident. The ambiguity is old. What is new is putting a marketplace pin and an autonomous updater on the other side of it.

Two conditions make the attack work, and I verified both. The branch must be the repository’s default, because a non-default branch arrives only as a remote-tracking ref and the checkout falls back to the real commit. In my control case, with the SHA-named branch present but not the default, git checkout <pin> landed on a detached HEAD at the benign commit and the working tree contained benign code. The other condition is the host. GitHub rejects a 40-hex branch or tag name outright. Bitbucket and self-hosted git servers do not, and those are supported marketplace backends: Air notes that Anthropic’s documentation lists both Bitbucket and self-hosted git as valid marketplaces for Claude Code.

The pin is fetched but never checked out

Gemini CLI takes a different route to the same place. It pins with --ref and installs in three steps:

git clone --depth 1 <plugin repo> ./
git fetch origin 41d0bc0a4aeb2fbf797dacea39e876d98c95024b
git checkout FETCH_HEAD

The fetch retrieves the correct commit and records it in .git/FETCH_HEAD. The subsequent checkout does not have to read that file. If the repository’s default branch is itself named FETCH_HEAD, the name resolves to the branch and the fetched commit is discarded in favor of attacker-controlled default-branch content.

The fix is one assertion, and it has to live in the client

Air’s prescribed fix closes both variants:

test "$(git rev-parse HEAD)" = "<pinned-sha>" || abort

The check has to resolve HEAD, not the ref that was requested, which is exactly the distinction the Gemini variant slips through. And it has to run inside the agent: the pin is resolved on the client, so no marketplace can enforce the guarantee it advertises. A marketplace can blunt the branch-name variant by allowing only hosts that reject SHA-shaped names, effectively GitHub only, and that bans backends the agents officially support while doing nothing at all for the Gemini CLI variant.

Patch status, as of September 21, 2026

  • Claude Code: patched in 2.1.179, which Anthropic confirmed on 2026-06-17.
  • Codex: patched in 0.146.0. The release notes for that version, published 2026-07-29, include the line “Verify Git plugin SHA checkouts” (#34644). Air verified the fix on 2026-08-12.
  • GitHub Copilot: no fix shipped. A GitHub spokesperson told The Register that GitHub does not allow branch or tag names resembling commit SHAs, so “the reported vulnerability cannot be exploited on GitHub.” Air’s answer is that marketplaces can be hosted on other platforms, Bitbucket among them, and that Copilot supports marketplaces from those hosts, so the host-level mitigation is a policy of one forge rather than a fix in the agent.
  • Gemini CLI: Google confirmed on 2026-08-04 that no fix will ship, because the CLI is deprecated in favor of Antigravity. Every existing install stays vulnerable, permanently. Antigravity is not reachable by this attack because it has no marketplace plugin SHA pinning to bypass. The fix for those users is migration, not patching.

What the intuition gets wrong

The instinct is that a full 40-hex commit id is the strongest pin available, because it is content-addressed and immutable. The uncomfortable truth is that immutability was a property of the object, not of the lookup. Handed to git checkout, a commit hash is first a name to resolve, and names lose to refs. Nobody had to break SHA-1 to defeat SHA pinning. They had to name a branch.

That is why “review more carefully” and “only use pinned plugins” are not mitigations here, and why the flaw is identical in four codebases built by four different organizations: it is a design assumption in how agent installers consume a pin, not an implementation slip. Air also notes the asymmetry that makes it durable. The pin is resolved inside the agent, so a marketplace cannot fix it, and the agents that could ship the assertion are the ones with the incentive to promote their marketplace.

What operators should change

  • Know what your agents have installed and from where. Plugin and skill marketplaces are the distribution layer beneath the agent, and each agent has its own trust set. Inventory the marketplaces each one trusts and which host each marketplace lives on, since the host is what decides whether the branch-name variant applies.
  • Turn off background plugin auto-update where the agent allows it. Auto-update converts a marketplace pin bump into a silent install event. If a pin change is a decision, the rug-pull requires a human who can notice.
  • Verify the object, not the reference. Where the agent does not check, wrap the install or add a CI gate that asserts git rev-parse HEAD equals the pin and hashes the plugin tree against a recorded value. This is the only check that closes both variants.
  • Treat a plugin install as code execution with the agent’s privileges. Whatever tokens, cloud credentials and SSH keys the agent can reach were reachable by the attacker’s code too. Least privilege and egress controls bound the damage, but they do not restore the pin’s guarantee.
  • Update the agents that have fixes: Claude Code 2.1.179 or later, Codex 0.146.0 or later. Anything still on Gemini CLI is exposed by design now.

A pin is not a control. It is a reference that the consumer has to resolve and then verify, and every vendor review process built on top of pinning inherits whatever the client does with it. The failure here was not a missing review or a stolen key. It was a checkout that asked for the right thing and never confirmed it got it.

Sources:

Keep reading