Rules / CFG098
CFG098 marketplace.json publishes a plugin nothing pins
Severity: error (an archive source with no sha256, or a command source) · warn (an npm source pointing at a non-default registry)
OWASP: LLM04:2026 – Supply Chain
OWASP Agentic: ASI04:2026 – Agentic Supply Chain Vulnerabilities: LLM04:2026 now covers the model and data supply chain and refers the agentic one, "including MCP servers and tool registries", to ASI04
What cfgaudit checks
.claude-plugin/marketplace.json is the supply-chain document of the Claude Code plugin system: one source object per entry, saying where the code is fetched from when somebody installs that plugin.
cfgaudit already recognised .claude-plugin/ as "this repository is a plugin" and read plugin.json out of it for the MCP rules. The marketplace manifest next to it was never read.
This is author-side coverage, on the same footing as .claude-plugin/plugin.json and kimi.plugin.json: the file is committed and pushed, and it describes what runs on other people's machines. The audience is the author publishing it and the reviewer reading it before installing.
archive with no sha256
Claude Code 2.1.224 added the archive source: a zip over HTTPS, with no git and no npm on the user's machine. The integrity pin is optional, and it is the only pin the type has.
| Source | Fields | What pins it |
|---|---|---|
| relative path (a bare string) | — | inside the marketplace repo, nothing external |
github |
repo, ref?, sha? |
sha |
url |
url, ref?, sha? |
sha |
git-subdir |
url, path, ref?, sha? |
sha |
npm |
package, version?, registry? |
version |
archive |
url, sha256? |
sha256 |
command |
command, timeout?, mode? |
nothing to pin |
The other types sit on a git object model: even an unpinned ref names a revision that exists in a history somebody can inspect. An archive has none of that. Whatever the server serves at install time is the plugin, and it can serve something else tomorrow. Upstream documents that mutability as the feature: with no sha256, users get an update "whenever the hosted zip file's bytes change".
Verified in the binary (2.1.231). The download path computes the hash either way and compares it only when the entry declares one:
let l = createHash("sha256").update(a).digest("hex");
if (t.sha256 && t.sha256.toLowerCase() !== l) throw new Mt(
`Plugin archive integrity check failed for ${r}: expected sha256 ${t.sha256.toLowerCase()}, got ${l}. …`,
"plugin archive sha256 mismatch");
The version label carries the same split, Using archive sha256 version for … (pinned) against … (downloaded). An absent sha256 is the second branch: nothing is ever compared.
What this finding does not claim
Claude Code has real guards on archive fetches, all present in 2.1.231, and the message deliberately stays off them:
https://only, and no loopback, link-local or cloud-metadata host: "Archive URLs must use https:// and must not point at a loopback, link-local, or cloud-metadata host"- every redirect hop must satisfy the same policy, and a cross-origin redirect drops the inherited marketplace headers
- a size cap, a file-count cap, and path-traversal protection on extraction
So this is a finding about integrity and mutability. It is not SSRF and not zip-slip.
npm with a non-default registry
An npm source resolves its package from the public registry unless the entry names another one. A committed registry means installing the plugin fetches code from a host this manifest chose, which is legitimate for an internal registry and is worth a reader's attention either way. Hence warn, not error.
The public registry is recognised and not reported. Spelling out https://registry.npmjs.org is a no-op, and it is the only registry value in the sampled corpus, so treating any registry as a finding would have been a false positive on the single real occurrence.
What is deliberately not flagged
An unpinned git source. github, url and git-subdir without sha are not reported, and the measurement is the reason. Across committed .claude-plugin/marketplace.json files:
| Search term | Hits |
|---|---|
| total | 30,144 |
github |
14,080 |
sha (also matches sha256, sha1) |
2,512 |
sha256 |
16 |
Under 9% mention any kind of sha, and upstream describes the omission as intended:
For the git-based source types
github,url,git-subdir, and relative paths inside a git-hosted marketplace, you can omitversionentirely. This is the simplest setup for internal or actively-developed plugins.
A finding at that rate reports documented normal usage rather than a weakness — the lesson #459 already applied when it removed CFG095's build-cache warn. If it is ever wanted, it gets its own issue and its own false-positive run.
The string shorthand. A bare string source is a path inside the marketplace repository, so the repository under review is already the thing being audited. It is also the dominant form: 2,616 of the plugin entries in a 292-file sample.
Trigger example
{
"name": "acme-tools",
"plugins": [
{
"name": "acme-helper",
"source": {
"source": "archive",
"url": "https://github.com/acme/acme-helper/releases/latest/download/plugin.zip"
}
}
]
}
→ error. Note the latest in the URL: the bytes are meant to change.
Safe alternative
{
"source": "archive",
"url": "https://github.com/acme/acme-helper/releases/download/v1.4.0/plugin.zip",
"sha256": "9f2c…"
}
A versioned URL is better than latest, but only sha256 is enforced.
References
- CFG089 (Copilot
extraKnownMarketplacessource pinning) — the same threat on another agent's marketplace - CFG055 (
extraKnownMarketplaces), CFG062 (plugin auto-install) — the consumer side of the plugin supply chain - OWASP LLM04:2026 – Supply Chain
command: the plugin directory is produced, not fetched
Claude Code 2.1.229 added the command source: "Shell command that prints the absolute path of the plugin directory on stdout (exactly one line) and exits 0. It must leave a complete plugin in that directory before exiting". There is no revision to name and no bytes to hash, because nothing is fetched. The plugin is whatever the command leaves behind on the machine that installs it.
The consent gate belongs in the reader's picture, and it is not a reason for silence. The command is shown and accepted at explicit install or update, and Claude Code then re-resolves it in the background "only ... while the marketplace entry still declares this exact command; a changed command (or an entry that became command-sourced later) is skipped with a warning until the user runs an explicit update". So the consent covers the command text, not what that text does on any later run. disableCommandPluginSources switches the type off, but it is honored only from managed settings, so it is not a mitigation an author or a user can rely on.
mode: "link" earns its own sentence in the finding: the produced directory is used where it lies instead of being copied into the plugin cache, so its contents can change after install with no re-resolve at all.
The command text itself is judged by the command-content rules (CFG008, CFG009, CFG014, CFG015, CFG027, CFG028, CFG037, CFG038, CFG039, CFG059, CFG072, CFG077, CFG078), which read it as a command site alongside a catalog entry's headersHelper.
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation