diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b8ea499..18d179d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" + }, + "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"] } ] } diff --git a/.github/workflows/catalog-admission.yml b/.github/workflows/catalog-admission.yml index db96dcd..12ca9a2 100644 --- a/.github/workflows/catalog-admission.yml +++ b/.github/workflows/catalog-admission.yml @@ -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 # 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: @@ -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 + 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 }} diff --git a/docs/how-to/add-a-plugin.md b/docs/how-to/add-a-plugin.md index 4b6c62d..b6b767e 100644 --- a/docs/how-to/add-a-plugin.md +++ b/docs/how-to/add-a-plugin.md @@ -50,11 +50,11 @@ a human-readable label. "description": "", "author": { "name": "" }, "source": { - "source": "git-subdir", // plugin lives in a subdirectory of a repo - "repo": "/", // the external plugin's source repo - "subdir": "plugins/",// 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//.git", // the external plugin's source repo (full git URL) + "path": "plugins/", // 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": "", "keywords": ["<...>"] @@ -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. ## 4. Verify, then merge