chaowen.← Back to archive

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.

Security / AI software supply-chain deep dive · Git ref resolution, SHA pinning, marketplace updates, zero-click execution, affected-tool design patterns, a safe local reproduction, and production response

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.

The invariant: the installer must not trust “checkout succeeded” or “the requested ref was a SHA.” It must require 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:

QuestionCorrect mechanismWhat 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.

Marketplace manifest Repository object database expected = A commit A: reviewed tree │ commit M: malicious tree │ ref branch "A" ─────────▶ M │ │ └── git checkout A ───────────────────┘ │ ▼ working tree = M installer says “success” Safe boundary: actual = git rev-parse HEAD if actual != expected: delete/quarantine and abort only then load plugin metadata or execute code

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:

marketplace manifest │ { repository, expected SHA, permissions, update policy } ▼ client update scheduler ──▶ clone/fetch repository │ │ │ ▼ │ checkout requested name │ │ │ missing: verify resolved HEAD == expected SHA │ │ │ ▼ └──────────────────▶ load plugin / discover hooks / run commands attacker path: benign plugin passes review → users install it → repository takeover → marketplace repins or auto-update runs → ambiguous ref resolves to M → no new click is needed when the scheduler performs the update

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:

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:

LayerImportant state or functionSecurity question
Marketplace manifestRepository URL, requested ref/SHA, plugin identity, update channelIs the expected value parsed as a strict object ID, or merely passed as a string?
Update workerClone/fetch, temporary directory, checkout, success markerDoes any code run before the identity check? Is a failed update allowed to replace the old version?
Git command boundarygit clone, git fetch, git checkout, git rev-parse HEADAre stderr warnings ignored? Is the actual checked-out commit recorded?
Git revision parserobject-name.c, ref lookup in refs.cCan a ref and an object ID have the same spelling, and which one wins?
Checkout commandbuiltin/checkout.cWhat revision was resolved before updating HEAD and the worktree?
Plugin loaderManifest parser, hook discovery, process launcher, environment constructionDoes “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

FailureObservable symptomWhy it is dangerousControl
Ambiguous ref resolves to another commitCheckout succeeds with a warning; HEAD differs from the manifestMalicious files are treated as an approved updateCompare actual HEAD to the strict expected object ID; fail closed.
Verification occurs after plugin discoveryLogs show “verification failed” after hooks or metadata code ranThe check becomes an audit event instead of a prevention boundaryVerify in a non-executing staging directory before loading any extension code.
Live directory is replaced before verificationFailed update leaves mixed or new files behindRollback may not restore the old executable setStage, verify, and atomically activate; retain the last-known-good version.
Only the marketplace host is trustedGitHub-hosted plugins are safe; Bitbucket or self-hosted plugins bypass assumptionsSupported alternate backends become a supply-chain escape hatchPut the invariant in the client, independent of host policy.
Background update has broad user privilegesUnexpected reads, network connections, or credential use during updateA plugin compromise becomes workstation or CI compromiseUse least privilege, isolated staging, restricted egress, and short-lived credentials.
Rollback restores files but not secretsTool appears healthy after reinstallTokens or source data may already have been exfiltratedRotate 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:

  1. Freeze automatic updates and preserve the installed metadata, update logs, and file timestamps.
  2. Record the repository URL, manifest pin, configured ref, and the output of git rev-parse HEAD from a read-only copy.
  3. Inspect git show-ref for a ref that shadows the requested name; treat any ambiguity warning as a failed install.
  4. 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.
  5. 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.

Do not suppress the warning. Git's ambiguity warning is a valuable signal for a general-purpose developer, but the security boundary should not depend on users seeing it. Treat non-equality as a hard error even when the command exits successfully.

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