Picture the log line you’d actually see. Your CI job spins up an agent, the agent pulls a plugin, and the console prints something like Verified commit a3f9c2e1... OK. Forty characters of hex, green checkmark, done. You move on. That checkmark is the entire trust model for a huge chunk of the AI coding agent ecosystem right now, and as of September 18, 2026, researchers at Air Security showed it can be worthless.

The bug has a name that tells you exactly what era we’re in: Plugin4Shell. Same shape as Log4Shell — a dependency-loading path nobody scrutinized until it was actively being exploited — except this time the thing loading the dependency is an autonomous agent with your SSH keys.

What’s actually broken

Claude Code, OpenAI Codex, GitHub Copilot, and Google Gemini CLI all support plugins pinned to a specific commit hash. That’s the right idea — pin to a SHA, not a branch name, so nobody can silently swap the code out from under you. The problem is what happens after the pin is declared.

The agent asks Git to check out commit a3f9c2e1.... It gets back a working tree. It reports the SHA it requested. What none of these tools did, until the patches landed, was verify that the tree it actually checked out matches that hash byte for byte.

For Claude Code, Codex, and Copilot, the exploit path runs through Git reference resolution: an attacker creates a branch whose name is the 40-character commit hash the agent is looking for. Git can resolve that branch reference ahead of the actual commit object during checkout. The agent thinks it fetched a3f9c2e1. It actually fetched whatever the attacker put on a branch that happens to be named a3f9c2e1. Gemini CLI’s variant is even cheekier — it abuses a repository branch literally named FETCH_HEAD, redirecting checkout away from the commit that was actually fetched.

Zero-click means exactly what it says here. No approval dialog, no “do you want to install this plugin” prompt, no reinstall step for the victim to rubber-stamp. If your agent has auto-update turned on for plugins — which is the default in most setups because who wants to manually bump forty plugin versions — the swap happens silently on the next background refresh.

And what does the malicious code inherit once it’s running? Whatever the developer running the agent has: local source access, cloud credentials, SSH keys, internal repo access, and in a lot of shops, a live path to production. Air Security’s writeup lays out two realistic attack shapes. One: publish a plugin that looks legitimate, let it accumulate real adoption over months, then flip the upstream repo later. Two, and this is the one that should worry you more: you don’t even need to publish anything new — “compromising an existing plugin maintainer’s repository could produce the same result.” Your trusted, already-installed plugin is the attack surface. You did nothing wrong and you’re still exposed.

Who patched, who didn’t, who can’t

This is where the four vendors split, and it’s worth naming names because the responses are genuinely not equivalent.

Anthropic (Claude Code) — fixed in version 2.1.179. If you’re on an earlier build, you’re exposed. This is the fastest turnaround of the four and the fix is unambiguous: upgrade, done.

OpenAI (Codex) — patched in version 0.146.0. Same story, same clean resolution. Two vendors, two version-bump fixes, no drama.

GitHub (Copilot) — this is the messy one. At time of disclosure, Microsoft “had not issued a fix for Copilot.” GitHub’s own hosted platform mitigates the underlying issue by blocking SHA-like branch and tag names outright, which closes the door for anything hosted on github.com. But that’s a platform-level Band-Aid, not a Copilot fix — if your plugin lives on Bitbucket or a self-hosted Git server, GitHub’s naming restriction doesn’t apply to you, and Copilot itself was still unpatched. Relying on “the platform happens to block this” instead of fixing your own verification logic is the kind of gap that looks fine until someone points a self-hosted Git remote at it.

Google (Gemini CLI) — the sharpest answer of the four, and not in a good way: it’s deprecated and will not receive a fix. Google’s guidance is to migrate to Antigravity instead. If you’re still running Gemini CLI with plugins pinned to commit hashes, there is no patched version coming. Your mitigation is “stop using this,” which is a fine strategy for a new project and a genuinely bad afternoon if you have it wired into a CI pipeline right now.

Rank them if you want a scorecard: Anthropic and OpenAI shipped real fixes fast. GitHub half-fixed it at the platform layer and left Copilot itself exposed at disclosure time. Google didn’t fix it at all — it just told everyone to leave.

Verify the hash yourself — don’t trust the tool’s checkmark

Here’s the part you can act on before any vendor patch reaches you, or if you’re stuck on Gemini CLI with no patch coming. The fix is conceptually simple: don’t let Git reference resolution pick the commit for you. Resolve the object directly and hash the tree contents yourself.

#!/usr/bin/env bash
# verify-plugin-hash.sh
# Usage: ./verify-plugin-hash.sh <repo-url> <pinned-commit-sha>

set -euo pipefail
REPO_URL="$1"
PINNED_SHA="$2"

WORKDIR=$(mktemp -d)
git clone --quiet "$REPO_URL" "$WORKDIR/plugin"
cd "$WORKDIR/plugin"

# Step 1: resolve the SHA as a Git *object*, not a ref/branch name.
# The ^{commit} suffix forces object resolution and fails loudly
# if the "sha" actually only exists as a branch name pointing elsewhere.
RESOLVED=$(git rev-parse --verify "${PINNED_SHA}^{commit}") || {
  echo "FAIL: ${PINNED_SHA} does not resolve to a real commit object"
  exit 1
}

if [ "$RESOLVED" != "$PINNED_SHA" ]; then
  echo "FAIL: resolution mismatch — expected ${PINNED_SHA}, got ${RESOLVED}"
  echo "This is exactly the Plugin4Shell branch-name collision. Stop here."
  exit 1
fi

# Step 2: check out that exact commit object by hash, not by any ref.
git checkout --quiet --detach "$PINNED_SHA"

# Step 3: hash the actual working tree contents and compare against
# a known-good hash you captured the first time you vetted this plugin.
ACTUAL_TREE_HASH=$(git rev-parse "HEAD^{tree}")
echo "Commit:     $PINNED_SHA"
echo "Tree hash:  $ACTUAL_TREE_HASH"

# Optional: full file-content hash for belt-and-suspenders comparison
find . -type f -not -path './.git/*' -print0 \
  | sort -z \
  | xargs -0 sha256sum \
  | sha256sum

rm -rf "$WORKDIR"

The load-bearing line is git rev-parse --verify "${PINNED_SHA}^{commit}". That ^{commit} peel is what forces Git to resolve the string as an actual commit object rather than letting a same-named branch win the lookup — which is the exact ambiguity Plugin4Shell abuses. If the resolved SHA doesn’t match what you asked for, stop immediately. Don’t proceed to checkout. That mismatch is the attack, caught before it touches your filesystem.

Bolt this into your CI as a pre-step before any agent plugin install, and diff the tree hash against a value you recorded the first time you reviewed the plugin. If someone rewrites the upstream repo later, your tree hash changes even though the commit SHA in your config file didn’t — because now you’re actually checking the thing you claimed to be checking.

What to do this week

  • Check your Claude Code version. If it’s below 2.1.179, upgrade now — this one’s a straightforward win.
  • Check Codex. Below 0.146.0 means you’re exposed; go to 0.146.0 or later.
  • If you’re running Copilot against Bitbucket, a self-hosted Git server, or anything that isn’t github.com’s hosted platform, don’t assume GitHub’s branch-name blocking protects you. It doesn’t.
  • If you’re still on Gemini CLI with pinned plugin commits, start your Antigravity migration plan now. There’s no patch coming — “later” is not on the roadmap.
  • Wire the hash-verification script above (or your own equivalent) into any pipeline that auto-installs agent plugins, and stop trusting a green checkmark you didn’t generate yourself.

Your agent’s permission model is only as honest as the layer underneath it that decides which bytes actually get executed. Right now, for at least one of the four tools you’re probably using, that layer still isn’t checking.

Export for reading

Comments