Safeguard
Vulnerability Research•

CVE-2026-86063: How a Branch Named Like a Commit Hash Bypassed Claude Code's Plugin Pinning

CVE-2026-86063: a branch named after a pinned SHA made git checkout install unreviewed Claude Code plugin code. Why it happens, safe pins, an audit script.

Safeguard Security Team
Security Research
11 min read

Pinning a dependency to a commit hash is supposed to be the strongest pin git offers. CVE-2026-86063 shows how it can fail. Claude Code's plugin installer checked out a marketplace entry's pinned commit with git checkout and never confirmed that it got that commit. A plugin's upstream repository could create a branch whose name was the pinned 40-character hash, and the checkout would land on that branch's code instead of the commit a reviewer had approved.

This post is for developers and platform teams who install Claude Code plugins, and for anyone who writes tooling that pins git dependencies by hash. It covers what the advisory says, a local reproduction of the git behavior underneath it, the checkout forms that are and aren't safe, and a script that looks for hash-named branches in remotes, marketplace files and local clones.

The short answer

CVE-2026-86063 is fixed in Claude Code 2.1.179, released on 16 June 2026. Anyone on auto-update already has it. Check with claude --version and update if you're older. The bug is a general git hazard, not a Claude Code quirk: when a local branch is named exactly like a commit hash, git checkout <hash>, git checkout --detach <hash> and git switch --detach <hash> all go to the branch, while git rev-parse <hash> still returns the real commit. Use git checkout <hash>^{commit} or git reset --hard <hash>, and confirm git rev-parse HEAD equals the pin before you trust the tree.

What the advisory says

Anthropic published GHSA-pq7j-f95f-qfcv in the Claude Code repository on 9 October 2026. The CVE record followed on 13 October, and GitHub's reviewed database added it on 15 October. It's rated high, CVSS 4.0 score 7.5, and affects @anthropic-ai/claude-code before 2.1.179. Claude Desktop's plugin flow had the same flaw. Or Nevo Michrowski of Air Security reported it.

The advisory states the root cause plainly: the install flow ran git checkout on the pinned SHA "without verifying that the resulting HEAD matched the pinned SHA". It also sets out the conditions. A user had to install a malicious plugin pinned to a safe hash, and the plugin's source had to be on a git host that allowed branch names shaped like a SHA.

So the attacker is the plugin's own maintainer, or whoever takes over its repository. That's the case a commit pin is meant to cover. A reviewer approves a specific commit, the marketplace records its hash, and later changes upstream shouldn't reach users until someone reviews them.

That pin is now common. On 16 October, the official Claude plugin marketplace file listed 262 plugins that point at an external git repository and pin it with a sha field.

Reproducing the git behavior

The advisory doesn't publish the installer's exact git commands, so we tested git itself, version 2.54.0, with local repositories only. The script makes an upstream repository with a reviewed commit, then adds a second commit on a branch named after the reviewed commit's hash, and makes that branch the default. A fresh clone then tries five ways to check out the pin:

#!/bin/sh
# Local-only demo: a branch named after a commit hash shadows that commit.
set -e
export GIT_CONFIG_GLOBAL=/dev/null GIT_AUTHOR_NAME=lab GIT_AUTHOR_EMAIL=lab@example.com
export GIT_COMMITTER_NAME=lab GIT_COMMITTER_EMAIL=lab@example.com
rm -rf shalab && mkdir shalab && cd shalab

git init -q -b main upstream && cd upstream
echo reviewed > plugin.txt && git add plugin.txt && git commit -qm reviewed
PIN=$(git rev-parse HEAD)                      # the commit a reviewer approved
git switch -qc next && echo changed > plugin.txt && git commit -qam changed
git branch "$PIN" next                          # branch named after the pin
git symbolic-ref HEAD "refs/heads/$PIN"         # and make it the default branch
cd ..

git clone -q upstream client && cd client
try() {
  git -c advice.detachedHead=false -c advice.objectNameWarning=false "$@" >/dev/null 2>&1
  cmd=$(echo "git $*" | sed "s/$PIN/<pin>/")
  printf '%-34s HEAD=%s  %s\n' "$cmd" "$(git rev-parse --short HEAD)" "$(cat plugin.txt)"
}
echo "pin is $(echo "$PIN" | cut -c1-7)"
echo "git rev-parse <pin> says $(git rev-parse --short "$PIN" 2>/dev/null)"
for args in "checkout $PIN" "checkout --detach $PIN" "switch --detach $PIN" \
            "checkout $PIN^{commit}" "reset --hard $PIN"; do
  git switch -q "$PIN" 2>/dev/null
  try $args
done

The output, on 16 October:

pin is 94fa5d5
git rev-parse <pin> says 94fa5d5
git checkout <pin>                 HEAD=eb2f931  changed
git checkout --detach <pin>        HEAD=eb2f931  changed
git switch --detach <pin>          HEAD=eb2f931  changed
git checkout <pin>^{commit}        HEAD=94fa5d5  reviewed
git reset --hard <pin>             HEAD=94fa5d5  reviewed

