Security / AI software supply-chain deep dive
Plugin4Shell: why an AI coding tool’s pinned commit was not actually pinned
A plugin marketplace can display a 40-character Git commit, the installer can run git checkout with that value, and the working tree can still contain attacker-controlled code. The missing invariant is simple: after checkout, the client must verify that HEAD equals the requested object ID before loading anything.
2026-09-18
The concrete problem
Modern coding tools can install extensions, skills, plugins, or other add-ons from a community marketplace. A marketplace reviewer inspects a proposed version, records the Git commit that was reviewed, and gives the client a pin such as 250b4f…49b1. The security promise is supposed to be: “run exactly the bytes that were reviewed, even if the repository changes later.”
That promise fails if the installer treats a user-controlled string as a Git revision and never checks the object actually placed in the working tree. Git names are not only object IDs. They are also refs: branches, tags, remote-tracking names, and special symbolic names. A repository can contain a branch whose name is the same 40 hexadecimal characters as a commit ID. When that happens, a command that looks content-addressed can resolve to the branch instead.
Air Security disclosed this design flaw in 2026 as Plugin4Shell, reporting affected marketplace flows in Claude Code, OpenAI Codex, GitHub Copilot, and Gemini CLI. The important lesson is broader than any one product: a content hash is not a verified content hash until the consumer compares the resolved object with the expected value.
actualHead == expectedCommit, and it must do so before plugin discovery, hooks, dependency installation, or any other code can execute.What the pin was meant to protect
Supply-chain protection has three separate questions that are easy to collapse into one:
| Question | Correct mechanism | What can go wrong |
|---|---|---|
| Did the marketplace review this version? | Review a specific tree and record its commit ID. | A mutable tag or branch later points to different content. |
| Did the client fetch that version? | Fetch the expected object and verify its cryptographic identity. | A ref with the same spelling resolves to another object. |
| Did the client execute only that version? | Verify the checked-out HEAD before loading code. | Hooks, scripts, or plugin metadata run before verification. |
A Git commit ID is a hash of a commit object, not a reserved namespace. Git's revision parser accepts many syntaxes and must disambiguate between refs and object names. That flexibility is useful for humans, but it is a bad default for a security boundary. An installer needs a postcondition, not a plausible-looking input.
The resolution bug in one diagram
Assume the marketplace originally reviewed commit A. Later, an attacker controls the upstream repository and creates a default branch named with the 40-character spelling of A. The branch points to malicious commit M.
The branch must be reachable in the clone, which is why the attack uses the repository's default branch in the reported variant. Hosts differ in whether they permit SHA-shaped branch names. A host-side rule can reduce one version of the attack, but the trust decision still belongs in the client: self-hosted Git servers and other supported hosts may have different ref policies.
Control flow: installation and background update
The vulnerable control flow is not “the model was tricked.” It is an ordinary software supply-chain path:
Auto-update changes the interaction model. A user may have made the only visible decision months earlier, when installing a benign add-on. If the update worker runs with the user's environment, workspace permissions, source-control credentials, cloud tokens, or network reach, a malicious update inherits that reach. “Zero-click” describes the update trigger, not a new kind of code execution primitive.
Why Git makes the mistake possible
Git has two relevant namespaces:
- Objects: commits, trees, blobs, and annotated tags stored by object ID.
- Refs: names under
refs/heads/,refs/tags/, and related namespaces that point to objects.
Commands such as checkout accept a revision expression rather than a “raw object ID only” type. The resolver consults refs and objects, and Git warns when a name is ambiguous. A warning is not a security control: the command can still return success and leave the process on the attacker-controlled branch.
In the reported Claude/Codex-style variant, a full-hex branch can win the resolution used by the checkout path. In the reported Gemini CLI variant, the fetch obtains the expected object, but git checkout FETCH_HEAD can be shadowed by a default branch literally named FETCH_HEAD. The syntaxes differ; the invariant failure is identical. The client asked Git to use a name and failed to verify the object it received.
Open-source implementation landmarks
The marketplace clients are not all implemented in the same repository, so the most reusable code-level map is the boundary between the client and Git:
| Layer | Important state or function | Security question |
|---|---|---|
| Marketplace manifest | Repository URL, requested ref/SHA, plugin identity, update channel | Is the expected value parsed as a strict object ID, or merely passed as a string? |
| Update worker | Clone/fetch, temporary directory, checkout, success marker | Does any code run before the identity check? Is a failed update allowed to replace the old version? |
| Git command boundary | git clone, git fetch, git checkout, git rev-parse HEAD | Are stderr warnings ignored? Is the actual checked-out commit recorded? |
| Git revision parser | object-name.c, ref lookup in refs.c | Can a ref and an object ID have the same spelling, and which one wins? |
| Checkout command | builtin/checkout.c | What revision was resolved before updating HEAD and the worktree? |
| Plugin loader | Manifest parser, hook discovery, process launcher, environment construction | Does “installed” mean only “files exist,” or “verified bytes are now executable”? |
The minimum safe state machine is explicit:
desired = strictSha256(manifest.commit)
stage = makePrivateTemporaryDirectory()
cloneOrFetch(repository, stage)
checkout(requestedValue, stage)
actual = readHeadObjectId(stage)
if actual != desired:
quarantine(stage)
fail("pin mismatch")
verifyOptionalTreeDigest(stage)
atomicallyActivate(stage)
onlyNowLoadOrRun(stage)
The activation step matters. If the update worker overwrites the live directory before checking the result, a later failure can leave malicious files in the location used by the loader. Stage, verify, then atomically rename or switch a version pointer. Keep the previous known-good version until the new one passes the full verification policy.
Reproducible lab: make a “pinned” checkout land on another commit
Goal: reproduce the ambiguity with harmless text files, then observe why a successful command is not proof of integrity.
Prerequisites: Git 2.28 or newer, a POSIX shell, and a temporary directory. The lab creates only local repositories and never executes repository contents.
set -eu lab=$(mktemp -d) trap 'rm -rf "$lab"' EXIT git -C "$lab" init -q --initial-branch=main git -C "$lab" config user.email [email protected] git -C "$lab" config user.name Lab printf 'benign\n' > "$lab/plugin.txt" git -C "$lab" add plugin.txt git -C "$lab" commit -q -m benign pinned=$(git -C "$lab" rev-parse HEAD) printf 'malicious-but-never-executed\n' > "$lab/plugin.txt" git -C "$lab" commit -q -am malicious malicious=$(git -C "$lab" rev-parse HEAD) # The attacker-controlled branch has the same spelling as the reviewed commit. git -C "$lab" branch "$pinned" "$malicious" git -C "$lab" symbolic-ref HEAD "refs/heads/$pinned" git clone -q "$lab" "$lab/clone" git -C "$lab/clone" checkout "$pinned" printf 'expected: %s\nactual: %s\ncontent: ' \ "$pinned" "$(git -C "$lab/clone" rev-parse HEAD)" tr -d '\n' < "$lab/clone/plugin.txt" printf '\n'
Expected result: Git prints an ambiguity warning but returns success. The displayed expected and actual hashes differ, and the file contains malicious-but-never-executed. The important observation is that the warning is diagnostic output, while the process exit status is still zero.
Variation 1 — enforce the invariant: append this check after checkout:
actual=$(git -C "$lab/clone" rev-parse HEAD)
test "$actual" = "$pinned" || {
echo "ABORT: pin mismatch; quarantine this checkout" >&2
exit 1
}
It must abort. A production client should make this check before it reads plugin metadata, starts a hook, installs a dependency, or marks the update successful.
Variation 2 — remove the ambiguous ref: delete the SHA-shaped branch in the source repository, clone again, and run the same checkout. The object ID then resolves to the benign commit. This demonstrates why host-side ref restrictions help, but also why they are only defense in depth: a client-side check remains necessary for every supported Git host and every alternate checkout syntax.
Failure modes in production
| Failure | Observable symptom | Why it is dangerous | Control |
|---|---|---|---|
| Ambiguous ref resolves to another commit | Checkout succeeds with a warning; HEAD differs from the manifest | Malicious files are treated as an approved update | Compare actual HEAD to the strict expected object ID; fail closed. |
| Verification occurs after plugin discovery | Logs show “verification failed” after hooks or metadata code ran | The check becomes an audit event instead of a prevention boundary | Verify in a non-executing staging directory before loading any extension code. |
| Live directory is replaced before verification | Failed update leaves mixed or new files behind | Rollback may not restore the old executable set | Stage, verify, and atomically activate; retain the last-known-good version. |
| Only the marketplace host is trusted | GitHub-hosted plugins are safe; Bitbucket or self-hosted plugins bypass assumptions | Supported alternate backends become a supply-chain escape hatch | Put the invariant in the client, independent of host policy. |
| Background update has broad user privileges | Unexpected reads, network connections, or credential use during update | A plugin compromise becomes workstation or CI compromise | Use least privilege, isolated staging, restricted egress, and short-lived credentials. |
| Rollback restores files but not secrets | Tool appears healthy after reinstall | Tokens or source data may already have been exfiltrated | Rotate reachable credentials and investigate logs after suspected execution. |
Security response and debugging
For an organization using marketplace add-ons, the first useful question is not “did the update report success?” It is “what exact object is on disk, and did anything run before we measured it?” A practical investigation sequence is:
- Freeze automatic updates and preserve the installed metadata, update logs, and file timestamps.
- Record the repository URL, manifest pin, configured ref, and the output of
git rev-parse HEADfrom a read-only copy. - Inspect
git show-reffor a ref that shadows the requested name; treat any ambiguity warning as a failed install. - Compare the tree and commit against a known-good mirror or independently fetched object. Do not “fix” the result by trusting the current default branch.
- Upgrade to a vendor-fixed release where one exists, remove and reinstall affected add-ons, and rotate credentials reachable by the tool if untrusted code may have run.
Useful telemetry includes requested commit, resolved HEAD, repository host, update trigger, staging path, verification result, plugin activation, child processes, and outbound destinations. The pair requested_sha / actual_head is more valuable than a single “update succeeded” counter.
What the 2026 disclosure changes
The disclosure is significant because it is a distribution-layer failure repeated across several popular coding tools, not a prompt-injection trick that depends on a particular model response. Air reported patches in Claude Code 2.1.179 and Codex 0.146.0, while reporting different remediation positions for GitHub Copilot and the deprecated Gemini CLI. The Register independently reported the disclosure and vendor responses on September 17, 2026.
The operational consequence is uncomfortable but useful: “we reviewed the plugin and pinned its SHA” is not enough if the client does not verify the checkout. Marketplace review, signed manifests, host-side branch rules, and repository takeover detection all remain useful, but none can replace the final client-side postcondition.
The durable design rule is the same one used in package managers, container runtimes, and deployment systems: bind names to immutable content, resolve them in a narrow namespace, verify the result at the point of use, and only then activate it. A hash printed in a manifest is an intention. The hash measured immediately before execution is the security property.
Sources and further reading
- Air Security: Plugin4Shell technical disclosure — affected tools, exploit variants, timeline, and remediation status.
- The Register: AI coding tools’ 0-click RCE flaw — independent reporting on the disclosure and vendor responses, published 2026-09-17.
- Git documentation: gitrevisions — revision syntax and the ref/object-name resolution model.
- Git documentation: git-check-ref-format — rules for valid ref names and why a full-hex branch name is syntactically possible.
- Git source: object-name.c and builtin/checkout.c — revision lookup and checkout implementation landmarks.
- Claude Code releases and Codex releases — vendor release history for checking installed versions.