Supply Chain Security

Plugin4Shell: A Branch Named Like a Commit Beats the Pin Your AI Agent Trusted

Dark cyberpunk illustration of two identical sealed metal crates on a night-time conveyor line, one lifted by a robotic arm and glowing orange from inside through its seams while the other sits cold under cyan light.

A forty-character commit hash in a plugin manifest is meant to name one exact tree of code. Git will hand your agent a different tree if the repository owner creates a branch whose name is that hash.

Air Security published Plugin4Shell on September 18. It affects the plugin installers in four coding agents: Claude Code, OpenAI Codex, GitHub Copilot and the Gemini CLI. Each one pins a marketplace plugin to a commit SHA, checks that SHA out with git, and then loads whatever landed in the directory. None of them checked that the code in the working tree was the code the SHA names. Air Security found the flaw in May and reported it to vendors in June. Help Net Security records the patch status: Claude Code fixed it in 2.1.179 and Codex in 0.146.0, GitHub Copilot has shipped no fix, and Google has retired the Gemini CLI rather than patch it. No CVE has been assigned and no vendor has published an advisory.

Who owns this, and who can close the tab

You own this if a developer on your payroll runs one of those four agents with a plugin, skill or extension installed from a marketplace. Auto-update makes it worse, because the install path runs in the background with no prompt and no click. Contractors count. Personal laptops running a company repo count. The agent runs with the developer's shell, the developer's SSH keys and the developer's cloud credentials, so a plugin that executes attacker code is a developer workstation compromise with the source tree already checked out.

You can close the tab if your agents run with no third-party plugins at all, or if plugin installs are blocked by policy, or if the only marketplace you allow is one you host and control. A shop that uses the hosted chat products rather than the command-line agents is also clear. This is a plugin-installer flaw, so no plugins means no exposure.

One number sets the scale. Air Security's own test plugin, published to demonstrate the delivery path, reached more than 26,000 agents before it was pulled. Their earlier work on hijacked agent skills counted 925 of them across roughly 134,000 agents. Nobody has reported real-world abuse of Plugin4Shell yet.

The pin was never doing the job you thought

The mental model most teams carry is borrowed from container digests and lockfile integrity hashes. You write down a cryptographic identifier, the tool fetches content, and the tool refuses the content if it hashes to something else. That is what sha256: in an image reference does, and what integrity in a package-lock.json does.

Git does something different. A commit SHA is the name of an object, and it is also a legal name for a reference. When you hand a name to git checkout, git has to decide which of the two you meant, and the answer is documented in gitrevisions. Git works down an ordered list: $GIT_DIR/<refname> first, then refs/<refname>, then refs/tags/<refname>, then refs/heads/<refname>, then the two remote-tracking forms. Nothing in that sequence verifies anything. It resolves a string to a commit, and a branch is allowed to carry any name the server will accept.

Air Security documented a second shape of the same mistake in the Gemini CLI, which fetched the pinned commit and then checked out FETCH_HEAD. That name sits at the top of the disambiguation list, so a repository whose default branch is called FETCH_HEAD gets the same result by a different route. Two installers, two spellings, one missing comparison.

So the attack writes itself. Publish a clean plugin. Let the marketplace review it and pin it at 34aa519.... Then create a branch called 34aa519..., point it at a commit that contains a payload, and make it the repository's default branch. Every agent that clones the repository and checks out the pin gets the payload. The marketplace still displays the original hash. The install still reports success.

What git actually returns, measured

I built the scenario in a scratch directory on git 2.43.0 to see which way the ambiguity falls. The result is narrower than the general description and more useful because of it.

# A repo with two commits. ea91c8c8 is the reviewed commit the marketplace pinned.
# ef911ec9 carries the payload. A branch named after the pin points at ef911ec9.

$ git rev-parse ea91c8c8a45c0a023c02b8b03c2e7375ace3d4e1
ea91c8c8a45c0a023c02b8b03c2e7375ace3d4e1        # the object

$ git rev-parse refs/heads/ea91c8c8a45c0a023c02b8b03c2e7375ace3d4e1
ef911ec975e8c366cccadd7d3395879616303afa        # the branch

$ git checkout ea91c8c8a45c0a023c02b8b03c2e7375ace3d4e1
warning: refname 'ea91c8c8a45c0a023c02b8b03c2e7375ace3d4e1' is ambiguous.
Switched to branch 'ea91c8c8a45c0a023c02b8b03c2e7375ace3d4e1'

$ git rev-parse HEAD
ef911ec975e8c366cccadd7d3395879616303afa        # you are on the payload

$ cat f
v2-evil

Three things in that transcript are worth keeping. git rev-parse given the bare hash returns the object, so a tool that resolves the pin and a tool that checks it out can disagree with each other. git checkout prefers the branch and says so in one word, "Switched", where a genuine commit checkout says "HEAD is now at" and detaches. And git does warn. The line refname ... is ambiguous goes to stderr on every one of these operations, which means the signal existed in the agents' subprocess output and nothing was reading it.

The reason four independent teams shipped the same defect is that git checkout <sha> looks like a content-addressed operation and behaves like a name lookup. A real content-addressed fetch fails closed: ask a registry for sha256:abc... and it either returns bytes that hash to abc... or it returns an error. Git resolves the name, hands you a tree, and leaves the verification to you. Every tool that treats the two as equivalent inherits this bug.

