diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..f586e7c --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,37 @@ +{ + "name": "error-handling", + "description": "Single-plugin marketplace for error-handling: RFC 9457 dual-consumer CLI error envelopes, source-code error-handling review, and LLM-agent consumer-side interpretation of those envelopes.", + "version": "1.0.0", + "owner": { + "name": "zircote", + "email": "zircote@zircote.com" + }, + "plugins": [ + { + "name": "error-handling", + "source": "./", + "description": "Three sibling skills covering the full CLI/code error-handling lifecycle: cdc-err (RFC 9457 dual-consumer envelopes for CLI output), cdc-review (source-code error-handling review across any language), and cdc-handle (LLM-agent consumer-side interpretation and action on cdc-err envelopes in tool_result payloads).", + "author": { + "name": "zircote", + "email": "zircote@zircote.com" + }, + "license": "MIT", + "keywords": [ + "cli", + "error-handling", + "rfc-9457", + "problem-details", + "dual-consumer", + "agent-tooling", + "stderr", + "exit-codes", + "machine-readable-errors", + "structured-errors", + "code-review", + "cdc-err", + "cdc-review", + "cdc-handle" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 949d760..9fdf7ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,12 +1,14 @@ { "name": "error-handling", - "version": "0.3.0", + "version": "0.4.0", "description": "Three sibling skills covering the full CLI/code error-handling lifecycle: cdc-err (RFC 9457 dual-consumer envelopes for CLI output), cdc-review (source-code error-handling review across any language), and cdc-handle (LLM-agent consumer-side interpretation and action on cdc-err envelopes in tool_result payloads).", "author": { "name": "zircote", "email": "zircote@zircote.com", "url": "https://github.com/zircote" }, + "homepage": "https://github.com/zircote/cdc-error-handling", + "repository": "https://github.com/zircote/cdc-error-handling", "license": "MIT", "keywords": [ "cli", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5350f73 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +--- +name: CI + +# Validate the plugin/marketplace manifests and the workflow files themselves +# on every push and PR. + +"on": + push: + branches: [develop] + pull_request: + branches: [develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Every `uses:` in .github/workflows MUST be pinned to a full 40-char commit + # SHA, never a mutable tag or branch (supply-chain hardening: a compromised + # tag/branch can't silently change what a workflow runs). Local reusable + # calls (`uses: ./...`) are exempt (the action's own documented behavior). + pin-check: + name: pin-check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Ensure SHA pinned actions + uses: zgosalvez/github-actions-ensure-sha-pinned-actions@3db98c0363e2fa5df3e1c4c471777a7c10b24cc9 # v5.0.5 + + actionlint: + name: actionlint + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Run actionlint + uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 + with: + fail_level: error + reporter: github-check + + validate: + name: claude plugin validate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Install Claude Code CLI (pinned) + env: + CLAUDE_VERSION: "2.1.199" + run: npm install -g "@anthropic-ai/claude-code@${CLAUDE_VERSION}" + - name: Validate marketplace + plugin manifest + run: claude plugin validate . --strict diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d835d28 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,783 @@ +--- +name: Release + +# Attested delivery for this plugin/marketplace repo: reproducible tarball, +# SLSA build provenance, CycloneDX SBOM, six quality-gate verdicts +# (SAST/SCA/Trivy/Semgrep/Secrets/Manifest) each seam-attested as a signed +# custom predicate bound to the tarball's digest, an OpenVEX vulnerability +# disposition, and a cosign-signed marketplace.json catalog — all fail-closed +# verified before the tag-gated publish step. +# +# Unlike a multi-repo org, this repo has no separate central signer: every +# attestation and the catalog signature are produced by THIS workflow running +# in THIS repo. Verification still pins the signer workflow explicitly +# (`--signer-workflow zircote/cdc-error-handling/.github/workflows/release.yml` +# for gh attestation verify; the cosign certificate-identity-regexp is +# anchored to this same workflow path) — `--repo` alone only proves "some +# workflow in this repo signed it," not specifically this one. See +# SECURITY.md and docs/how-to/verify-release.md. +# +# Flow: build tarball -> attest-build-provenance +# -> [gate-* -> attest-*] x6 (parallel), sbom (generate + attest), +# sign-catalog (cosign), vex (normalize + attest) +# -> verify (fail-closed) -> publish (tag push only). +# +# Dry-run: workflow_dispatch exercises the full chain through verify; publish +# only runs on an actual tag push. + +"on": + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + meta: + name: Resolve Metadata + runs-on: ubuntu-latest + outputs: + name: ${{ steps.meta.outputs.name }} + version: ${{ steps.meta.outputs.version }} + steps: + - name: Resolve artifact name and version + id: meta + run: | + set -euo pipefail + NAME="${GITHUB_REPOSITORY##*/}" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + VERSION="0.0.0-dev" + fi + echo "name=${NAME}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Resolved: ${NAME} ${VERSION}" + + build: + name: Build Artifact + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + outputs: + subject-name: ${{ steps.subject.outputs.name }} + subject-digest: ${{ steps.subject.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Build reproducible tarball + env: + NAME: ${{ needs.meta.outputs.name }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + git archive --format=tar.gz --prefix="${NAME}-${VERSION}/" \ + -o "dist/${NAME}-${VERSION}.tar.gz" "${GITHUB_SHA}" + sha256sum dist/* + - name: Record subject name + digest (for gate attestations) + id: subject + env: + NAME: ${{ needs.meta.outputs.name }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + set -euo pipefail + FILE="${NAME}-${VERSION}.tar.gz" + DIGEST=$(sha256sum "dist/${FILE}" | cut -d' ' -f1) + echo "name=${FILE}" >> "$GITHUB_OUTPUT" + echo "digest=sha256:${DIGEST}" >> "$GITHUB_OUTPUT" + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/* + - name: Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist + path: dist/* + if-no-files-found: error + + # --- quality gates: each produces a SARIF/JSON evidence file ----------- + + gate-sast: + name: gate-sast + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: actions + build-mode: none + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: /language:actions + output: codeql-sarif + - name: Normalize SARIF filename + run: | + set -euo pipefail + shopt -s nullglob + files=(codeql-sarif/*.sarif) + if [ "${#files[@]}" -eq 0 ]; then echo "::error::CodeQL produced no SARIF"; exit 1; fi + jq -s '{"$schema": .[0]."$schema", "version": .[0].version, "runs": (map(.runs) | add)}' \ + "${files[@]}" > results.sarif + - name: Upload to code scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sast-sarif + path: results.sarif + if-no-files-found: error + + gate-sca: + name: gate-sca + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Run OSV-Scanner + uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + continue-on-error: true + with: + scan-args: |- + --output=results.json + --format=json + --recursive + ./ + - name: Generate SARIF report + uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: |- + --output=results.sarif + --new=results.json + --gh-annotations=false + --fail-on-vuln=true + - name: Upload to code scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + category: osv-scanner + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sca-sarif + path: results.sarif + if-no-files-found: error + + gate-trivy: + name: gate-trivy + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # SECURITY: aquasecurity/trivy-action is pinned to a full commit SHA. + # In March 2026 (CVE-2026-33634) an attacker force-pushed 76 of 77 + # trivy-action tags to credential-stealing malware. Never replace this + # SHA with a tag. + - name: Trivy IaC + license scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: misconfig,license + format: sarif + output: results.sarif + severity: HIGH,CRITICAL + exit-code: '0' + - name: Upload to code scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + category: trivy-iac-license + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: trivy-sarif + path: results.sarif + if-no-files-found: error + + gate-semgrep: + name: gate-semgrep + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Install Semgrep (pinned) + run: | + set -euo pipefail + python3 -m venv "${RUNNER_TEMP}/semgrep-venv" + "${RUNNER_TEMP}/semgrep-venv/bin/pip" install --no-cache-dir --disable-pip-version-check \ + "semgrep==1.168.0" + echo "${RUNNER_TEMP}/semgrep-venv/bin" >> "$GITHUB_PATH" + - name: Semgrep -> SARIF + run: | + set -euo pipefail + semgrep scan --config p/security-audit --config p/secrets --config p/command-injection \ + --sarif --output results.sarif \ + --metrics off --disable-version-check --no-error . || true + if [ ! -f results.sarif ]; then echo "::error::Semgrep produced no SARIF"; exit 1; fi + - name: Upload to code scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + category: semgrep + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: semgrep-sarif + path: results.sarif + if-no-files-found: error + + gate-secrets: + name: gate-secrets + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Install Gitleaks + TruffleHog (checksum-verified) + run: | + set -euo pipefail + dest="${RUNNER_TEMP}/secrets-bin"; mkdir -p "${dest}" + dl() { curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 -o "$1" "$2"; } + GL_VER=8.30.1 + glbase="https://github.com/gitleaks/gitleaks/releases/download/v${GL_VER}" + glzip="gitleaks_${GL_VER}_linux_x64.tar.gz" + dl "${dest}/${glzip}" "${glbase}/${glzip}" + dl "${dest}/gl.sums" "${glbase}/gitleaks_${GL_VER}_checksums.txt" + ( cd "${dest}" && grep " ${glzip}\$" gl.sums | sha256sum --check --strict - ) + tar -xzf "${dest}/${glzip}" -C "${dest}" gitleaks + TH_VER=3.95.8 + thbase="https://github.com/trufflesecurity/trufflehog/releases/download/v${TH_VER}" + thzip="trufflehog_${TH_VER}_linux_amd64.tar.gz" + dl "${dest}/${thzip}" "${thbase}/${thzip}" + dl "${dest}/th.sums" "${thbase}/trufflehog_${TH_VER}_checksums.txt" + ( cd "${dest}" && grep " ${thzip}\$" th.sums | sha256sum --check --strict - ) + tar -xzf "${dest}/${thzip}" -C "${dest}" trufflehog + chmod +x "${dest}/gitleaks" "${dest}/trufflehog" + echo "${dest}" >> "$GITHUB_PATH" + - name: Gitleaks -> SARIF + run: | + set -euo pipefail + gitleaks dir . --report-format sarif --report-path results.sarif --exit-code 0 --no-banner + if [ ! -f results.sarif ]; then + echo "::error::Gitleaks produced no SARIF (--exit-code 0 always exits 0 on findings, so a missing file means the scan itself failed to run, not that it found nothing)" + exit 1 + fi + - name: TruffleHog (verified-only, hard-fail) + run: | + set -euo pipefail + trufflehog filesystem . --results=verified --json --no-update > trufflehog.json + n="$(grep -c . trufflehog.json || true)" + echo "TruffleHog verified findings: ${n:-0}" + if [ "${n:-0}" -gt 0 ]; then + echo "::error::TruffleHog confirmed ${n} VERIFIED live secret(s), failing closed." + exit 1 + fi + - name: Upload to code scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + category: gitleaks + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: secrets-sarif + path: results.sarif + if-no-files-found: error + + gate-manifest: + name: gate-manifest + needs: [meta] + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Review manifests -> SARIF + run: | + set -euo pipefail + python3 - . > results.sarif <<'PY' + import json, os, re, sys + root = sys.argv[1] + SHA = re.compile(r'^[0-9a-f]{40}$') + RESERVED = { + "claude-code-marketplace", "claude-code-plugins", "claude-plugins-official", + "claude-plugins-community", "claude-community", "anthropic-marketplace", + "anthropic-plugins", "agent-skills", "anthropic-agent-skills", + "knowledge-work-plugins", "life-sciences", "claude-for-legal", + "claude-for-financial-services", "financial-services-plugins", + } + results = [] + + def add(rule, level, text, path, line=1): + results.append({"ruleId": rule, "level": level, + "message": {"text": text}, + "locations": [{"physicalLocation": { + "artifactLocation": {"uri": path}, + "region": {"startLine": line}}}]}) + + def rel(p): + return os.path.relpath(p, root).replace(os.sep, "/") + + for dirpath, _dirs, files in os.walk(root): + if ".git" in dirpath.split(os.sep): + continue + for fn in files: + full = os.path.join(dirpath, fn) + if fn == "marketplace.json" and ".claude-plugin" in dirpath: + try: + with open(full, encoding="utf-8") as fh: + mp = json.load(fh) + except Exception as e: + add("MR001", "error", "marketplace.json is not valid JSON: %s" % e, rel(full)); continue + if not mp.get("name"): + add("MR002", "error", "marketplace.json missing required 'name'", rel(full)) + elif mp["name"] in RESERVED: + add("MR003", "error", "marketplace name '%s' is Anthropic-reserved" % mp["name"], rel(full)) + if not (mp.get("owner") or {}).get("name"): + add("MR004", "error", "marketplace.json missing required 'owner.name'", rel(full)) + for i, p in enumerate(mp.get("plugins", [])): + src = p.get("source") + name = p.get("name", "plugins[%d]" % i) + if not name or not src: + add("MR005", "error", "plugin entry '%s' missing required name/source" % name, rel(full)) + if isinstance(src, dict) and src.get("source") in ("github", "url", "git-subdir"): + sha = src.get("sha", "") + if not SHA.match(str(sha)): + add("MR006", "error", + "external plugin '%s' source is not pinned to a 40-char sha (got %r)" % (name, sha), + rel(full)) + elif fn == "plugin.json" and ".claude-plugin" in dirpath: + try: + with open(full, encoding="utf-8") as fh: + pl = json.load(fh) + except Exception as e: + add("MR007", "error", "plugin.json is not valid JSON: %s" % e, rel(full)); continue + for req in ("name", "description"): + if not pl.get(req): + add("MR008", "error", "plugin.json missing required '%s'" % req, rel(full)) + if not (pl.get("author") or {}).get("name"): + add("MR009", "error", "plugin.json missing required 'author.name'", rel(full)) + + sarif = {"version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "runs": [{"tool": {"driver": {"name": "manifest-review", + "rules": [{"id": r} for r in sorted({x["ruleId"] for x in results})]}}, + "results": results}]} + print(json.dumps(sarif)) + PY + - name: Upload to code scanning + if: always() + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + category: manifest-review + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: manifest-sarif + path: results.sarif + if-no-files-found: error + + # --- seam: bind each gate's evidence to the tarball digest as a signed + # custom-predicate attestation. This repo is both the artifact repo AND + # the signer (no separate central org), so verification only needs + # --repo, not --signer-workflow. --------------------------------------- + + attest-sast: + name: attest-sast + needs: [build, gate-sast] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sast-sarif + path: predicate + - name: Attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://zircote.github.io/attestations/sast/v1 + predicate-path: predicate/results.sarif + + attest-sca: + name: attest-sca + needs: [build, gate-sca] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sca-sarif + path: predicate + - name: Attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://zircote.github.io/attestations/sca/v1 + predicate-path: predicate/results.sarif + + attest-trivy: + name: attest-trivy + needs: [build, gate-trivy] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: trivy-sarif + path: predicate + - name: Attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://zircote.github.io/attestations/iac-license/v1 + predicate-path: predicate/results.sarif + + attest-semgrep: + name: attest-semgrep + needs: [build, gate-semgrep] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: semgrep-sarif + path: predicate + - name: Attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://zircote.github.io/attestations/semgrep/v1 + predicate-path: predicate/results.sarif + + attest-secrets: + name: attest-secrets + needs: [build, gate-secrets] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: secrets-sarif + path: predicate + - name: Attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://zircote.github.io/attestations/secrets/v1 + predicate-path: predicate/results.sarif + + attest-manifest: + name: attest-manifest + needs: [build, gate-manifest] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + steps: + - name: Download evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: manifest-sarif + path: predicate + - name: Attest + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://zircote.github.io/attestations/manifest/v1 + predicate-path: predicate/results.sarif + + # --- SBOM ------------------------------------------------------------- + + sbom: + name: SBOM (generate + attest) + needs: [meta, build] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Download build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist + path: dist + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: . + format: cyclonedx-json + output-file: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom.cdx.json + upload-artifact: false + upload-release-assets: false + - name: Attest SBOM + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: dist/* + sbom-path: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom.cdx.json + - name: Upload SBOM artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom + path: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom.cdx.json + if-no-files-found: error + + # --- cosign keyless-sign the marketplace.json catalog blob ------------- + + sign-catalog: + name: sign-catalog + needs: [meta] + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + outputs: + bundle: ${{ steps.sign.outputs.bundle }} + identity: ${{ steps.sign.outputs.identity }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Sign blob (keyless) + verify back (fail-closed) + id: sign + env: + # This repo's own workflow is the signer (no separate central seam), + # so the Fulcio cert SAN is this release.yml. Deliberately NOT + # anchored to a specific @ref (e.g. refs/tags/*): this job has no + # tag-only `if:` gate, so it also runs (and self-verifies) on a + # workflow_dispatch dry-run from a branch. Anchoring to + # refs/tags/ here would make the dry-run's own in-run verify-blob + # check fail every time. The residual risk (a signature from a + # non-tag ref passing this same regex) requires push access to + # this repo to exploit, which is already the trust boundary for a + # solo-maintained repo with no other collaborators. + SIGNER_IDENTITY_RE: '^https://github\.com/zircote/cdc-error-handling/\.github/workflows/release\.yml@' + run: | + set -euo pipefail + BLOB=.claude-plugin/marketplace.json + bundle="marketplace.json.cosign.bundle" + cosign sign-blob --yes "${BLOB}" --bundle "${bundle}" + cosign verify-blob "${BLOB}" \ + --bundle "${bundle}" \ + --certificate-identity-regexp "${SIGNER_IDENTITY_RE}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" + echo "bundle=${bundle}" >> "$GITHUB_OUTPUT" + echo "identity=${SIGNER_IDENTITY_RE}" >> "$GITHUB_OUTPUT" + - name: Upload cosign bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cosign-bundle + path: ${{ steps.sign.outputs.bundle }} + if-no-files-found: error + + # --- OpenVEX disposition ---------------------------------------------- + + vex: + name: vex + needs: [meta, build] + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: 'stable' + - name: Install vexctl + run: go install "github.com/openvex/vexctl@v0.4.4" + - name: Normalize OpenVEX document + run: | + set -euo pipefail + vexctl merge .vex/openvex.json > openvex.normalized.json + jq -e . openvex.normalized.json > /dev/null + - name: Attest OpenVEX document + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ needs.build.outputs.subject-name }} + subject-digest: ${{ needs.build.outputs.subject-digest }} + predicate-type: https://openvex.dev/ns/v0.2.0 + predicate-path: openvex.normalized.json + + # --- fail-closed verification (MUST pass before publish) ---------------- + + verify: + name: Verify Attestations (fail-closed) + needs: [meta, build, sbom, attest-sast, attest-sca, attest-trivy, + attest-semgrep, attest-secrets, attest-manifest, sign-catalog, vex] + runs-on: ubuntu-latest + permissions: + contents: read + attestations: read + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Download build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist + path: dist + - name: Download cosign bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cosign-bundle + path: catalog-sig + - name: Fail-closed attestation + catalog-signature verification + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + # Pin the signer workflow, not just the repo: --repo alone accepts + # an attestation from ANY workflow in this repo with + # id-token:write + attestations:write, not specifically this one. + SIGNER: ${{ github.repository }}/.github/workflows/release.yml + SUBJECT_NAME: ${{ needs.build.outputs.subject-name }} + CATALOG_SIGNER_ID: ${{ needs.sign-catalog.outputs.identity }} + run: | + set -euo pipefail + artifact="dist/${SUBJECT_NAME}" + test -f "${artifact}" || { echo "::error::expected artifact not found: ${artifact}"; exit 1; } + echo "=== Verifying: ${artifact} ===" + gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \ + --predicate-type https://slsa.dev/provenance/v1 + gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \ + --predicate-type https://cyclonedx.org/bom + for pt in sast sca iac-license semgrep secrets manifest; do + gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \ + --predicate-type "https://zircote.github.io/attestations/${pt}/v1" + echo " PASS: ${pt}" + done + gh attestation verify "${artifact}" --repo "${REPO}" --signer-workflow "${SIGNER}" \ + --predicate-type https://openvex.dev/ns/v0.2.0 + echo " PASS: VEX" + echo "=== Verifying catalog signature (cosign) ===" + cosign verify-blob .claude-plugin/marketplace.json \ + --bundle catalog-sig/marketplace.json.cosign.bundle \ + --certificate-identity-regexp "${CATALOG_SIGNER_ID}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" + echo "All attestations + catalog signature verified." + + publish: + name: Publish Release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: [meta, verify, sign-catalog] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-dist + path: dist + - name: Download SBOM artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.meta.outputs.name }}-${{ needs.meta.outputs.version }}-sbom + path: dist + - name: Download cosign bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cosign-bundle + path: dist + - name: Generate checksums + env: + NAME: ${{ needs.meta.outputs.name }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + cd dist + sha256sum -- * > "${NAME}-${VERSION}-checksums.txt" + cat "${NAME}-${VERSION}-checksums.txt" + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.meta.outputs.version }} + run: | + set -euo pipefail + PRERELEASE=() + case "${GITHUB_REF_NAME}" in *-*) PRERELEASE=(--prerelease) ;; esac + gh release create "${GITHUB_REF_NAME}" dist/* \ + --repo "${GITHUB_REPOSITORY}" --title "Release ${VERSION}" \ + --generate-notes "${PRERELEASE[@]}" diff --git a/.vex/openvex.json b/.vex/openvex.json new file mode 100644 index 0000000..2604377 --- /dev/null +++ b/.vex/openvex.json @@ -0,0 +1,19 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://zircote.github.io/vex/cdc-error-handling/v1", + "author": "zircote", + "role": "Project Maintainer", + "timestamp": "2026-07-03T00:00:00Z", + "version": 1, + "statements": [ + { + "vulnerability": { "name": "CVE-0000-00000" }, + "products": [ + { "@id": "pkg:generic/cdc-error-handling" } + ], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "Sample disposition, replace with real entries. This plugin has no runtime dependency tree (it is a markdown/JSON skill bundle), so scanners (Semgrep, Gitleaks/TruffleHog, Trivy, OSV-Scanner) are expected to find little to nothing here. Disposition real findings here as they're triaged, so the release gate enforces \"no undispositioned high/critical findings\" rather than \"zero findings\"." + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e3ba3c0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +--- +id: error-handling-changelog +type: episodic +created: 2026-07-03T10:00:27-04:00 +--- + +# Changelog + +All notable changes to this plugin are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this plugin adheres to [Semantic Versioning](https://semver.org/). + +Plugin version (`.claude-plugin/plugin.json`) and marketplace catalog version +(`.claude-plugin/marketplace.json`) are versioned independently. This log +tracks the plugin. + +## [Unreleased] + +## [0.4.0] - 2026-07-03 + +### Added + +- `.claude-plugin/marketplace.json`: this repository now doubles as a + single-plugin marketplace, referencing the plugin via a local `./` source. +- `homepage` and `repository` fields on the plugin manifest. +- CI (`.github/workflows/ci.yml`): pin-check + (`zgosalvez/github-actions-ensure-sha-pinned-actions`, every action `uses:` + pinned to a full 40-char commit SHA), actionlint + (`reviewdog/action-actionlint`), and `claude plugin validate . --strict`. +- Release pipeline (`.github/workflows/release.yml`): reproducible + `git archive` tarball, SLSA build provenance via + `actions/attest-build-provenance`, a CycloneDX SBOM (`anchore/sbom-action` + + `actions/attest-sbom`), six quality-gate verdicts each seam-attested as a + signed custom predicate bound to the tarball digest (SAST via CodeQL, SCA + via OSV-Scanner, IaC/license via Trivy, Semgrep, secrets via Gitleaks + + TruffleHog, manifest structural review), an OpenVEX disposition + attestation (`.vex/openvex.json`), and a cosign keyless signature over the + `marketplace.json` catalog. All attestations and the catalog signature are + fail-closed re-verified in the same run before the tag-gated publish step. + This repo has no separate central signer, so verification uniformly pins + `--repo` rather than a distinct `--signer-workflow`. ShellCheck is + deliberately not wired: this repo has no `.sh` scripts to scan. +- `SECURITY.md` documenting the verification model, the full attestation + table, and how to independently verify every attestation and the catalog + signature. +- `docs/how-to/verify-release.md`. +- `.vex/openvex.json`: OpenVEX vulnerability-disposition document. + +## [0.3.0] - 2026-05-21 + +### Added + +- `cdc-handle` skill: LLM-agent consumer-side interpretation of `cdc-err` + RFC 9457 envelopes in `tool_result` payloads (parse / decide / act). +- Diátaxis documentation set (`docs/tutorials`, `docs/how-to`, + `docs/explanation`, `docs/reference`). + +### Changed + +- Plugin metadata and README updated to describe three sibling skills + (`cdc-err`, `cdc-review`, `cdc-handle`) instead of two. + +## [0.2.0] - 2026-05-20 + +### Added + +- Initial plugin scaffold with the `cdc-err` (RFC 9457 dual-consumer CLI + error envelopes) and `cdc-review` (source-code error-handling review) + skills. diff --git a/README.md b/README.md index 3595d10..dc02fe3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ --- +id: error-handling-plugin-readme +type: semantic +created: 2026-05-20T09:08:01-04:00 diataxis_type: explanation --- @@ -84,28 +87,47 @@ When any skill encounters work outside its scope, it names the right sibling and Organized per [Diátaxis](https://diataxis.fr/) under [`docs/`](docs/): - **Tutorial** — [Get started with the plugin](docs/tutorials/getting-started.md) -- **How-to** — [Install](docs/how-to/install.md) · [Add cdc-err to a CLI](docs/how-to/add-cdc-err-to-cli.md) · [Run evals](docs/how-to/run-evals.md) +- **How-to** — [Install](docs/how-to/install.md) · [Add cdc-err to a CLI](docs/how-to/add-cdc-err-to-cli.md) · [Run evals](docs/how-to/run-evals.md) · [Verify a release](docs/how-to/verify-release.md) - **Reference** — [Index](docs/reference/index.md) (envelope, severity, language refs) - **Explanation** — [Dual-consumer problem](docs/explanation/dual-consumer.md) · [Why two sibling skills](docs/explanation/skill-cooperation.md) ## Installation -Add this plugin to a Claude Code marketplace, then enable it: +This repository doubles as its own single-plugin marketplace +(`.claude-plugin/marketplace.json` lists the one plugin, sourced from the +repo root): ```bash -# If you maintain a personal marketplace pointing at this repo -claude plugins add error-handling - -# Or, for local development, point Claude Code at this directory directly -# via .claude/settings.json: -# { -# "plugins": { -# "marketplaces": [{ "path": "/absolute/path/to/this/repo" }] -# } -# } +/plugin marketplace add zircote/cdc-error-handling +/plugin install error-handling@error-handling ``` -Both skills are auto-discovered from `skills/`. No further configuration. +For local development, point Claude Code at a checkout directly: + +```bash +/plugin marketplace add /absolute/path/to/this/repo +/plugin install error-handling@error-handling +``` + +All three skills auto-discover from `skills/`. No further configuration. +See [docs/how-to/install.md](docs/how-to/install.md) for details. + +## Versioning and releases + +The plugin (`.claude-plugin/plugin.json`) and the marketplace catalog +(`.claude-plugin/marketplace.json`) carry independent semver `version` +fields — installing users track the plugin version; the catalog version +tracks changes to the marketplace listing itself. Plugin changes are +recorded in [CHANGELOG.md](CHANGELOG.md). + +Tagged releases (`vX.Y.Z`) build a reproducible source tarball and attest it +with SLSA build provenance, a CycloneDX SBOM, six quality-gate verdicts +(SAST, SCA, IaC/license, Semgrep, secrets, manifest review), and an OpenVEX +disposition; the marketplace catalog itself is cosign-signed. Every +attestation and the catalog signature are fail-closed re-verified before +publishing — see [SECURITY.md](SECURITY.md) for the full security model and +[docs/how-to/verify-release.md](docs/how-to/verify-release.md) for how to +independently verify a downloaded release. ## When the skills won't trigger (and shouldn't) @@ -148,7 +170,14 @@ If you ask outside their scope, they say so and use general knowledge or point y ``` . ├── .claude-plugin/ -│ └── plugin.json +│ ├── plugin.json +│ └── marketplace.json # this repo doubles as its own marketplace +├── .github/ +│ └── workflows/ +│ ├── ci.yml # pin-check, actionlint, claude plugin validate +│ └── release.yml # tarball -> provenance + SBOM + 6 gates + VEX + cosign -> verify -> publish +├── .vex/ +│ └── openvex.json # vulnerability disposition, attested each release ├── skills/ │ ├── cdc-err/ │ │ ├── SKILL.md @@ -182,6 +211,8 @@ If you ask outside their scope, they say so and use general knowledge or point y │ └── evals/ │ └── evals.json # 15 evals, 69 expectations, 76 deterministic checks (52.4%) ├── README.md +├── CHANGELOG.md # Keep a Changelog format +├── SECURITY.md # verification model + gh attestation verify commands ├── docs/ # Diátaxis-organized docs (tutorials/how-to/reference/explanation) └── .gitignore # see file for full ignore list ``` @@ -208,21 +239,18 @@ Both eval sets carry ≥50% deterministic-check ratio with grep-anchored regex o ## Validation -Both skills have the required SKILL.md frontmatter (`name`, `description`) and `plugin.json` carries the canonical Claude Code plugin schema fields (`name`, `version`, `description`, `author`, `license`, `keywords`). - -Quick check from the repo root: +Canonical validation, run on every push/PR in `.github/workflows/ci.yml`: ```bash -# Each SKILL.md has the required frontmatter -for s in skills/*/SKILL.md; do - head -5 "$s" | grep -E '^(name|description):' | wc -l | xargs -I{} echo "$s: {} required fields" -done - -# plugin.json parses and has all canonical fields -jq -e 'has("name") and has("version") and has("description") and has("author") and has("license") and has("keywords")' \ - .claude-plugin/plugin.json +claude plugin validate . --strict ``` +This checks `marketplace.json` and `plugin.json` schema conformance, and +(for the local `./` plugin entry) `plugin.json` and each skill's SKILL.md +frontmatter. CI also runs `actionlint` against the workflow files and +`pin-check` (every `uses:` in `.github/workflows` must be a full 40-char +commit SHA). + ## Sources and authority - **`cdc-err`** traces all prescriptions to https://zircote.com/blog/2026/04/cli-error-messages-are-a-dual-consumer-problem/. The post is the only authority; anything outside it is marked **out of scope** rather than invented. Upstream sources cited in the post: RFC 9457, RFC 7231 §7.1.3, SARIF 2.1.0, LSP 3.17, Anthropic tool-use docs, miette, rustc diagnostic guide. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..69e696a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,177 @@ +--- +id: error-handling-security-policy +type: semantic +created: 2026-07-03T10:00:27-04:00 +--- + +# Security Policy + +## Reporting a Vulnerability + +Please do **not** open a public GitHub issue for security vulnerabilities. + +Report security issues using +[GitHub private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +for this repository (Security → Advisories → "Report a vulnerability"), or by +emailing the maintainer directly. + +--- + +## Where enforcement lives + +Claude Code does **not** verify plugin signatures or attestations at install +time yet (tracked upstream: +[anthropics/claude-code#30727](https://github.com/anthropics/claude-code/issues/30727)). +This marketplace cannot rely on the installer to refuse an unverified plugin. +Enforcement lives at these points instead: + +1. **CI on every push/PR** (`.github/workflows/ci.yml`) — `claude plugin + validate . --strict` on both `plugin.json` and `marketplace.json`, + `actionlint` on the workflow files themselves, and `pin-check` (every + `uses:` in `.github/workflows` must be a full 40-char commit SHA, never a + mutable tag or branch). +2. **Fail-closed release verification** (`.github/workflows/release.yml`) — + the release tarball is attested with SLSA build provenance, a CycloneDX + SBOM, six quality-gate verdicts (SAST, SCA, IaC/license, Semgrep, secrets, + manifest review), and an OpenVEX disposition; the `marketplace.json` + catalog is cosign-signed. Every attestation and the catalog signature are + re-verified in the same run before the tag-gated publish step. A tag + publishes nothing that didn't just pass its own verification. +3. **Documented consumer verification** — the commands below let anyone + re-check a release from a clean workstation before trusting it. + +> **Every gate is risk-reducing, not risk-eliminating.** A passing +> verification proves the tarball was built by this repository's own release +> workflow from a specific commit, untampered after signing. It does not +> certify the plugin's contents are free of bugs or the skills it teaches are +> correct for your use case. + +--- + +## Verify a release + +Each release tarball carries GitHub's Sigstore-backed (keyless, OIDC) +attestations — no long-lived signing keys, and anyone can re-verify. Unlike a +multi-repo org, this plugin has no separate central signer: this repository's +own `release.yml` workflow produces every attestation and the catalog +signature. Pin `--signer-workflow` to that workflow explicitly rather than +relying on `--repo` alone — `--repo` only proves "signed by some workflow in +this repo," not specifically the release workflow. + +Prerequisites: [GitHub CLI](https://cli.github.com/) `gh` ≥ 2.49.0, +authenticated (`gh auth login`); [`cosign`](https://github.com/sigstore/cosign) +for the catalog signature. + +```bash +TARBALL="cdc-error-handling-0.4.0.tar.gz" # substitute the downloaded release asset +REPO="zircote/cdc-error-handling" +SIGNER="zircote/cdc-error-handling/.github/workflows/release.yml" +``` + +### 1. SLSA build provenance + CycloneDX SBOM + +```bash +gh attestation verify "$TARBALL" --repo "$REPO" --signer-workflow "$SIGNER" \ + --predicate-type https://slsa.dev/provenance/v1 + +gh attestation verify "$TARBALL" --repo "$REPO" --signer-workflow "$SIGNER" \ + --predicate-type https://cyclonedx.org/bom +``` + +### 2. Quality-gate verdicts + +```bash +for pt in sast sca iac-license semgrep secrets manifest; do + gh attestation verify "$TARBALL" --repo "$REPO" --signer-workflow "$SIGNER" \ + --predicate-type "https://zircote.github.io/attestations/${pt}/v1" +done +``` + +### 3. OpenVEX disposition + +```bash +gh attestation verify "$TARBALL" --repo "$REPO" --signer-workflow "$SIGNER" \ + --predicate-type https://openvex.dev/ns/v0.2.0 +``` + +A passing verification looks like: + +``` +Loaded digest sha256:... for file://cdc-error-handling-0.4.0.tar.gz +Loaded 1 attestation from GitHub API +✓ Verification succeeded! +``` + +A failed verification exits non-zero. **Treat any verification failure as a +supply-chain integrity issue — do not install or use the artifact.** + +Also verify the published checksum, included as a release asset alongside the +tarball: + +```bash +sha256sum -c cdc-error-handling-*-checksums.txt # substitute the downloaded checksums file +``` + +--- + +## Verify the catalog signature + +The `marketplace.json` catalog is a blob (not an OCI image), signed with +**cosign keyless** (Sigstore Fulcio/Rekor) by this repository's own +`release.yml` workflow. Verify the downloaded catalog against its detached +bundle (also a release asset): + +```bash +cosign verify-blob .claude-plugin/marketplace.json \ + --bundle marketplace.json.cosign.bundle \ + --certificate-identity-regexp '^https://github\.com/zircote/cdc-error-handling/\.github/workflows/release\.yml@' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +See [docs/how-to/verify-release.md](docs/how-to/verify-release.md) for a +narrative walkthrough. + +--- + +## What the attestations prove + +| Attestation | Predicate type | What it proves | +| --- | --- | --- | +| SLSA build provenance | `https://slsa.dev/provenance/v1` | The tarball was built by this repo's release workflow from a specific commit, untampered since | +| CycloneDX SBOM | `https://cyclonedx.org/bom` | The tarball is bound to a bill of materials | +| SAST | `.../attestations/sast/v1` | CodeQL ran over this repo's workflows and recorded a verdict | +| SCA | `.../attestations/sca/v1` | OSV-Scanner ran and recorded a verdict (no dependency manifests currently exist, so this asserts an empty, non-vulnerable dependency graph) | +| IaC / license | `.../attestations/iac-license/v1` | Trivy ran (misconfig + license) and recorded a verdict | +| Semgrep | `.../attestations/semgrep/v1` | Semgrep ran and recorded a verdict | +| Secrets | `.../attestations/secrets/v1` | Gitleaks + TruffleHog ran and recorded a verdict | +| Manifest review | `.../attestations/manifest/v1` | `marketplace.json`/`plugin.json` passed structural integrity review | +| OpenVEX | `https://openvex.dev/ns/v0.2.0` | Vulnerability disposition recorded for this release | +| Catalog signature | cosign keyless blob signature | The `marketplace.json` catalog blob is the one this repo published | + +> **Signed ≠ passed.** A passing verification proves the gate *ran and +> recorded a verdict* bound to the subject digest. Read the predicate body +> (`gh attestation verify ... --format json`) for the verdict itself. + +--- + +## Supply-chain security posture + +- Every GitHub Action referenced in `.github/workflows/` is pinned to a full + 40-character commit SHA, never a mutable tag or branch. The `pin-check` CI + job enforces this on every push and PR. +- The release pipeline is fail-closed: every attestation and the catalog + signature must verify before a tag's publish step runs. There is no path + from build to publish that bypasses verification. +- This is a single solo-maintained plugin repository, not a multi-repo org. + There is no fleet of GitHub Apps minting release tokens (the default + `GITHUB_TOKEN`, scoped per job, is used throughout) and no separate central + signing seam — this repo's own `release.yml` is the signer for every + attestation and the catalog signature. Verification still pins + `--signer-workflow` to that workflow explicitly: `--repo` alone only + proves "signed by some workflow in this repo," not specifically the + release workflow. +- ShellCheck is deliberately not wired as a gate: this repo has no `.sh` + scripts to check (its only executable logic lives in `.github/workflows/*.yml` + `run:` blocks). Wiring a gate with nothing to scan would produce a + permanently-empty attestation, which asserts nothing and would be + misleading. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index dbe1a49..5c20b07 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -1,50 +1,57 @@ --- +id: how-to-install-error-handling-plugin +type: procedural +created: 2026-05-21T10:47:00-04:00 diataxis_type: how-to title: Install the error-handling plugin --- # Install the error-handling plugin -Three install paths, ordered from most to least common. +This repository is itself a single-plugin marketplace: `.claude-plugin/marketplace.json` +lists one plugin, `error-handling`, sourced from the same repo. Two install paths, +ordered from most to least common. -## From the marketplace +## From GitHub (recommended) ``` -/plugin install zircote/error-handling +/plugin marketplace add zircote/cdc-error-handling +/plugin install error-handling@error-handling ``` -Verify both skills loaded: +Verify all three skills loaded: ``` /plugin list ``` -You should see `error-handling` with `cdc-err` and `cdc-review` underneath. +You should see `error-handling` with `cdc-err`, `cdc-review`, and `cdc-handle` underneath. ## From a local checkout (development) -Clone the repo, then point Claude Code at it via a project-level marketplace in `.claude/settings.json`: +Clone the repo, then add it as a local marketplace: + +``` +/plugin marketplace add /absolute/path/to/cdc-error-handling +/plugin install error-handling@error-handling +``` + +To make this automatic for a project (so teammates are prompted to install it), +add it to `.claude/settings.json`: ```json { - "plugins": { - "marketplaces": [ - { "path": "/absolute/path/to/error-handling" } - ] + "extraKnownMarketplaces": { + "error-handling": { + "source": { + "source": "github", + "repo": "zircote/cdc-error-handling" + } + } } } ``` -Restart Claude Code. Skills auto-discover from `skills/`. - -## From a personal marketplace - -If you maintain your own marketplace pointing at this repo: - -``` -/plugin install error-handling -``` - ## Verify Open a project with any code in it and type: diff --git a/docs/how-to/verify-release.md b/docs/how-to/verify-release.md new file mode 100644 index 0000000..b9b5861 --- /dev/null +++ b/docs/how-to/verify-release.md @@ -0,0 +1,153 @@ +--- +id: how-to-verify-a-release +type: procedural +created: 2026-07-03T10:00:27-04:00 +diataxis_type: how-to +title: Verify a release +--- + +# Verify a release + +Every tagged release publishes a `git archive` tarball carrying SLSA +build-provenance, a CycloneDX SBOM, six quality-gate verdicts, and an OpenVEX +disposition attestation; the `marketplace.json` catalog is cosign-signed. This +walks through re-verifying all of it from a clean workstation. Unlike a +multi-repo org, this plugin has no separate central signer: this repo's own +`release.yml` produces every attestation and the catalog signature. Pin +`--signer-workflow` to that workflow explicitly in every command below — +`--repo` alone only proves "some workflow in this repo signed it," not +specifically the release workflow. + +## Prerequisites + +- [GitHub CLI](https://cli.github.com/) `gh` ≥ 2.49.0, authenticated: + + ```bash + gh auth login + gh auth status + ``` + +- [`cosign`](https://github.com/sigstore/cosign), for the catalog signature. + +Substitute the actual release tag you're verifying for `TAG` below — the +commands are written against a variable, not a hard-coded version, since a +new tag ships each release. + +## Step 1 — Download the release assets + +```bash +TAG="v0.4.0" # substitute the release tag you're verifying +gh release download "$TAG" --repo zircote/cdc-error-handling --dir ./dl +cd ./dl +ls +# cdc-error-handling-.tar.gz +# cdc-error-handling--sbom.cdx.json +# cdc-error-handling--checksums.txt +# marketplace.json.cosign.bundle +``` + +## Step 2 — Verify the checksum + +```bash +sha256sum -c cdc-error-handling-*-checksums.txt +``` + +This only proves the download wasn't corrupted or truncated — it says +nothing about who built it. That's what the attestation is for. + +## Step 3 — Verify the provenance attestation + +```bash +gh attestation verify cdc-error-handling-*.tar.gz \ + --repo zircote/cdc-error-handling \ + --signer-workflow zircote/cdc-error-handling/.github/workflows/release.yml \ + --predicate-type https://slsa.dev/provenance/v1 +``` + +A passing verification looks like: + +``` +Loaded digest sha256:... for file://cdc-error-handling-0.4.0.tar.gz +Loaded 1 attestation from GitHub API +✓ Verification succeeded! +``` + +This proves the tarball was built by `zircote/cdc-error-handling`'s own +`release.yml` workflow, from the commit the release tag points at, and hasn't +been modified since. `gh attestation verify` exits non-zero on any mismatch +— treat a non-zero exit as a reason not to trust the artifact. + +## Step 4 — Verify the SBOM attestation + +```bash +gh attestation verify cdc-error-handling-*.tar.gz \ + --repo zircote/cdc-error-handling \ + --signer-workflow zircote/cdc-error-handling/.github/workflows/release.yml \ + --predicate-type https://cyclonedx.org/bom +``` + +Proves the tarball is bound to the CycloneDX SBOM published alongside it +(`cdc-error-handling-*-sbom.cdx.json`). + +## Step 5 — Verify the quality-gate verdicts + +Each gate (SAST, SCA, IaC/license, Semgrep, secrets, manifest review) is +attested separately, bound to the same tarball digest: + +```bash +for pt in sast sca iac-license semgrep secrets manifest; do + gh attestation verify cdc-error-handling-*.tar.gz \ + --repo zircote/cdc-error-handling \ + --signer-workflow zircote/cdc-error-handling/.github/workflows/release.yml \ + --predicate-type "https://zircote.github.io/attestations/${pt}/v1" +done +``` + +A passing verification proves the gate *ran and recorded a verdict* bound to +this exact tarball — read the predicate body (Step 8) for the verdict itself. + +## Step 6 — Verify the OpenVEX disposition + +```bash +gh attestation verify cdc-error-handling-*.tar.gz \ + --repo zircote/cdc-error-handling \ + --signer-workflow zircote/cdc-error-handling/.github/workflows/release.yml \ + --predicate-type https://openvex.dev/ns/v0.2.0 +``` + +## Step 7 — Verify the catalog signature + +The `marketplace.json` catalog is a blob (not an OCI image), so it's signed +with cosign keyless rather than `gh attestation verify`: + +```bash +cosign verify-blob .claude-plugin/marketplace.json \ + --bundle marketplace.json.cosign.bundle \ + --certificate-identity-regexp '^https://github\.com/zircote/cdc-error-handling/\.github/workflows/release\.yml@' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +Note this checks the catalog file in your local checkout of the repo +(`.claude-plugin/marketplace.json`), not a release asset — download or +checkout the repo at the release tag first if you don't already have it. + +## Step 8 (optional) — Inspect a predicate body + +To see the actual details bound into any attestation (build provenance shown +here; substitute any predicate type from the steps above): + +```bash +gh attestation verify cdc-error-handling-*.tar.gz \ + --repo zircote/cdc-error-handling \ + --signer-workflow zircote/cdc-error-handling/.github/workflows/release.yml \ + --predicate-type https://slsa.dev/provenance/v1 \ + --format json | jq '.[0].verificationResult.statement.predicate' +``` + +## What this does and doesn't prove + +- **Proves**: this exact file was produced by this repository's release + workflow from a specific commit, and hasn't been altered since. +- **Doesn't prove**: the code is bug-free, or that the maintainer's account + or the GitHub Actions runner it built on wasn't itself compromised. See + [SECURITY.md](../../SECURITY.md) for the full threat-model caveat.