Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@
"category": "example",
"license": "Apache-2.0",
"keywords": ["reference", "attested", "example"]
},
{
"name": "attested-delivery",
"source": {
"source": "git-subdir",
"url": "https://github.com/attested-delivery/.github.git",
"path": ".github",
"ref": "v0.1.0",
"sha": "f211de97395ced798da52e28d89d79c22b3020ee"
Comment thread
Copilot marked this conversation as resolved.
},
"description": "Signed, SLSA-attested, fail-closed-verified releases — central reusable signing/seam/verify workflows and independent gh attestation verify.",
"author": {
"name": "attested-delivery"
},
"license": "Apache-2.0",
"keywords": ["slsa", "attestation", "supply-chain", "sigstore", "sbom", "ci-cd", "release-signing"]
}
]
}
70 changes: 60 additions & 10 deletions .github/workflows/catalog-admission.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
---
name: catalog-admission

# Fail-closed admission control for the marketplace catalog.
# Fail-closed admission control for the marketplace catalog — the gate that makes
# SHA-pinning non-negotiable.
#
# Claude Code does not verify attestations at plugin INSTALL time yet
# (anthropics/claude-code#30727), so the enforceable fail-closed seam is here:
# a change to .claude-plugin/marketplace.json is admitted only if
# (anthropics/claude-code#30727), so the enforceable fail-closed seam is here.
# It runs on EVERY pull request (not only when marketplace.json changes) so it is
# always present and can be a hard REQUIRED status check: no PR merges unless the
# entire catalog still satisfies the invariants. The catalog is admitted only if
# 1. `claude plugin validate` passes (canonical validation), and
# 2. every EXTERNAL plugin source (github / url / git-subdir) is pinned to a
# full 40-char `sha`, and the marketplace name is not Anthropic-reserved
# (HARD fail — unlike the soft-fail quality-gates manifest-review), and
# full 40-char `sha` — a `ref` without a `sha` is mutable and REJECTED — and
# the marketplace name is not Anthropic-reserved (HARD fail — unlike the
# soft-fail quality-gates manifest-review), and
Comment thread
zircote marked this conversation as resolved.
# 3. each external entry's pinned artifact attestations verify (when present).
# A plugin SHA enters the catalog only when these hold.
#
# Make this job a REQUIRED status check in branch protection so the pin
# requirement is enforced at merge, not by convention.

"on":
pull_request:
paths:
- ".claude-plugin/marketplace.json"
push:
branches: [main]
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -71,12 +78,55 @@ jobs:
print("catalog-admission OK: %d external entr(y/ies) SHA-pinned, name not reserved" % n)
PY

# Hardening: a 40-char sha is not enough — the pin must resolve to a real
# plugin. For every external entry, fetch the plugin manifest AT the pinned
# commit; a pin without a .claude-plugin/plugin.json is REJECTED (stops a
# catalog entry pointing at a commit that lacks the plugin, or a moved one).
- name: Verify each external pin resolves to a real plugin (fail-closed)
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
python3 - .claude-plugin/marketplace.json > entries.tsv <<'PY'
import json, sys
mp = json.load(open(sys.argv[1]))
for p in mp.get("plugins", []):
s = p.get("source")
if not isinstance(s, dict) or s.get("source") not in ("github", "git-subdir", "url"):
continue
Comment thread
zircote marked this conversation as resolved.
sha = s.get("sha", ""); name = p.get("name", "")
if s.get("source") == "github":
slug = s.get("repo", "").strip("/"); path = s.get("path", "").strip("/")
else: # git-subdir / url — accept url+path (claude-canonical) or repo+subdir (legacy)
raw = (s.get("repo") or s.get("url", "")).strip().rstrip("/")
for pre in ("https://github.com/", "git@github.com:", "ssh://git@github.com/"):
if raw.startswith(pre):
raw = raw[len(pre):]
break
slug = raw.removesuffix(".git").strip("/") # non-github -> invalid slug -> fail-closed
path = (s.get("subdir") or s.get("path") or "").strip("/")
man = (path + "/" if path else "") + ".claude-plugin/plugin.json"
print("%s %s %s %s" % (name, slug, man, sha))
PY
fail=0
while IFS=$' ' read -r name slug man sha; do
[ -z "${name}" ] && continue
if gh api "repos/${slug}/contents/${man}?ref=${sha}" --jq '.name' >/dev/null 2>&1; then
echo " OK ${name}: ${slug}@${sha:0:12} contains ${man}"
else
echo "::error::${name}: pinned sha ${sha} in ${slug} has no ${man} — the pin does not resolve to a plugin"
fail=1
fi
done < entries.tsv
rm -f entries.tsv
[ "${fail}" -eq 0 ] || { echo "::error::catalog admission failed — one or more pins do not resolve to a plugin"; exit 1; }
echo "All external entries resolve to a plugin manifest at their pinned SHA."