Three of the five land on the branch. The reason is in how git checkout reads its argument. It first asks whether the argument names a local branch, and only then treats it as a commit. --detach doesn't change that order. It detaches at the branch tip instead of switching to it. git rev-parse uses the other order and prefers the hash, which is why a tool that checks the pin with rev-parse before checking out will see nothing wrong.

Git knows these names are a problem. With advice enabled, it warns that a ref ending in 40 hex characters "will be ignored when you just specify 40-hex". For checkout, as the output shows, the branch wins.

When the hash-named branch is local

The branch has to exist locally for checkout to pick it. In our tests, two common ways to clone created one:

  • The remote's default branch is the hash-named branch. git clone creates a local branch for the default, as in the script above.
  • git clone --branch <hash>. Git looks up the name among the remote's branches, finds the hash-named one, and checks out its tip.

Two other cases fell back safely. When the remote had the branch but the clone had no local branch of that name, git checkout <hash> went to the real commit. And git fetch origin <hash> fetched the real commit, not the branch, in our test over git's file:// transport. When the pinned commit was missing from the clone entirely, checkout failed with unable to read tree, which is the correct result.

We didn't test which git hosts accept hash-shaped branch names, or how other tools that pin by hash, such as CI action runners, resolve them. The hazard applies to any tool that runs git checkout <hash> on a clone where such a branch can exist.

Safe ways to check out a pin

From the reproduction, these forms resolve to the commit:

git checkout --detach "$PIN^{commit}"   # ^{commit} forces object lookup
git reset --hard "$PIN"                 # reset resolves the hash as an object

Then verify, because it's cheap and catches every variant of this bug:

test "$(git rev-parse HEAD)" = "$PIN" || { echo "pin mismatch" >&2; exit 1; }

The advisory names the missing step as exactly this comparison. If you maintain a tool that installs anything from git by hash, add it after every checkout, whatever form the checkout takes.

Find hash-named branches

This script checks three places: remote repositories, through git ls-remote; a Claude Code marketplace file, checking every entry that pins an external URL; and a local clone, where it also compares HEAD with a pin you give it. It flags branches and tags whose whole name is a 40- or 64-character hash, which is the shape that can shadow a commit. Refs that only end in a hash, such as refs/heads/sync/<hash> or CI refs, can't, so it ignores them. It runs read-only git commands and uses only the Python standard library.

#!/usr/bin/env python3
"""Find git refs named like commit hashes, the trick behind CVE-2026-86063.

A branch or tag whose name is a 40- or 64-character hex string can make
`git checkout <sha>` land on the branch instead of the commit. This script
checks remote repositories, a Claude Code plugin marketplace file, or a local
checkout. It only runs read-only git commands (ls-remote, rev-parse,
for-each-ref). Standard library only.

  python3 sha_ref_audit.py remote https://github.com/org/repo.git [...]
  python3 sha_ref_audit.py marketplace .claude-plugin/marketplace.json
  python3 sha_ref_audit.py checkout path/to/clone --pin <sha>
"""
import argparse, concurrent.futures as cf, json, os, re, subprocess, sys

HEX = r"(?:[0-9a-f]{40}|[0-9a-f]{64})"
# Only a branch or tag whose whole short name is a hash can shadow a commit in
# `git checkout <sha>`. Refs like refs/heads/sync/<sha> or CI refs don't match.
SHADOW = re.compile(rf"^refs/(?:heads|tags|remotes/[^/]+)/({HEX})$", re.I)
ENV = dict(os.environ, GIT_TERMINAL_PROMPT="0")


def git(*args, cwd=None):
    r = subprocess.run(["git", *args], cwd=cwd, env=ENV, capture_output=True, text=True, timeout=60)
    return r.returncode, r.stdout


def hex_named(refs):
    """refs: iterable of (oid, refname). Returns branches and tags named like a hash."""
    return [(oid, ref) for oid, ref in refs if SHADOW.match(ref)]


def remote_refs(url):
    code, out = git("ls-remote", url)
    if code:
        return None
    return [tuple(line.split("\t", 1)) for line in out.splitlines() if "\t" in line]


def check_remote(url, pin=None):
    refs = remote_refs(url)
    if refs is None:
        return url, "unreachable", []
    bad = hex_named(refs)
    return url, "FLAG" if bad else "ok", bad


def report(url, status, bad, pin=None):
    label = f"{url}" + (f" (pin {pin[:12]})" if pin else "")
    print(f"{status:<11} {label}")
    for oid, ref in bad:
        same = pin and ref.rsplit("/", 1)[-1].lower() == pin.lower()
        note = "  <- named after the pinned commit" if same else ""
        print(f"            {ref} -> {oid[:12]}{note}")


def cmd_marketplace(path, workers):
    data = json.load(open(path))
    targets = []
    for p in data.get("plugins", []):
        src = p.get("source")
        if isinstance(src, dict) and src.get("url") and src.get("sha"):
            targets.append((src["url"], src["sha"]))
    print(f"{len(targets)} plugin entries pin an external git URL to a commit\n")
    flagged = unreachable = 0
    with cf.ThreadPoolExecutor(workers) as pool:
        for (url, status, bad), (_, pin) in zip(pool.map(lambda t: check_remote(*t), targets), targets):
            if status == "FLAG":
                flagged += 1
                report(url, status, bad, pin)
            elif status == "unreachable":
                unreachable += 1
                report(url, status, bad, pin)
    print(f"\n{flagged} flagged, {unreachable} unreachable, {len(targets) - flagged - unreachable} clean")
    return flagged


def cmd_checkout(path, pin):
    code, out = git("for-each-ref", "--format=%(objectname)\t%(refname)", cwd=path)
    if code:
        sys.exit(f"{path}: not a git repository")
    bad = hex_named(tuple(l.split("\t", 1)) for l in out.splitlines())
    for oid, ref in bad:
        print(f"FLAG        local ref {ref} -> {oid[:12]}")
    if pin:
        _, head = git("rev-parse", "HEAD", cwd=path)
        head = head.strip()
        if head.lower() == pin.lower():
            print(f"ok          HEAD is the pinned commit {pin[:12]}")
        else:
            print(f"MISMATCH    HEAD is {head[:12]}, pin is {pin[:12]}")
            return 1
    return 1 if bad else 0


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    r = sub.add_parser("remote"); r.add_argument("urls", nargs="+")
    m = sub.add_parser("marketplace"); m.add_argument("path"); m.add_argument("--workers", type=int, default=8)
    c = sub.add_parser("checkout"); c.add_argument("path"); c.add_argument("--pin")
    a = ap.parse_args()
    if a.cmd == "remote":
        hits = 0
        for url in a.urls:
            url, status, bad = check_remote(url)
            report(url, status, bad)
            hits += status != "ok"
        sys.exit(1 if hits else 0)
    if a.cmd == "marketplace":
        sys.exit(1 if cmd_marketplace(a.path, a.workers) else 0)
    sys.exit(cmd_checkout(a.path, a.pin))


if __name__ == "__main__":
    main()

On the lab repositories from the reproduction, the remote check and a fresh clone after git checkout <pin>:

$ python3 sha_ref_audit.py remote ./upstream
FLAG        ./upstream
            refs/heads/94fa5d5a60c645480bfba380427c51792214828f -> eb2f9314fe33

$ python3 sha_ref_audit.py checkout ./fresh --pin 94fa5d5a60c645480bfba380427c51792214828f
FLAG        local ref refs/heads/94fa5d5a60c645480bfba380427c51792214828f -> eb2f9314fe33
FLAG        local ref refs/remotes/origin/94fa5d5a60c645480bfba380427c51792214828f -> eb2f9314fe33
MISMATCH    HEAD is eb2f9314fe33, pin is 94fa5d5a60c6

And against the official marketplace file, as installed on 16 October:

$ python3 sha_ref_audit.py marketplace ~/.claude/plugins/marketplaces/claude-plugins-official/.claude-plugin/marketplace.json
262 plugin entries pin an external git URL to a commit

0 flagged, 0 unreachable, 262 clean

None of the 262 upstream repositories had a branch or tag named like a commit hash. Two had refs ending in hashes, from template-sync tooling and a CI system, which the narrower rule correctly ignores.

What to do

  1. Update Claude Code to 2.1.179 or later if you've turned off auto-update, and update Claude Desktop.
  2. Audit the marketplaces you've added. Third-party marketplace files can pin plugins the same way. Run the script on each marketplace file under ~/.claude/plugins/marketplaces/.
  3. Fix your own tooling. Search build scripts, installers and CI for git checkout $SHA and similar. Change them to "$SHA^{commit}" and add the HEAD check.
  4. Reject hash-named branches where you can. A server-side hook or branch rule that refuses ref names matching ^[0-9a-f]{40}$ costs nothing, since git itself normally never creates them.
  5. Treat a flagged ref as an incident if it appears in a repository you pin. There's no ordinary reason for a branch named after a commit.

For the wider controls around coding agents, our Claude Code security guide covers permissions and MCP servers, and our post on pinning GitHub Actions to SHAs correctly covers the CI side of hash pinning.

The fair view

This was a narrow bug. Exploitation needed a malicious plugin that a user chose to install, a pin that someone had reviewed, and a host that accepts hash-shaped branch names. Anthropic fixed it in June, months before the advisory and CVE, and auto-update delivered the fix to most users without action. The advisory is clear about the cause and credits the reporter.

The broader point is about pins. A hash pin is only as strong as the code that resolves it, and git's checkout command reads its argument in an order most people don't expect. Hash pins now sit under plugin marketplaces, CI actions and package managers that install from git. Each of them is one unverified git checkout away from the same bug. Checking that HEAD equals the pin after checkout, every time, closes it whatever git does with the name.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.