I also tried the same trick with a tag rather than a branch. Git printed the same ambiguity warning and then resolved to the object anyway, leaving the reviewed code in place. The vector that works is a branch, which matches Air Security's description of the attacker setting the hash-named branch as the repository default. That detail matters for anyone writing a detection: a hash-shaped entry under refs/heads/ in a plugin repository is the thing to alert on.

Reproduce it before you believe it

Ten lines, no network, no agent required. Run this in a scratch directory to confirm the behaviour of the git build your developers actually have, because the ambiguity handling is a git-version question as much as an agent question.

#!/bin/bash
set -e
d=$(mktemp -d); cd "$d"
git init -q --initial-branch=main repo && cd repo
git config user.email t@example.invalid; git config user.name t
echo "reviewed" > plugin.sh; git add .; git commit -qm v1
PIN=$(git rev-parse HEAD)                       # what the marketplace records
echo "payload" > plugin.sh; git commit -qam v2  # attacker's later commit
git branch "$PIN" HEAD                          # branch NAMED like the pin
git checkout -q main

git checkout "$PIN"                             # what the agent runs
echo "pinned : $PIN"
echo "actual : $(git rev-parse HEAD)"
echo "content: $(cat plugin.sh)"
[ "$(git rev-parse HEAD)" = "$PIN" ] && echo "PIN HONOURED" || echo "PIN BYPASSED"

If the last line reads PIN BYPASSED, your git resolves the branch ahead of the object, and any tool on that machine that pins by SHA and checks out by SHA is trusting a string it never verified.

Three checks to run on Monday

1. Raise the version floor on the two agents that have a fix

Claude Code needs 2.1.179 or later and Codex needs 0.146.0 or later. Collect the versions across your developer fleet rather than asking people, because the agents self-update on their own schedule and a laptop that has been asleep for a fortnight is behind.

# Run per developer workstation, or through your MDM's script runner.
for bin in claude codex copilot gemini; do
  command -v "$bin" >/dev/null && printf '%-8s %s\n' "$bin" "$($bin --version 2>&1 | head -1)"
done

# Every plugin repo the agents have cloned, and the ref each one sits on.
find "$HOME" -type d -name '.git' -path '*plugin*' -prune 2>/dev/null | while read -r g; do
  r=$(dirname "$g")
  printf '%s\n  HEAD=%s ref=%s\n' "$r" "$(git -C "$r" rev-parse HEAD)" \
    "$(git -C "$r" symbolic-ref -q HEAD || echo detached)"
  # A branch whose name is 40 hex characters has no innocent explanation.
  git -C "$r" for-each-ref --format='  SUSPECT REF %(refname)' refs/heads refs/remotes \
    | grep -E '[0-9a-f]{40}$' || true
done

Any line beginning SUSPECT REF is the indicator. Git itself says it never creates a reference ending in forty hex characters, so one in a plugin repository was put there deliberately.

2. Turn off background updates for third-party catalogues

The zero-click property of Plugin4Shell comes from auto-update. An attacker who ships a clean version, waits for the marketplace to re-pin, and then swaps the branch behind the new hash reaches every installed agent without anyone approving anything. Manual updates put a human between the swap and the execution.

3. Verify the commit after the checkout, in anything you build

If you write tooling that installs code by SHA, and plenty of teams have a small internal script that does exactly this, add the comparison. It is one line and it closes the whole class.

git clone --quiet "$REPO" "$DEST"
git -C "$DEST" checkout --quiet "$PIN"

# The pin is a claim until you check it. Compare what you got, not what you asked for.
got=$(git -C "$DEST" rev-parse HEAD)
if [ "$got" != "$PIN" ]; then
  echo "refusing $REPO: asked for $PIN, working tree is $got" >&2
  rm -rf "$DEST"; exit 1
fi

Air Security's recommendation is the same one, and they are right that it has to live inside the agent, where the pin is resolved. Until Microsoft ships that for Copilot, the control you hold is the plugin allowlist and the update setting.

4. If a suspect ref turns up, treat it as a workstation compromise

The agent runs plugin code with the developer's identity. Scope the response to that: rotate the SSH keys and cloud credentials reachable from the machine, pull the shell history and the agent's own tool-call log for the window between the plugin's install date and now, and check outbound connections from the workstation over that period. Deleting the plugin directory removes the code and none of the access it had.

Verify the commit you got, not the commit you asked for

Plugin4Shell is a small bug with a wide blast radius because four independent teams made the same assumption about a tool they all use. Treat every pinned-SHA install in your environment as unverified until something compares git rev-parse HEAD against the pin. Start with the agents your developers run today: raise Claude Code to 2.1.179, raise Codex to 0.146.0, disable background plugin updates on Copilot until a fix exists, and move Gemini CLI users off a tool its vendor has stopped maintaining. Then grep your own build scripts for git checkout next to a variable holding a hash, because the agents are not the only software that pins this way.

Want to try our open-source security tools?

We build open-source tools that automate the checks teams keep meaning to script, including supply-chain and exposure auditing. Browse them on GitHub, or book a session to talk through how AI coding agents should be governed on your developer estate.