Pin Claude Code Plugins to Commit SHAs in marketplace.json
Branch refs in marketplace.json let a force-push swap your installed plugin overnight. Pin commit SHAs, layer stable and beta channels, and keep upgrades intentional.
Pin Claude Code Plugins to Commit SHAs in marketplace.json
A Claude Code plugin marketplace is just a marketplace.json file pointing at git sources. That simplicity is the appeal and the risk. If your marketplace entries reference a branch like main or a floating tag like v1, the plugin code your operators run tomorrow is whatever the upstream maintainer pushed since yesterday. A force-push, a compromised maintainer token, a moved tag \u2014 any of these silently rewrites the slash commands, hooks, and MCP servers your agents execute, with no install-time prompt.
Pinning each plugin source to an immutable commit SHA closes that window. Layering a stable channel and a beta channel on top of SHA pins gives you a deliberate upgrade path without giving up reproducibility. This article walks through the failure mode in concrete terms, shows the marketplace.json shape that fixes it, and lays out a two-channel workflow you can run with gh and a small shell script.
What marketplace.json actually controls
A Claude Code plugin marketplace lives in a git repo with a top-level .claude-plugin/marketplace.json. Operators add it with /plugin marketplace add <org>/<repo> and then install individual plugins with /plugin install <plugin-name>@<marketplace-name>. The marketplace file is the manifest \u2014 it lists each plugin, where to fetch it from, and what to call it.
The source field on a plugin entry accepts a few shapes. The two most common in the wild today look like this:
{
"name": "your-org-marketplace",
"owner": { "name": "your-org" },
"plugins": [
{
"name": "deploy-helper",
"source": {
"source": "github",
"repo": "your-org/deploy-helper-plugin",
"branch": "main"
},
"description": "Slash commands for safe production deploys"
}
]
}
That entry tells Claude Code to clone your-org/deploy-helper-plugin and check out whatever main points to at install time. If an operator runs /plugin install deploy-helper@your-org-marketplace on Monday and again on Friday, they get two different snapshots of main \u2014 whatever has landed in between. Worse, the Friday install could quietly replace the Monday install on a /plugin update sweep, with no diff shown to the operator.
The fix is a one-line shape change. The same source field accepts a commit ref:
{
"name": "deploy-helper",
"source": {
"source": "github",
"repo": "your-org/deploy-helper-plugin",
"commit": "4f9a2c1b8d3e6f0a7c5b9e2d4f1a8c6b3e9d7f5a"
},
"description": "Slash commands for safe production deploys"
}
Now every install of deploy-helper@your-org-marketplace checks out that exact tree object. A force-push to upstream main cannot reach an installed operator. A tag rewrite on upstream v1.2.0 cannot reach an installed operator. The only way the plugin contents change on an operator's machine is if someone with write access to your marketplace repo edits the SHA in marketplace.json \u2014 which shows up in git log and code review.
Why a branch ref is a silent supply-chain channel
Compare three reference styles for the same plugin install:
| Reference style | Reproducible install | Resists upstream force-push | Resists upstream tag rewrite | Audit signal in marketplace repo |
|---|---|---|---|---|
branch: main | No | No | N/A | None \u2014 branch content drifts invisibly |
tag: v1.2.0 | Mostly | Mostly | No \u2014 tags are mutable on GitHub | Tag move leaves no diff in marketplace repo |
commit: 4f9a2c1b... | Yes | Yes | Yes | Every change is a marketplace.json diff |
Branch refs are the supply-chain equivalent of latest on a Docker tag \u2014 convenient until the day someone you don't control decides to ship a breaking change, or worse. Tags are better than branches because the social contract is "tags don't move," but GitHub permits force-pushing tags by default, and a compromised maintainer token can move v1.2.0 to point at malicious code in under a second. There is no install-time check that v1.2.0 today is the same tree object as v1.2.0 yesterday.
A commit SHA is a content-addressed pointer into the git object graph. The SHA 4f9a2c1b8d3e6f0a7c5b9e2d4f1a8c6b3e9d7f5a either resolves to a specific tree (with specific file contents) or it does not resolve at all. There is no third state where it resolves to different contents than yesterday. That is the property you want for code that ends up wired into your operators' slash commands and pre-tool-use hooks.
A useful sanity check: the npm ecosystem learned this lesson in 2018 when event-stream was hijacked. The compromise rode in on a transitive ^ semver range that allowed any minor update to flow into millions of installs. The current best practice for npm is to commit a package-lock.json that pins every transitive dependency to a resolved tarball hash. SHA pinning in marketplace.json is the same pattern, one layer up: pin the plugin source to a tree object, not a moving label.
A two-channel layout: stable and beta
SHA pinning solves the "silent swap" problem, but it introduces a real workflow question: how do operators get upgrades?
A clean answer is to run two marketplace branches in your own marketplace repo and let operators subscribe to one:
stablebranch \u2014 every plugin pinned to a SHA you have personally tested in production for at least 48 hoursbetabranch \u2014 every plugin pinned to a SHA from the upstream's latest release, with no soak period required
Operators add whichever channel they want:
# Stable operators
/plugin marketplace add your-org/marketplace@stable
# Beta operators (you, your team)
/plugin marketplace add your-org/marketplace@beta
The marketplace repo itself uses a branch ref here, but that is a deliberate choice \u2014 the marketplace repo is under your control, code-reviewed, and not exposed to upstream force-pushes. The plugin sources inside marketplace.json are still SHA-pinned.
This layout gives you three properties at once. Beta operators catch regressions before stable operators see them. Stable operators get a deterministic plugin tree that does not change between upgrade windows. And the marketplace repo's git log becomes the canonical audit trail for "which plugin version was deployed when" \u2014 every promotion is a commit that bumps SHAs from beta to stable, and you can git revert it in a single command if a regression slips through.
A minimal marketplace.json on the stable branch:
{
"name": "your-org-marketplace",
"owner": { "name": "your-org" },
"metadata": { "version": "2026.05.15.stable" },
"plugins": [
{
"name": "deploy-helper",
"source": {
"source": "github",
"repo": "your-org/deploy-helper-plugin",
"commit": "4f9a2c1b8d3e6f0a7c5b9e2d4f1a8c6b3e9d7f5a"
}
},
{
"name": "security-review",
"source": {
"source": "github",
"repo": "your-org/security-review-plugin",
"commit": "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a291807"
}
}
]
}
The same file on beta will diverge \u2014 newer SHAs, possibly entries for plugins not yet ready for stable. Promotion from beta to stable is one commit: copy the SHA, open a PR, merge.
Automating the promotion workflow with gh
Manually copying SHAs out of upstream repos is the failure mode that recreates the supply-chain risk you just closed. A small script keeps the discipline mechanical. The pattern below resolves an upstream tag to a SHA, writes it into marketplace.json on the beta branch, opens a PR, and lets a 48-hour soak period gate stable promotion.
import json
import subprocess
import sys
from pathlib import Path
def resolve_upstream_sha(repo: str, ref: str) -> str:
"""Resolve a ref (tag/branch) to an immutable commit SHA via gh api."""
result = subprocess.run(
["gh", "api", f"repos/{repo}/commits/{ref}", "--jq", ".sha"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def bump_plugin_sha(
marketplace_path: Path,
plugin_name: str,
new_sha: str,
) -> None:
data = json.loads(marketplace_path.read_text())
for entry in data["plugins"]:
if entry["name"] == plugin_name:
old_sha = entry["source"].get("commit", "<none>")
entry["source"]["commit"] = new_sha
print(f"{plugin_name}: {old_sha[:12]} -> {new_sha[:12]}")
break
else:
sys.exit(f"plugin {plugin_name} not found in {marketplace_path}")
marketplace_path.write_text(json.dumps(data, indent=2) + "\
")
if __name__ == "__main__":
plugin, repo, ref = sys.argv[1], sys.argv[2], sys.argv[3]
sha = resolve_upstream_sha(repo, ref)
bump_plugin_sha(Path(".claude-plugin/marketplace.json"), plugin, sha)
Invoke it inside a checkout of the beta branch:
git checkout beta
python scripts/bump_plugin.py deploy-helper your-org/deploy-helper-plugin v1.3.0
git commit -am "beta: deploy-helper -> v1.3.0 (4f9a2c1b...)"
git push origin beta
Two days later, if no beta operator has reported a regression, promote to stable:
git checkout stable
git checkout beta -- .claude-plugin/marketplace.json
git commit -am "stable: promote $(date -u +%Y-%m-%d) snapshot"
git push origin stable
The 48-hour soak window is the cheap version of the npm ecosystem's "wait a week before pinning a new release." For a team running maybe 10 plugins under their own marketplace, two days is enough to catch the obvious breakage and short enough that operators do not feel stranded on old code.
Handling MCP servers and hooks inside pinned plugins
SHA pinning addresses the plugin source, but plugins themselves often pull in further dependencies \u2014 npm packages for MCP servers, pip packages for Python tools, container images for sandboxed hooks. Pinning the plugin SHA freezes the configuration of those dependencies, not the dependencies themselves.
A plugin that declares an MCP server like this:
{
"mcpServers": {
"search-helper": {
"command": "npx",
"args": ["-y", "@some-org/search-mcp@latest"]
}
}
}
still uses @latest at runtime. The plugin SHA is pinned, but the MCP server it spawns is not. Catching this is part of code review on the upstream plugin repo before you accept a new SHA. When you bump a plugin's SHA in your marketplace, audit its mcp.json and any hooks.json for floating refs:
gh api repos/your-org/deploy-helper-plugin/contents/.claude-plugin/mcp.json \
--jq '.content' | base64 -d | jq '.. | strings | select(test("latest|main"))'
If the upstream plugin uses @latest in MCP server args, you have three options: send a PR to the upstream pinning the version, fork and maintain a SHA-pinned copy, or accept the residual risk and document it in your marketplace repo's README. None of those are free, but the first one improves the ecosystem and the second one keeps your supply chain auditable while you wait for the PR.
Cost of pinning, and when to skip it
SHA pinning is not free. The clear costs:
- Every upstream patch requires an operator action in the marketplace repo \u2014 bumping a SHA, opening a PR, soaking for 48 hours
- CVE fixes in plugins flow through your marketplace at the same cadence as any other change, so urgent security patches need an explicit fast-path
- New operators onboarding to a plugin get an older SHA than the upstream tip until the next promotion cycle
The fast-path for security patches matters. Define a "security override" rule in the marketplace repo: any commit on stable whose message starts with security: skips the 48-hour soak. Two commits over six months is a reasonable budget; if you find yourself using it weekly, the soak window is wrong, not the rule.
When not to pin: a single-developer marketplace where all the plugin sources are also repos you maintain. In that case the marketplace repo and the plugin repos share the same blast radius \u2014 a compromised account can rewrite either. Pinning still gives you an audit trail and reproducible installs, but the threat model where "upstream is hostile" does not apply. Branch refs on your own plugins, pulled from your own marketplace, are defensible as long as you treat the plugin repos themselves with the same care as the marketplace.
The interesting boundary is anything you do not maintain yourself. If your marketplace lists a community plugin you find useful \u2014 a great pre-tool-use hook, an MCP server someone else builds \u2014 SHA-pin it. The pin is what makes the trust decision durable.
A migration plan for an existing marketplace
If you already operate a marketplace with branch refs, the conversion is mechanical:
- Resolve every current branch ref to its current SHA:
gh api repos/<owner>/<repo>/commits/<branch> --jq .sha - Update every
sourceblock to usecommitinstead ofbranchortag - Tag the marketplace repo with
pre-sha-pinso you can diff later - Open one PR that converts all entries at once, merge it, and ask operators to run
/plugin update - Add the bump script from the previous section to the marketplace repo so future updates flow through it
- Split into
stableandbetabranches only after operators have lived with single-branch SHA pinning for a week \u2014 adding channel complexity before the basic discipline is in place tends to mean both branches drift
The whole conversion is usually under an hour for a marketplace with 10 plugins. The discipline of running the bump script every time is the durable habit, not the one-time pin.
What "good" looks like in practice
A marketplace running this pattern feels like a normal git repo. The plugin contents your operators run on Friday match the contents they ran on Monday, unless you made a deliberate commit in between. The marketplace repo's git log reads like a changelog of every plugin version transition. A force-push at any upstream is a non-event for installed operators. New operators get bit-identical plugin trees to existing operators, modulo their own /plugin update cadence.
The cost is real but small \u2014 one extra command per plugin upgrade, one shared script, a two-branch layout. The gain is that "what plugin code runs on my agents" stops being a question whose answer depends on what an upstream maintainer pushed last night.
References: