Public OSS writeup. Repos referenced (
yolo-labz/claude-mac-chrome,yolo-labz/wa,yolo-labz/kokoro-speakd,yolo-labz/claude-classroom-submit,yolo-labz/linkedin-chrome-copilot,yolo-labz/fand) are all visible on GitHub. Numbers below are from current release pipelines.
Context
A Claude Code plugin is a tiny artifact — a hook script, a slash command, an MCP server wrapper. The repo footprint stays small (median ~2 KLOC across the six in scope). The trust footprint does not. A plugin gets installed into the same shell session as a developer’s code, secrets, and tokens; an unsigned tarball is a credible attack surface.
As of April 2026, Anthropic’s Claude Code plugin marketplace has no supply-chain requirements: no signing, no SBOM, no SLSA, no install-time signature verification. Trust is per-marketplace, not per-plugin. That changes the calculus — supply-chain hardening on these repos is voluntary, ahead of marketplace policy. The reason to do it anyway is that any consumer who knows what to look for can verify the plugin in one command, and any future tightening of marketplace policy lands on a fleet that already complies.
Y-statement
In the context of solo-dev OSS plugin shipping, facing supply-chain trust pressure with no security team, we decided SLSA L2 attestations + cosign keyless OIDC + dual SBOM applied via single GitHub Actions canon to achieve audit-grade provenance with zero per-plugin overhead, accepting one-time template setup cost.
The fleet
Six repos share the canon, picked because each has a release surface (binary, wheel, or tagged tarball):
| Repo | Language | Release surface |
|---|---|---|
claude-mac-chrome | Bash | tagged tarball + Homebrew tap |
wa | Go 1.24 | GoReleaser darwin/linux binaries + Homebrew tap |
kokoro-speakd | Python 3.12 | PyPI wheel + GitHub Release model weights |
claude-classroom-submit | Python 3.12 | PyPI wheel |
linkedin-chrome-copilot | Bash | tagged tarball |
fand | Rust | cargo-built binaries + Homebrew tap |
Three release shapes (binary, wheel, tarball), one canon. The canon’s job is to make the signing surface identical across all three.
The 9-line core
The signing step that lands in every release workflow is short:
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: ${{ inputs.artifact-path }}
- name: Attest SBOM
uses: actions/attest-sbom@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: ${{ inputs.artifact-path }}
sbom-path: sbom.cdx.jsonTwo GitHub-native actions, full 40-char SHA pin with the trailing # v4.1.0 comment that Dependabot’s regex needs. No external signing infrastructure: the OIDC token issued by https://token.actions.githubusercontent.com is exchanged with Sigstore’s Fulcio CA for a short-lived signing certificate, the signature lands in the public Rekor transparency log, and the resulting bundle is attached to the GitHub Release.
A common mistake worth calling out: the OIDC issuer for CI is https://token.actions.githubusercontent.com. The URL https://github.com/login/oauth is the interactive human OAuth flow — wiring it into a workflow’s --certificate-oidc-issuer flag will fail with a confusing error during cosign verify-blob. If you see issuer-mismatch errors, check that string first.
Dual SBOM in one syft call
Two formats matter: CycloneDX 1.7 (the OWASP standard, mandated by the EU Cyber Resilience Act for products in scope) and SPDX 2.3 (the ISO/IEC 5962:2021 standard, required by US Executive Order 14028 for federal procurement). Producing both used to mean two passes; modern syft emits both from one invocation:
syft . \
-o [email protected]=sbom.cdx.json \
-o spdx-json=sbom.spdx.jsonFor the Go repo (wa), an extra cyclonedx-gomod app -licenses -std -json run produces a richer Go-native SBOM — module versions, license summaries, std-lib boundaries — that augments the syft output. Python repos rely on pypa/gh-action-pypi-publish@release/v1 (v1.11+), which auto-generates PEP 740 attestations from the Trusted Publishing OIDC flow and removes the need for a separate Sigstore step.
The SBOM files are tiny — sbom.cdx.json weighs ~12 KB for the Bash repos, ~84 KB for wa after the gomod augment. The provenance attestation bundle (attestation.intoto.jsonl) sits at ~4 KB per artifact. None of this rounds up to a meaningful release-asset footprint.
Permissions hardening
The workflow-level default is deny-all, with each job re-granting only what it needs:
permissions: {}
jobs:
release:
permissions:
id-token: write # OIDC for keyless signing
attestations: write # write provenance + SBOM attestations
contents: write # cut the GitHub Release
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@<sha> # v2
with:
egress-policy: audit
- uses: actions/checkout@<sha>
with:
persist-credentials: false
# ... build + sign stepsstep-security/harden-runner runs in egress-policy: audit for the first release cycle to observe legitimate Sigstore endpoints, then flips to block once the allowlist is stable. persist-credentials: false on actions/checkout prevents the default GITHUB_TOKEN from sticking around in .git/config after the workflow finishes.
Fitness function: verify on every release
A signing pipeline that is never exercised on the consumer side is a signing pipeline that quietly rots. The fitness function lives in CI itself — every release workflow ends with the verification it expects users to run:
- name: Smoke-verify the just-signed artifact
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
artifact="${{ inputs.artifact-path }}"
repo="${{ github.repository }}"
gh attestation verify "${artifact}" \
--repo "${repo}" \
--predicate-type "https://slsa.dev/provenance/v1" \
--format json \
| jq -e '.[] | select(.verificationResult.signature.certificate.sourceRepositoryURI == "https://github.com/${{ github.repository }}")'
echo "OK: SLSA L2 provenance verified against ${repo}"If the just-published artifact fails to verify against its own attestation, the release fails — louder than any documentation can be. The same gh attestation verify command in the README is the user-side smoke test.
Anti-pattern: never re-tag a release
This is the rule that catches solo devs first: when CI failed and a tag got pushed early, the instinct is to delete the tag locally, force-push, fix, re-tag.
Don’t. slsa-verifier and the underlying provenance predicate validate against the commit SHA at signing time. Re-tagging produces stale provenance — the new commits exist, but the signed claim still points at the old SHA. gh attestation verify will continue to pass against the original tag’s signed bundle, and any user who pulls the re-tagged source tree will get artifacts whose attestation describes a commit they don’t have.
The cure is boring: cut vX.Y.Z+1. Bump the patch number, push the new tag, run the workflow again, archive the broken release with a “superseded by” note in the body. The discipline of monotonic tags is the discipline that makes provenance verifiable.
Scorecard footprint
OpenSSF Scorecard is a useful indicator, not a goal in itself. The realistic ceiling for a solo-dev yolo-labz repo is ~8.7/10. The structural floor is set by the Contributors check, which caps around 3/10 for solo devs and is not gameable through Co-Authored-By: trailers (Scorecard filters bots and empty Company fields). Every other check sits above 8 once the canon lands:
- Pinned-Dependencies →
10/10after running every action through StepSecurity’s secure-workflow rewriter. - Token-Permissions →
10/10from the deny-all default. - Signed-Releases →
10/10once Sigstore attestations are attached. - Packaging →
10/10because every repo publishes via a recognized action (softprops/action-gh-release,pypa/gh-action-pypi-publish). - Maintained → auto-heals at day 90 with at least one commit per week.
The single check that requires actual code is Fuzzing. A fuzz.yml workflow is invisible to Scorecard — it expects either Go fuzz tests (func FuzzX(f *testing.F) in any *_test.go) or a .clusterfuzzlite/ directory with the OSS-Fuzz contract. For wa a single Go fuzz function adds ten points to that check. For shell repos like claude-mac-chrome the path is ClusterFuzzLite via .github/workflows/cflite_pr.yml plus .github/workflows/cflite_cron.yml for nightly batch runs.
What user-side verification looks like
The README’s verification block reduces to two commands:
gh release download v1.2.0 --repo yolo-labz/wa
gh attestation verify wa-darwin-arm64.tar.gz --repo yolo-labz/waThat is the entire trust-establishment ritual for any consumer who wants to confirm the artifact came from the claimed pipeline. cosign verify-blob and slsa-verifier are the offline / advanced paths — they belong in a Reproducing the verification offline appendix, not in the headline. Conflating “advanced” and “default” is how trust UX rots.
Cost accounting
The honest accounting of the canon, after rolling it across six repos:
- One-time template authoring: roughly two days for the first repo, including the egress-allowlist debugging cycle for
harden-runnerblock mode. - Per-repo onboarding: ~30 minutes — copy
release.yml, adjustsubject-path, add the language-specific build job, push. - Per-release overhead: ~25 seconds added to CI for SBOM generation + attestation + smoke verify.
- Rotated secrets: zero. Sigstore keyless eliminates the long-lived signing key.
The setup is a fixed cost amortized across every future release in the fleet. The marginal cost per plugin is the YAML copy — the cryptography is the same code path running on the same runner image.
Citations
- SLSA Specification v1.0, Build Track Levels — slsa.dev/spec/v1.0/levels
- GitHub, Using artifact attestations to establish provenance for builds — docs.github.com/en/actions/security-guides/using-artifact-attestations
- Sigstore, Rekor Transparency Log Architecture — docs.sigstore.dev/rekor/overview
- Zahan, Lin et al. (2023) OpenSSF Scorecard: On the path toward ecosystem-wide automated security metrics
arXiv:2208.03922— arxiv.org/abs/2208.03922 - CycloneDX 1.7 spec — cyclonedx.org/specification/overview
- Linux Foundation / SPDX 2.3 — spdx.github.io/spdx-spec/v2.3
- PEP 740, Index support for digital attestations — peps.python.org/pep-0740