Plugin4Shell: SHA Pinning Without HEAD Verify Is Theater
Plugin4Shell beat SHA pinning in four coding agents. A Tuesday drill: version floors by harness, post-checkout HEAD assert, auto-update posture, plugin census.
Go deeper. Build your own.
The manifest says the plugin is pinned to 3f9c…. The install log says it checked out 3f9c…. Both lines are true, and the code in the working tree belongs to somebody else.
That is Plugin4Shell in one sentence: the harness ran git checkout against a pinned SHA, an attacker had named the repository’s default branch with that same forty-character string, git preferred the branch, and the agent reported a clean install at the pin. Then background auto-update did it again on every machine that had the plugin.
Air Security published the research on Sep 17, 2026. Two of the four affected harnesses have a fix, one has no patch and is exposed through plugin repos hosted off GitHub, and one will never be patched. The fleet-side work is not the patch. It is finding out which machines sit below the floor, proving each plugin checkout landed where its manifest says, and deciding what plugin auto-update does while you find out.
This is that drill: a plugin census that reaches WSL and CI runners, a version matrix by harness, one assertion that closes both variants, and an auto-update posture until the census comes back clean. Budget a morning for the first pass and ten minutes per host after.
Air Security’s Plugin4Shell disclosure, Sep 17, 2026: the checkout nobody checked
Or Nevo, Dor Granat and Niv Hoffman of Air Security published Plugin4Shell on Sep 17, 2026. The affected products are exactly four: Claude Code, Codex, GitHub Copilot and Gemini CLI. Air calls it a zero-click, high-severity remote code execution flaw, and the line that matters is the mechanism: “Every affected agent checks out the pinned commit but never checks that it actually landed there”.
Two variants share that root. In the first, which reaches Claude Code, Codex and Copilot, an attacker who controls a plugin repository creates a branch whose name is the exact 40-hex pinned SHA and makes it the default. When the harness runs git checkout <sha>, git resolves the name as a ref rather than an object, because, in Air’s words, “when a name is both a valid ref and an object id, git prefers the ref”. In the second, which reaches Gemini CLI, the default branch is named FETCH_HEAD, so the checkout resolves to the branch and the commit just fetched is thrown away.
Either way, per Air, “The working tree is now attacker-controlled, and the agent reports a successful install at the pinned commit.”
Screenshot: Air Security, “Plugin4Shell - Zero Click RCE Vulnerability found in top 4 most popular coding agents, millions of agents affected” (Sep 17, 2026), captured Sep 19, 2026.
The part that turns a bug into a fleet event is the update path. Air: “the same git checkout re-runs on background auto-update - the default in Claude Code and Codex - so when the marketplace bumps the pinned SHA, the swap reaches already-installed plugins with no user action.” A plugin vetted in June can be swapped in September by a routine pin bump in the marketplace plus a branch named after the new pin, on every host that trusts that marketplace.
Hosting decides how far the first variant reaches. GitHub told The Register: “To prevent abuse of SHAs, GitHub does not allow users to create branch or tag names that resemble commit SHAs.” and “This mitigation ensures the reported vulnerability cannot be exploited on GitHub.” Bitbucket and any self-hosted git server allow such names, which is why Air says “Microsoft Copilot is also still vulnerable because it supports marketplaces from such platforms as well, which exposes it to the vulnerability.”
Patch status at publication, per Air and corroborated by The Register and Help Net Security: Anthropic confirmed the fix in Claude Code 2.1.179 on Jun 17, 2026. Codex 0.146.0 carries the bullet “Verify Git plugin SHA checkouts (#34644)” on its release page, and Air verified it fixed on Aug 12, 2026.
Copilot has no patch; Air told The Register it reported to Microsoft in June and got no response. Google confirmed on Aug 4, 2026 that no fix will ship because Gemini CLI is deprecated; Air points those users to Antigravity, which it says the attack does not reach because there is no marketplace plugin SHA pinning to bypass. No CVE, advisory ID or CVSS score appears in Air’s post or in the coverage cited here.
Screenshot: Air Security, “Plugin4Shell - Zero Click RCE Vulnerability found in top 4 most popular coding agents, millions of agents affected” (Sep 17, 2026), captured Sep 19, 2026.
A plugin that updates itself is a privileged actor, not a dependency
A library you pinned in a lockfile runs when your build runs. A plugin in a coding agent runs when the agent runs, with the agent’s credentials, on every host where the agent is installed, and it replaces itself in the background. The success report at the pinned commit is the worst part, because every downstream control, including the transcript, believes it. The plugin bag still needs a boss argued that swapping harness internals leaves the layer above them empty; Plugin4Shell is that empty layer with a shell in it.
It also inverts the GitSpawn intake checklist: there, opening a folder was the danger and a clean clone was the safe door; here, the danger arrives through the clone itself, by checkout, from a remote you chose. The check moves from where code came from to where it landed.
The Plugin4Shell fleet patch drill: census, floors, assert, posture
Steps 1 and 2 tell you how exposed you are; 3 decides what runs in the meantime; 4 and 5 are the controls; 6 is the receipt.
Step 1: Count every plugin checkout on every host, including the ones nobody updated
You cannot verify a pin you have not found. Every marketplace plugin is a git working tree on disk, so the census is a filesystem walk that records the remote, the resolved HEAD and, the column that matters, whether HEAD is detached or attached to a branch. A pinned checkout of a commit leaves HEAD detached. A checkout that landed on a branch named like a commit leaves HEAD attached to refs/heads/<40-hex>, a signal you can grep for today, before anything else in this drill.
#!/usr/bin/env bash
# plugin-census.sh (illustrative; run as the agent user on every host)
set -euo pipefail
host=$(hostname)
find "$HOME" -maxdepth 6 -type d -name .git 2>/dev/null | while read -r g; do
w=$(dirname "$g")
remote=$(git -C "$w" config --get remote.origin.url 2>/dev/null || echo none)
head=$(git -C "$w" rev-parse HEAD 2>/dev/null || echo none)
ref=$(git -C "$w" symbolic-ref -q HEAD 2>/dev/null || echo detached)
printf '%s\t%s\t%s\t%s\t%s\n' "$host" "$w" "$remote" "$head" "$ref"
done
Filter the output to each harness’s plugin and marketplace directories, taking the paths from that harness’s own docs for the version you run, then join it against each harness’s own plugin state. Claude Code records installed plugins in an installed_plugins.json file, per its changelog, which also notes a fix for that file keeping an old commit after a pinned-commit plugin updated. Where record and filesystem disagree, the filesystem wins, because the filesystem is what runs.
Count copies rather than product names. A Windows developer box often has the same CLI twice, natively and inside WSL, and the WSL copy is the one that missed the update. Add containers built from an image that bakes the harness in, CI runners that install plugins at job start, and the golden image your platform team publishes quarterly. The inventory shape is the one from the MCP server inventory ritual with three new columns: pinned SHA, resolved HEAD, detached or attached.
Step 2: Set the version floor per harness, and read the dates behind it
The floor is the version at which the harness itself performs the HEAD check. Below it, step 4 is all that stands between a marketplace edit and a shell.
| Harness | Status at Sep 17, 2026 (Air) | Floor or action |
|---|---|---|
| Claude Code | Patched; Anthropic confirmed the fix Jun 17, 2026 | Floor 2.1.179 |
| Codex | Patched; Air verified fixed Aug 12, 2026 | Floor 0.146.0 |
| GitHub Copilot | No patch at publication; exposure depends on where plugin repos are hosted | Restrict marketplaces to GitHub-hosted repos; assert on everything else |
| Gemini CLI | Deprecated; Google confirmed Aug 4, 2026 that no fix will ship | Remove from the fleet or block its marketplace path; Air points to Antigravity |
Check the harness version on every host from step 1 with each CLI’s own version flag, and write the result next to the plugin rows. Release history for the two patched harnesses lives on github.com/anthropics/claude-code and github.com/openai/codex.
Air’s timeline as published. Ninety-two days separate Anthropic’s fix from the public post.
The timeline argues for a floor that moves on a schedule rather than on news. Anthropic’s confirmed fix predates the public post by 92 days; Air’s verification of the Codex fix, by 36. A fleet that only updates when a headline lands spent that window below a floor it did not know existed. A fleet that diffs versions weekly against the latest release was above it by accident, the only kind of luck worth engineering.
Step 3: Decide what plugin auto-update does until the census is clean
Air does not tell you to disable auto-update. Its mitigation is to update the harness and, in the harness, to assert on HEAD. What follows is operator practice, not the researchers’ advice, and it hinges on separating two updates that share a word.
Harness updates are the fix; keep them flowing everywhere, by self-update or by your package channel, because 2.1.179 and 0.146.0 arrive that way. Plugin auto-update is the attack path on any host below the floor, because it re-runs the unverified checkout whenever a marketplace bumps a pin. The posture follows host state, not preference.
| Host state | Plugin auto-update | Why |
|---|---|---|
| Harness at or above the floor | Leave on | The patched checkout asserts on HEAD |
| Harness below the floor, update scheduled | Pause until the update lands, then resume | Every background bump is an unverified checkout |
| Harness cannot reach the floor (Copilot on non-GitHub marketplaces; Gemini CLI) | Pause; install only through the step 4 wrapper | No harness-side check will ever exist |
| CI runner or image that installs plugins at job start | Pin the harness version in the image; the wrapper does the install | Job-start installs are auto-update with a different name |
Air says background plugin auto-update is the default in Claude Code and Codex; it does not say how to turn it off, and neither does this page. Take the switch from each harness’s own docs, and record the posture and the date in the inventory rather than the setting name, because setting names move. Pausing is a bridge measured in days. A fleet that leaves plugin auto-update off for a quarter has traded one unverified state for another, since a plugin that never updates never gets its own fixes either.
Step 4: Put the HEAD assert in front of every plugin load
One assertion closes both variants, and it is Air’s own line: test "$(git rev-parse HEAD)" = "<pinned-sha>" || abort. The subtlety is which side of the comparison you read. Air again: “It has to check the resolved HEAD, not the ref that was requested”.
Asking git whether it can resolve <pinned-sha> tells you nothing, because the attacker’s branch resolves fine. Asking git what HEAD is, after the checkout, is the one question the attack cannot answer honestly.
On a patched harness this assert already runs inside the install path. On hosts below the floor, on Copilot against non-GitHub marketplaces, and on any Gemini CLI not yet removed, you run it yourself, as a wrapper that owns the install and refuses to let the plugin load until it passes.
The gate sits after checkout and before load. The pin is an input; the resolved HEAD is the evidence.
#!/usr/bin/env bash
# pin-verify-install.sh <repo-url> <pinned-sha> <dest> (illustrative wrapper)
set -euo pipefail
url="$1"; pin="$2"; dest="$3"
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || { echo "STOP: pin is not a full 40-hex SHA"; exit 2; }
git clone --no-checkout --quiet "$url" "$dest"
git -C "$dest" checkout --quiet "$pin"
head=$(git -C "$dest" rev-parse HEAD)
test "$head" = "$pin" || { echo "STOP: HEAD $head does not equal pin $pin"; rm -rf "$dest"; exit 3; }
git -C "$dest" symbolic-ref -q HEAD >/dev/null && { echo "STOP: HEAD is attached to a branch, not detached at the pin"; rm -rf "$dest"; exit 3; }
echo "verified: $dest at $pin"
Exit 3 on the HEAD comparison is the attack, or a marketplace that pinned a SHA the repo does not contain, and either deserves a page. The symbolic-ref line is our addition: a legitimate pinned checkout leaves HEAD detached, so an attached HEAD after checking out a commit id means the name resolved to a branch, the first variant’s fingerprint even if a future variant makes the SHAs agree.
Prove the wrapper before you trust it. On a self-hosted git server you control, create a throwaway repository with two harmless commits, then create a branch named with the first commit’s full SHA, pointing at the second, and make it the default. Run the wrapper with the first SHA as the pin.
It must exit 3 and leave nothing behind. If it prints verified, you learned that on a canary rather than a customer’s laptop.
Step 5: Prefer marketplaces and hosts that cannot carry the branch
Air is explicit that the marketplace cannot save you: “Because the pin is resolved inside the agent, no marketplace can enforce it — the fix has to ship in the agent, and updating is the only complete mitigation where one exists”. True of the vulnerability; not the whole story of your exposure, because the first variant needs a host that permits a 40-hex branch name, and GitHub says it does not.
So the allowlist has two tiers, and this is our practice rather than Air’s recommendation. Tier one: marketplaces whose plugin repositories are GitHub-hosted, where GitHub’s statement says the first variant cannot be exploited; the assert still runs, because the second variant and the next one do not care about branch naming rules. Tier two: marketplaces that pull from Bitbucket, a self-hosted server, or a mirror you do not control; these get the wrapper with no exceptions and a person’s name next to the approval. Everything else is a URL someone pasted, and it stays off the agent user’s marketplace list until a reviewer adds it.
Enterprise-vetted marketplaces belong in tier one when they can show you their vetting, not because a vendor says so. Air states that enterprises using its own Air Marketplace and Air Filter were not affected, a vendor claim about vendor products, and the same question applies to any commercial curator: what did you check, when, and can I see the record.
Nor is the question confined to git. Air’s MCPJacking post of Aug 27, 2026 found 155 hijackable entries in an official MCP registry, each resting on an expired domain. Marketplace hygiene after Plugin4Shell and MCPJacking turns both incidents into one intake checklist, and a keyless public server raises the same identity problem with no repository in sight.
Step 6: Write the receipt into the inventory
Each plugin row now carries host, harness version, marketplace, plugin, pinned SHA, resolved HEAD, detached or attached, auto-update posture, the date of the last assert, and who approved the marketplace. That row answers the question a security lead will ask after the next disclosure: did any agent ever run this plugin at a commit we did not pin. A transcript naming the plugin and the session is the other half; together they are fleet replay applied to a checkout.
How the Plugin4Shell drill fails quietly, and the tell for each
- The census missed a host. A laptop that was off, a WSL distro nobody launches, a runner image from March. Signal: a version report from a hostname with no inventory row. Fix the census, not the host.
- The floor moved and you did not. Signal: the weekly version diff shows the latest release above your floor for more than one cycle.
- The wrapper exists but the harness does not use it. Its own install command still runs the unverified checkout. Signal: a plugin directory appears with no wrapper log line. Take marketplace additions away from the agent user with whatever control your harness or endpoint policy offers, or accept that the wrapper is documentation.
- HEAD attached on an existing plugin. The census finds
refs/heads/<40-hex>under a plugin directory today. Treat it as a live compromise: isolate the host, rotate every credential the agent could reach, export the transcript, then re-clone through the wrapper. - Plugin auto-update paused and forgotten. Signal: a pause older than the harness update that was supposed to end it.
- A Copilot marketplace added from a non-GitHub host without review. Signal: a marketplace URL whose host is neither
github.comnor on the tier-two allowlist. A policy failure, not a Copilot failure.
Pinning is a harness feature; verifying it is operating-layer work
Nothing in this drill changes a prompt, a model or a system instruction, because nothing in the exploit touched them. The harness did what its code said, and its code trusted a name. What failed was the layer around it: which build ran on which host, what was allowed to install what, and whether anyone compared the outcome to the intent. That layer is the operating infrastructure for agents, the one that already holds your restricted-mode fleet policy, and it is where the census, the floors and the wrapper live, because a harness cannot audit itself from inside a compromised working tree.
The lesson that outlasts the versions is the shape of the check. Pin what you approved; after every action that is supposed to honor the pin, read back what happened and compare. The same shape fits a model routing table, an MCP endpoint and a subagent’s claimed completion, which is why a fleet that learns it here will recognize it next time under a different name.
FAQ: Plugin4Shell versions, hosting and identifiers
Which versions of Claude Code and Codex fix Plugin4Shell?
Claude Code 2.1.179 and Codex 0.146.0, per Air Security’s disclosure of Sep 17, 2026. Anthropic confirmed its fix on Jun 17, 2026; Air verified the Codex fix on Aug 12, 2026. GitHub Copilot had no patch at publication, and Google said on Aug 4, 2026 that Gemini CLI will not be patched.
Does Plugin4Shell affect plugins hosted on GitHub?
The first variant needs a branch named as a 40-hex SHA, and GitHub told The Register it rejects such branch and tag names, so the flaw cannot be exploited against GitHub-hosted plugin repositories. Bitbucket and self-hosted git servers allow those names. The Gemini CLI variant relies on a branch named FETCH_HEAD instead.
Is there a CVE for Plugin4Shell?
No CVE, advisory identifier or CVSS score appears in Air Security’s post or in The Register and Help Net Security coverage as of Sep 19, 2026. Air describes it as high-severity and zero-click. Track it by name and by harness version floors until an identifier is assigned, and record the floor in your inventory.
Sources
- Air Security, Plugin4Shell - Zero Click RCE Vulnerability found in top 4 most popular coding agents, millions of agents affected (Sep 17, 2026)
- The Register, AI coding agents’ 0-click RCE flaw could hand attackers keys to the kingdom (Sep 17, 2026)
- Help Net Security, Zero-click RCE vulnerability hit four major AI coding agents, two remain unpatched (Sep 18, 2026)
- OpenAI, codex release rust-v0.146.0, “Verify Git plugin SHA checkouts (#34644)”
- OpenAI, codex repository and releases
- Anthropic, claude-code repository, releases and changelog
- Air Security, MCPJacking: 155 Hijackable MCPs Discovered Live in the Official MCP Marketplace (Aug 27, 2026)
