cfgaudit

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:

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 omit version entirely. 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

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