# Fail-closed verification of every external entry's attestations, shared
# with the central catalog-updater hub so "what must verify for an external
# entry" lives in one place. No external entries yet (v1 vendors its only
# plugin), so this is a clean no-op until the catalog grows.
# entry" lives in one place.
- name: Verify external entries' attestations (shared, fail-closed)
uses: attested-delivery/.github/.github/actions/plugin-catalog-update@f211de97395ced798da52e28d89d79c22b3020ee # v0.1.0
uses: attested-delivery/.github/.github/actions/plugin-catalog-update@36cdbd7d75865b88e8beec4813c1d3587fa98644 # engine url-source fix (post v0.1.0)
with:
mode: verify
repo: ${{ github.repository }}
Expand Down
37 changes: 22 additions & 15 deletions docs/how-to/add-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ a human-readable label.
"description": "<one-line summary>",
"author": { "name": "<author>" },
"source": {
"source": "git-subdir", // plugin lives in a subdirectory of a repo
"repo": "<owner>/<plugin-repo>", // the external plugin's source repo
"subdir": "plugins/<plugin-name>",// path to the plugin within that repo
"ref": "v1.2.3", // human-readable label (mutable)
"sha": "<40-char-commit-sha>" // EFFECTIVE PIN — immutable identity
"source": "git-subdir", // plugin lives in a subdirectory of a repo
"url": "https://github.com/<owner>/<repo>.git", // the external plugin's source repo (full git URL)
"path": "plugins/<plugin-name>", // subdirectory holding the plugin's .claude-plugin/
"ref": "v1.2.3", // human-readable label (mutable)
"sha": "<40-char-commit-sha>" // EFFECTIVE PIN — immutable identity
},
"license": "<SPDX-id>",
"keywords": ["<...>"]
Expand All @@ -67,16 +67,23 @@ a human-readable label.

## 3. Open a PR — catalog admission runs fail-closed

The pull request triggers the marketplace gates. Two are decisive for admission:

- **manifest-review** (`manifest/v1`) — fails closed unless every external plugin
source is SHA-pinned, the marketplace `name` is not a reserved name, and the
required manifest fields are present.
- **catalog admission** — re-verifies the plugin's published attestations
(provenance, SBOM, gate verdicts) for the pinned SHA. If any attestation fails
to verify, admission fails and the entry cannot merge.

`claude plugin validate` runs as the canonical manifest check alongside these.
The **catalog-admission** gate runs on every pull request (so it can be a hard
required status check) and fails closed unless **all** of these hold:

- every external plugin source is pinned to a full 40-char `sha` — a `ref`
without a `sha` is mutable and rejected;
- the pinned `sha` **actually resolves to a plugin**: admission fetches the
`.claude-plugin/plugin.json` at that commit and rejects the entry if it is not
there (this is what stops a pin from pointing at a commit that lacks the
plugin, or a placeholder SHA);
- the marketplace `name` is not an Anthropic-reserved name;
- `claude plugin validate` passes (canonical manifest check);
- each external entry's pinned release **attestations verify fail-closed** (SLSA
provenance), using the same verify the central catalog-updater runs.

The soft-fail **manifest-review** (`manifest/v1`) gate reports the same SHA-pin
findings to the Security tab. Make `catalog-admission` a **required** check in
branch protection so the pin requirement is enforced at merge, not by convention.
Comment thread
zircote marked this conversation as resolved.

## 4. Verify, then merge

Expand Down
Loading