diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b10198..2cd4a76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true @@ -34,6 +36,8 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true @@ -44,16 +48,59 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.14" + - name: Stamp simulated immutable source identity + id: release + run: | + VERSION=$(python3 -c 'import re; print(re.search(r"^version\s*=\s*\"([^\"]+)\"", open("pyproject.toml").read(), re.M).group(1))') + python3 scripts/stamp_build_metadata.py \ + --source-ref "refs/tags/v${VERSION}" \ + --source-commit "$GITHUB_SHA" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - name: Build wheel and sdist - run: uv build - - name: Verify skills are bundled in the wheel run: | - python - <<'PY' - import glob, zipfile, sys - names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist() - skills = [n for n in names if n.startswith("skilldeck/skills/")] - assert any(n.endswith("meta.yaml") for n in skills), "no skill meta.yaml in wheel" - assert any(n.endswith("skill.md") for n in skills), "no skill.md in wheel" - print(f"ok: {len(skills)} skill files bundled") - PY + uv build --out-dir dist + python3 -c 'from pathlib import Path; Path("dist/.gitignore").unlink(missing_ok=True)' + - name: Verify Python distributions and plugin share one identity + env: + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + run: | + uv run --extra dev python scripts/verify_distribution_identity.py \ + --wheel dist/*.whl \ + --sdist dist/*.tar.gz \ + --plugin-dir claude-plugin \ + --expected-version "$EXPECTED_VERSION" \ + --expected-ref "refs/tags/v${EXPECTED_VERSION}" \ + --expected-commit "$GITHUB_SHA" + - name: Install only the release wheel and runtime dependencies + run: | + uv venv .release-venv --python 3.14 + uv pip install --python .release-venv/bin/python dist/*.whl + .release-venv/bin/skilldeck provenance --json > /tmp/skilldeck-provenance.json + - name: Generate SPDX runtime SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: .release-venv + format: spdx-json + output-file: dist/skilldeck-${{ steps.release.outputs.version }}.spdx.json + artifact-name: skilldeck-${{ steps.release.outputs.version }}.spdx.json + upload-artifact: false + upload-release-assets: false + dependency-snapshot: false + syft-version: v1.51.0 + - name: Validate SBOM and exact checksum set + run: | + python3 scripts/verify_sbom.py dist/*.spdx.json + python3 scripts/write_checksums.py dist + python3 scripts/write_checksums.py --verify dist/SHA256SUMS + - name: Upload candidate trust bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-candidate-${{ github.sha }} + path: dist/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1124f90..b3fca78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,8 @@ name: Release -# Publishes to PyPI when a version tag is pushed (e.g. v0.1.0). -# Uses PyPI Trusted Publishing (OIDC) — no API token needed. Before the first -# release, configure a trusted publisher on PyPI for this repo + workflow and -# create a GitHub environment named "pypi". +# A tag is the explicit release authorization. The workflow builds once, then +# carries the same verified bytes through attestation, PyPI, GitHub, and +# independent channel readback. on: push: tags: ["v*"] @@ -14,26 +13,216 @@ permissions: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.release.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Verify tag matches package version and CHANGELOG - run: python3 scripts/check_release_consistency.py --tag "${GITHUB_REF_NAME}" + with: + persist-credentials: false + - name: Verify tag, package version, and CHANGELOG + run: python3 scripts/check_release_consistency.py --tag "$GITHUB_REF_NAME" - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - - run: uv build - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: dist + python-version: "3.14" + enable-cache: false + - name: Stamp exact release source identity + id: release + run: | + VERSION="${GITHUB_REF_NAME#v}" + python3 scripts/stamp_build_metadata.py \ + --source-ref "$GITHUB_REF" \ + --source-commit "$GITHUB_SHA" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + - name: Build wheel and source distribution + run: | + uv build --out-dir dist + python3 -c 'from pathlib import Path; Path("dist/.gitignore").unlink(missing_ok=True)' + - name: Verify distributions and tagged plugin identity + env: + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + run: | + uv run --extra dev python scripts/verify_distribution_identity.py \ + --wheel dist/*.whl \ + --sdist dist/*.tar.gz \ + --plugin-dir claude-plugin \ + --expected-version "$EXPECTED_VERSION" \ + --expected-ref "$GITHUB_REF" \ + --expected-commit "$GITHUB_SHA" + - name: Install only release runtime dependencies + run: | + uv venv .release-venv --python 3.14 + uv pip install --python .release-venv/bin/python dist/*.whl + .release-venv/bin/skilldeck provenance --json > /tmp/skilldeck-provenance.json + - name: Generate SPDX runtime SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: .release-venv + format: spdx-json + output-file: dist/skilldeck-${{ steps.release.outputs.version }}.spdx.json + artifact-name: skilldeck-${{ steps.release.outputs.version }}.spdx.json + upload-artifact: false + upload-release-assets: false + dependency-snapshot: false + syft-version: v1.51.0 + - name: Validate SBOM and exact checksum set + run: | + python3 scripts/verify_sbom.py dist/*.spdx.json + python3 scripts/write_checksums.py dist + python3 scripts/write_checksums.py --verify dist/SHA256SUMS + - name: Upload immutable release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-bundle path: dist/ + if-no-files-found: error - publish: + attest: needs: build runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-bundle + path: dist + - name: Re-verify downloaded bundle + run: python3 scripts/write_checksums.py --verify dist/SHA256SUMS + - name: Attest artifact build provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: dist/SHA256SUMS + - name: Attest checksum-file build provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/SHA256SUMS + - name: Attest wheel and source-distribution SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist/*.whl + dist/*.tar.gz + sbom-path: dist/skilldeck-${{ needs.build.outputs.version }}.spdx.json + + publish-pypi: + needs: [build, attest] + runs-on: ubuntu-latest environment: pypi permissions: - id-token: write # required for trusted publishing + contents: read + id-token: write steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist - path: dist/ + name: release-bundle + path: release-bundle + - name: Re-verify and isolate Python distributions + run: | + python3 scripts/write_checksums.py --verify release-bundle/SHA256SUMS + mkdir publish + cp release-bundle/*.whl release-bundle/*.tar.gz publish/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + packages-dir: publish + + github-release: + needs: [build, attest, publish-pypi] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-bundle + path: release-bundle + - name: Verify and publish the existing tag + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 scripts/write_checksums.py --verify release-bundle/SHA256SUMS + gh release create "$GITHUB_REF_NAME" release-bundle/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + --title "Skilldeck ${GITHUB_REF_NAME#v}" + + verify-channels: + needs: [build, github-release] + runs-on: ubuntu-latest + permissions: + contents: read + attestations: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.14" + enable-cache: false + - name: Download fresh GitHub Release assets + env: + GH_TOKEN: ${{ github.token }} + run: gh release download "$GITHUB_REF_NAME" --dir downloaded + - name: Verify fresh bytes, GitHub attestations, and PyPI attestations + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 scripts/write_checksums.py --verify downloaded/SHA256SUMS + for ARTIFACT in downloaded/*.whl downloaded/*.tar.gz; do + BASENAME=$(basename "$ARTIFACT") + gh attestation verify "$ARTIFACT" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-ref "$GITHUB_REF" \ + --source-digest "$GITHUB_SHA" \ + --deny-self-hosted-runners + gh attestation verify "$ARTIFACT" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ + --source-ref "$GITHUB_REF" \ + --source-digest "$GITHUB_SHA" \ + --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 + uvx --from pypi-attestations==0.0.29 pypi-attestations verify pypi \ + --repository https://github.com/IcebergAI/skilldeck \ + "pypi:$BASENAME" + done + - name: Re-verify distribution identity from downloaded assets + env: + EXPECTED_VERSION: ${{ needs.build.outputs.version }} + run: | + uv run --extra dev python scripts/verify_distribution_identity.py \ + --wheel downloaded/*.whl \ + --sdist downloaded/*.tar.gz \ + --plugin-dir claude-plugin \ + --expected-version "$EXPECTED_VERSION" \ + --expected-ref "$GITHUB_REF" \ + --expected-commit "$GITHUB_SHA" + - name: Prove tampering is rejected + env: + GH_TOKEN: ${{ github.token }} + run: | + cp -R downloaded tampered + printf 'tampered' >> tampered/*.whl + if python3 scripts/write_checksums.py --verify tampered/SHA256SUMS; then + echo "checksum verification accepted a tampered wheel" >&2 + exit 1 + fi + if gh attestation verify tampered/*.whl --repo "$GITHUB_REPOSITORY"; then + echo "attestation verification accepted a tampered wheel" >&2 + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 61330e3..0c5b94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,12 @@ All notable changes to this project are documented here. The format is based on ### Added +- Release trust chain (#76): exact tag/commit metadata in wheel and source + distribution, a shared canonical content manifest for Python and the Claude + plugin, `skilldeck provenance`, archive-safe cross-distribution verification, + a runtime-only SPDX 2.3 SBOM, exact SHA-256 checksums, GitHub build/SBOM + attestations, PyPI PEP 740 verification, and post-publication channel and + tamper checks. Consumer and operator verification procedures are documented. - `docs/releasing.md` documenting the versioning and release procedure, plus `scripts/check_release_consistency.py` — a stdlib guard that asserts the `pyproject` version, the newest dated CHANGELOG section, and (on a tag push) the diff --git a/CLAUDE.md b/CLAUDE.md index d225442..c1bae0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,13 @@ Skilldeck is a collection of skills for coding assistants to use mostly for secu marketplace tree, **generated** by `scripts/build_plugin.py` from the canonical skills; regenerate after changing skills or the project version (a pytest freshness guard enforces this), never edit by hand +- `src/skilldeck/_content_manifest.json` + + `claude-plugin/.skilldeck/content-manifest.json` — identical generated + canonical and rendered-skill identities; regenerated with the plugin tree, + never edited by hand +- `src/skilldeck/_build_metadata.json` — development placeholder; only + `scripts/stamp_build_metadata.py` may add an exact release tag/full commit in + the authorized tag workflow ## Conventions - Skills are authored once in `src/skilldeck/skills/`; never hand-edit per-agent @@ -75,4 +82,7 @@ Skilldeck is a collection of skills for coding assistants to use mostly for secu stay in sync — `scripts/check_release_consistency.py` enforces this in CI and `pytest`. A dated CHANGELOG section without a matching `v*` tag is prepared, not published. - \ No newline at end of file +- Release CI must build once, verify wheel/sdist/plugin identity, produce a + runtime-only SPDX SBOM and exact checksums, attest those bytes, then publish + the same bundle. Keep build, attest, PyPI, and GitHub-release permissions in + separate jobs and preserve the post-publication readback/tamper gate. diff --git a/README.md b/README.md index 71c8bc7..4584341 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ skilldeck status --agent claude # Refresh installed skills after upgrading skilldeck skilldeck update --agent claude + +# Inspect the package source identity and bundled skill digests +skilldeck provenance +skilldeck provenance --json ``` Installed files carry a `skilldeck` stamp recording the skill version, so @@ -133,6 +137,18 @@ pass `--force`. writes into your home directory. Where exactly each agent looks is documented in [docs/adapters.md](docs/adapters.md). +## Release trust + +Tagged releases publish one wheel, one source distribution, an SPDX 2.3 runtime +SBOM, and `SHA256SUMS`. GitHub build/SBOM attestations and PyPI's Trusted +Publishing attestation bind those bytes to the exact tag commit. The release +workflow downloads both channels again and rejects checksum, provenance, SBOM, +content-manifest, or tamper-test failures before it succeeds. + +See [Verifying a Skilldeck release](docs/verifying-releases.md) for the complete +consumer procedure. These commands become actionable with the first published +release; the package remains unpublished today. + ## Authoring skills Each skill is a directory under `src/skilldeck/skills/` containing a `meta.yaml` diff --git a/claude-plugin/.skilldeck/content-manifest.json b/claude-plugin/.skilldeck/content-manifest.json new file mode 100644 index 0000000..e3775bb --- /dev/null +++ b/claude-plugin/.skilldeck/content-manifest.json @@ -0,0 +1,86 @@ +{ + "package_version": "0.3.0", + "schema_version": 1, + "skills": [ + { + "body_sha256": "sha256:a7ad8a0b2d441b65941db1c33ede8b5c79a10c8a2d4953e726b451421825535f", + "canonical_sha256": "sha256:959b8fdb94da830415e760084c484932bbf951815c944e80d4e016a3fd8835ee", + "claude_rendered_sha256": "sha256:5ff974113d642db2b6870369ec49692c0838b76cc677912d75f40d058c1d180b", + "meta_sha256": "sha256:4adf117b42241e18cf31a5add5fc081c2014d7769c5c9b2136b0bbfddc6ad024", + "name": "authentication-review", + "version": "0.1.0" + }, + { + "body_sha256": "sha256:e6d57912d945ae733a2dbfe4349e395ef42555eec1fe0b5912482d8169796a20", + "canonical_sha256": "sha256:dc4b75a427098e1ae692339d44b767f9b2883dd8653f2d1382dd469584dac1f7", + "claude_rendered_sha256": "sha256:7ed6934ffb5d5b03fe3aaabe5892d9c36bd8373ca36a63f8b3820d53261c1853", + "meta_sha256": "sha256:9646bc2a752745b5b9a872b778371afb259541f6a416c8c759c3ddf8aee8c69a", + "name": "ci-workflow-review", + "version": "0.2.0" + }, + { + "body_sha256": "sha256:1a40a16b2afc5fae7940a6d1b2e4b218c46171d74916683c6651582b92f46387", + "canonical_sha256": "sha256:604df1564c70febf7d147218a4325e46e3184df87f612880abfea67ab7880881", + "claude_rendered_sha256": "sha256:fe580e51da98d73ec079af315d95334f6a332b2b1342eb23427b40d2b2543ada", + "meta_sha256": "sha256:9b1ccd5172280cfcec8430801239d44e5a4955f7d4a8aad7ff25e8253303aad1", + "name": "code-smells", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:5f5e00ff451b7589751b00062573d781942dd10610a590e9fba89db3417b994d", + "canonical_sha256": "sha256:b5c2fa47d57e9d1265d909c4b96438839f35f39b1543245be8ede2b69b24fdeb", + "claude_rendered_sha256": "sha256:68ac07cdd373871e1ddbd3f675b01229f1feddea37f7c59c91da6869a469fc9e", + "meta_sha256": "sha256:0b19ff954bb43cea8a6cefe329a7b4687ec255100ce98fb95181d2141abf2732", + "name": "dependency-review", + "version": "0.2.2" + }, + { + "body_sha256": "sha256:18bf52096e596422434d0536db59cf3a06ee75f264e2951925a251fd1b6def64", + "canonical_sha256": "sha256:8e2cf5f056673cf89173d1f564090d448c30d61d81c3e6c53dc70f09db9a2c99", + "claude_rendered_sha256": "sha256:4bcde77df9bc3aa34d18c16872e88fff3d5b70ce7237ed16411959c88a51f183", + "meta_sha256": "sha256:b08a37404286edf6f8426bb35023d13f95dced3f62d03edf638de3ebf25ec90b", + "name": "iac-review", + "version": "0.1.0" + }, + { + "body_sha256": "sha256:c1dc5d41396e4757f4d83f1bafeee57b5faae2ec1dfd6cafd1424e016e2ef168", + "canonical_sha256": "sha256:b3bdb8524872d1ea6c5a3e613bb846a3255999f5aafcf41b6c5dbb2b589a228e", + "claude_rendered_sha256": "sha256:dbda296679e4ab283b60319d69be37f6bde7153b1743f0f72c2cbfb024580519", + "meta_sha256": "sha256:792a7147a855a1d0723e625e00f800ebe1a543370a9ba93e6d9b5ae279f059a7", + "name": "logging", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:da723ff2f114ec22ca316c25323272398349c39d07ebb303d7c96cbc74ad6c5d", + "canonical_sha256": "sha256:c3feb88053c1ea2c49775f4926c55362d8a1e102e722e0918ed5672dbf89ea7c", + "claude_rendered_sha256": "sha256:4d2c8be475467890a06380836239ef40bdb15c87d7f466aa33c36593ce7821d5", + "meta_sha256": "sha256:b734d846c2b777e43beaa06a4660f30c8814edbaf44c3635d8a52847cc64bbe8", + "name": "migration-review", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:d4f858b08536c4a7a011e28bcd099f44c044981bc2bd2b1c72bff28df715e03b", + "canonical_sha256": "sha256:b17a68d643eb66ba7a279b14a0e3e419850ea30c0a5676346b2725635ccd3836", + "claude_rendered_sha256": "sha256:204996856a605b57cf5424473ba7939d8ea10e00170bc207508ac4e4a81d9787", + "meta_sha256": "sha256:d7a52f983ab69b6bbb13631e7f5062bfbfbb24d78e727c760c2a3d7b01613fae", + "name": "resilience-review", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:e28cb0a29f2f2827d9d221a79c4b3bcb5459b45628521628604111bef8addf76", + "canonical_sha256": "sha256:f5de69e8353cac96cd2d347f795626745cfb955c234191aabf6c29cd6251be1e", + "claude_rendered_sha256": "sha256:ae6d042864de193ab3a220d4929b902a69fc24ea7ec983968d1bc864b20d245f", + "meta_sha256": "sha256:2b91647dcfe0fe755be71d17d0824b0d9bc7eaab9ffdc542c74e5889aee60e15", + "name": "security-review", + "version": "0.3.2" + }, + { + "body_sha256": "sha256:9f7a3b0643d9f0bab1cd18171e6e1e96b5e5b1317037fcd0c41f678fb1bf6f84", + "canonical_sha256": "sha256:c602673b548a095676aad417b396e7749c2f9cd28717d04ea2333a1a70fca5cc", + "claude_rendered_sha256": "sha256:08ef05a1c1dd331dde481b237ade94df7825885f173aaa22f3f0f0c0106455a1", + "meta_sha256": "sha256:b873ae05416991b7eef527cd0897ef8d4517e4dea29f33add321ee20473f2c33", + "name": "test-review", + "version": "0.2.1" + } + ] +} diff --git a/docs/releasing.md b/docs/releasing.md index ba6eb91..e91c5d0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -40,8 +40,31 @@ state of the repo (nothing is tagged or on PyPI yet). At this point the version is **prepared**. To actually **publish**: 5. Push a tag matching the version: `git tag vX.Y.Z && git push origin vX.Y.Z`. - This triggers `.github/workflows/release.yml`, which re-checks the tag against - the version and publishes to PyPI via Trusted Publishing. + This is the explicit publication authorization. The release workflow then: + + - re-checks tag, package, and changelog agreement; + - stamps the full tag ref and commit into both Python distributions; + - proves wheel, source distribution, and committed Claude plugin have the + same canonical skill manifest; + - builds and validates an SPDX 2.3 SBOM from a clean runtime-only install; + - writes and verifies an exact `SHA256SUMS` file; + - creates GitHub SLSA provenance and SBOM attestations; + - publishes only the wheel and source distribution to PyPI with Trusted + Publishing and its PEP 740 attestation; + - creates the GitHub release from the same build bundle; and + - downloads the public release again, verifies both channels, and proves a + one-byte modification is rejected. + + Build, attestation, PyPI, and GitHub release permissions are isolated in + separate jobs. No publish job rebuilds an artifact. + +Before the first tag, an owner must configure the repository's `pypi` +environment and the matching pending/trusted publisher on PyPI. This is an +external release gate, not a value committed to the repository. Do not create a +tag until that configuration has been read back and the release PR is frozen. + +Consumer verification is documented in +[verifying-releases.md](verifying-releases.md). ## The consistency guard @@ -55,3 +78,18 @@ At this point the version is **prepared**. To actually **publish**: So a version/CHANGELOG mismatch fails CI, and a mis-tagged release fails before anything reaches PyPI. + +## Generated trust files + +- `src/skilldeck/_content_manifest.json` and + `claude-plugin/.skilldeck/content-manifest.json` are generated together by + `scripts/build_plugin.py`; never edit either by hand. +- `src/skilldeck/_build_metadata.json` is committed with unavailable source + fields for development. `scripts/stamp_build_metadata.py` writes an exact + `refs/tags/vX.Y.Z` plus full commit only inside the authorized tag workflow. +- `scripts/verify_distribution_identity.py` fails closed on archive traversal, + links, duplicate members, malformed manifests, missing/orphaned skills, or + any wheel/sdist/plugin digest mismatch. +- `scripts/write_checksums.py` accepts exactly one wheel, one source + distribution, and one SPDX document. It streams verification and rejects + symlinks, malformed lines, duplicates, extras, and missing artifacts. diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md new file mode 100644 index 0000000..0554386 --- /dev/null +++ b/docs/verifying-releases.md @@ -0,0 +1,98 @@ +# Verifying a Skilldeck release + +These checks authenticate where release artifacts came from, verify their +bytes, and show which canonical skills they contain. They apply after the first +tagged release is published; until then the README's source-checkout install +path remains the supported path. + +## Download and verify the release bundle + +Use a current GitHub CLI with the `gh attestation` command. Replace `vX.Y.Z` +with the release you intend to install. + +```bash +TAG=vX.Y.Z +REPOSITORY=IcebergAI/skilldeck +mkdir -p release +gh release download "$TAG" --repo "$REPOSITORY" --dir release +(cd release && sha256sum --check SHA256SUMS) +TAG_COMMIT=$(gh api "repos/$REPOSITORY/commits/$TAG" --jq .sha) +``` + +`SHA256SUMS` covers exactly one wheel, one source distribution, and one SPDX +2.3 runtime SBOM. On macOS, use `shasum -a 256 --check SHA256SUMS` if GNU +`sha256sum` is unavailable. + +For both the wheel and source distribution, verify GitHub's signed build +provenance and the attached SPDX predicate: + +```bash +for ARTIFACT in release/*.whl release/*.tar.gz; do + gh attestation verify "$ARTIFACT" \ + --repo "$REPOSITORY" \ + --signer-workflow IcebergAI/skilldeck/.github/workflows/release.yml \ + --source-ref "refs/tags/$TAG" \ + --source-digest "$TAG_COMMIT" \ + --deny-self-hosted-runners + + gh attestation verify "$ARTIFACT" \ + --repo "$REPOSITORY" \ + --signer-workflow IcebergAI/skilldeck/.github/workflows/release.yml \ + --source-ref "refs/tags/$TAG" \ + --source-digest "$TAG_COMMIT" \ + --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 +done +``` + +The first command proves the artifact was built by the tagged Skilldeck release +workflow at that exact commit. The second authenticates the SPDX document that +describes the wheel and source distribution. + +## Verify the PyPI channel + +PyPI Trusted Publishing supplies a separate PEP 740 publish attestation. Ask +PyPI for each exact release filename and verify the file it serves against its +provenance record: + +```bash +for ARTIFACT in release/*.whl release/*.tar.gz; do + BASENAME=$(basename "$ARTIFACT") + uvx --from pypi-attestations==0.0.29 pypi-attestations verify pypi \ + --repository https://github.com/IcebergAI/skilldeck \ + "pypi:$BASENAME" +done +``` + +This independently downloads and verifies PyPI's wheel and source distribution +under the expected repository identity. The release workflow publishes those +files from the same checksum-verified bundle used for the GitHub release; no +channel rebuilds them. + +## Inspect installed content + +After installing the verified wheel in an isolated environment: + +```bash +skilldeck provenance +skilldeck provenance --json +``` + +The command reports the package version, exact tag and commit embedded at build +time, and each bundled skill's version and canonical SHA-256 identity. A source +checkout honestly reports the tag and commit as unavailable instead of +inventing a release identity. + +The Claude plugin contains the same generated content manifest at +`claude-plugin/.skilldeck/content-manifest.json`. Release CI recomputes the +canonical metadata, skill bodies, and rendered Claude files from the wheel, +source distribution, and tagged plugin tree before publication. + +## What verification does and does not prove + +- Checksums detect accidental corruption, but a checksum downloaded beside a + mutable artifact does not authenticate its publisher by itself. +- GitHub and PyPI attestations authenticate producer identity and artifact + integrity. They do not prove that the software is vulnerability-free. +- A modified artifact must fail both its checksum and its signed attestation. + The release workflow performs that negative test after publishing each tag. diff --git a/scripts/build_plugin.py b/scripts/build_plugin.py index 2cbfaaf..b7d70e9 100644 --- a/scripts/build_plugin.py +++ b/scripts/build_plugin.py @@ -28,6 +28,11 @@ sys.path.insert(0, str(ROOT / "src")) # run from a checkout without installing from skilldeck.adapters import ADAPTERS # noqa: E402 +from skilldeck.provenance import ( # noqa: E402 + canonical_json, + claude_plugin_metadata, + content_manifest, +) from skilldeck.registry import discover_skills # noqa: E402 PLUGIN_NAME = "skilldeck" @@ -52,19 +57,8 @@ def generate() -> dict[Path, str]: path = Path("claude-plugin/skills") / skill.name / "SKILL.md" files[path] = claude.render(skill) - description = "Security and code-review skills for Claude Code: " + ", ".join( - skill.name for skill in skills - ) - plugin = { - "name": PLUGIN_NAME, - "version": project_version(), - "description": description, - "author": {"name": "Richard Hope", "url": REPO_URL}, - "homepage": REPO_URL, - "repository": REPO_URL, - "license": "MIT", - "keywords": ["security", "code-review", "skills"], - } + plugin = claude_plugin_metadata(project_version(), skills) + description = str(plugin["description"]) files[Path("claude-plugin/.claude-plugin/plugin.json")] = ( json.dumps(plugin, indent=2) + "\n" ) @@ -83,6 +77,9 @@ def generate() -> dict[Path, str]: files[Path(".claude-plugin/marketplace.json")] = ( json.dumps(marketplace, indent=2) + "\n" ) + manifest_text = canonical_json(content_manifest(project_version(), skills)) + files[Path("src/skilldeck/_content_manifest.json")] = manifest_text + files[Path("claude-plugin/.skilldeck/content-manifest.json")] = manifest_text return files @@ -95,12 +92,16 @@ def stale(files: dict[Path, str]) -> list[str]: problems.append(f"missing: {rel}") elif on_disk.read_text(encoding="utf-8") != content: problems.append(f"outdated: {rel}") - skills_dir = ROOT / "claude-plugin" / "skills" - if skills_dir.is_dir(): - expected = {ROOT / rel for rel in files} - for skill_md in skills_dir.glob("*/SKILL.md"): - if skill_md not in expected: - problems.append(f"orphaned: {skill_md.relative_to(ROOT)}") + plugin_dir = ROOT / "claude-plugin" + if plugin_dir.is_dir(): + expected = {ROOT / rel for rel in files if rel.is_relative_to("claude-plugin")} + actual = { + path + for path in plugin_dir.rglob("*") + if path.is_file() or path.is_symlink() + } + for path in sorted(actual - expected): + problems.append(f"unexpected: {path.relative_to(ROOT)}") return problems diff --git a/scripts/stamp_build_metadata.py b/scripts/stamp_build_metadata.py new file mode 100644 index 0000000..eded481 --- /dev/null +++ b/scripts/stamp_build_metadata.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Stamp an exact release tag and commit into the package before building.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +REPOSITORY_URL = "https://github.com/IcebergAI/skilldeck" +_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) + + +def project_version(root: Path) -> str: + match = _VERSION_RE.search((root / "pyproject.toml").read_text(encoding="utf-8")) + if not match: + raise ValueError("pyproject.toml has no project version") + return match.group(1) + + +def expected_metadata( + root: Path, source_ref: str, source_commit: str +) -> dict[str, object]: + version = project_version(root) + expected_ref = f"refs/tags/v{version}" + if source_ref != expected_ref: + raise ValueError( + f"source ref {source_ref!r} does not match package tag {expected_ref!r}" + ) + if not _COMMIT_RE.fullmatch(source_commit): + raise ValueError("source commit must be 40 lowercase hexadecimal characters") + return { + "schema_version": 1, + "source_commit": source_commit, + "source_ref": source_ref, + "source_repository": REPOSITORY_URL, + } + + +def metadata_path(root: Path) -> Path: + return root / "src" / "skilldeck" / "_build_metadata.json" + + +def serialized(data: object) -> str: + return json.dumps(data, indent=2, sort_keys=True) + "\n" + + +def stamp( + root: Path, + source_ref: str, + source_commit: str, + *, + check: bool = False, +) -> None: + expected = serialized(expected_metadata(root, source_ref, source_commit)) + path = metadata_path(root) + if check: + if path.read_text(encoding="utf-8") != expected: + raise ValueError(f"{path} does not contain the expected release identity") + return + path.write_text(expected, encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-ref", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument( + "--root", type=Path, default=Path(__file__).resolve().parent.parent + ) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + try: + stamp( + args.root.resolve(), + args.source_ref, + args.source_commit, + check=args.check, + ) + except (OSError, ValueError) as exc: + parser.error(str(exc)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_distribution_identity.py b/scripts/verify_distribution_identity.py new file mode 100644 index 0000000..5b900ff --- /dev/null +++ b/scripts/verify_distribution_identity.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Verify wheel, sdist, and Claude plugin share one exact release identity.""" + +from __future__ import annotations + +import argparse +import json +import re +import stat +import sys +import tarfile +import zipfile +from pathlib import Path, PurePosixPath +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from skilldeck.adapters import ADAPTERS # noqa: E402 +from skilldeck.provenance import ( # noqa: E402 + REPOSITORY_URL, + canonical_skill_digest, + claude_plugin_metadata, + sha256_text, +) +from skilldeck.registry import Skill # noqa: E402 + +MAX_MEMBER_BYTES = 64 * 1024 * 1024 +MAX_ARCHIVE_MEMBERS = 1_024 +MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") + + +class VerificationError(ValueError): + """A release artifact does not satisfy the identity contract.""" + + +def _safe_member(name: str) -> PurePosixPath: + if not name or "\\" in name: + raise VerificationError(f"unsafe archive member: {name!r}") + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts: + raise VerificationError(f"unsafe archive member: {name!r}") + return path + + +def read_zip(path: Path) -> dict[str, bytes]: + files: dict[str, bytes] = {} + seen: set[str] = set() + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if len(members) > MAX_ARCHIVE_MEMBERS: + raise VerificationError("too many archive members") + total = 0 + for member in members: + name = str(_safe_member(member.filename)) + if name in seen: + raise VerificationError(f"duplicate archive member: {name}") + seen.add(name) + if member.flag_bits & 0x1: + raise VerificationError(f"encrypted archive member: {name}") + mode = member.external_attr >> 16 + kind = stat.S_IFMT(mode) + if member.is_dir(): + continue + if kind not in (0, stat.S_IFREG): + raise VerificationError(f"non-regular archive member: {name}") + if member.file_size > MAX_MEMBER_BYTES: + raise VerificationError(f"oversized archive member: {name}") + total += member.file_size + if total > MAX_ARCHIVE_BYTES: + raise VerificationError("archive exceeds aggregate size limit") + payload = archive.read(member) + if len(payload) != member.file_size: + raise VerificationError(f"truncated archive member: {name}") + files[name] = payload + return files + + +def read_tar(path: Path) -> dict[str, bytes]: + files: dict[str, bytes] = {} + seen: set[str] = set() + with tarfile.open(path, mode="r|gz") as archive: + total = 0 + count = 0 + for member in archive: + count += 1 + if count > MAX_ARCHIVE_MEMBERS: + raise VerificationError("too many archive members") + name = str(_safe_member(member.name)) + if name in seen: + raise VerificationError(f"duplicate archive member: {name}") + seen.add(name) + if member.isdir(): + continue + if not member.isfile(): + raise VerificationError(f"non-regular archive member: {name}") + if member.size > MAX_MEMBER_BYTES: + raise VerificationError(f"oversized archive member: {name}") + total += member.size + if total > MAX_ARCHIVE_BYTES: + raise VerificationError("archive exceeds aggregate size limit") + stream = archive.extractfile(member) + if stream is None: + raise VerificationError(f"unreadable archive member: {name}") + payload = stream.read(MAX_MEMBER_BYTES + 1) + if len(payload) != member.size: + raise VerificationError(f"truncated archive member: {name}") + files[name] = payload + return files + + +def _one_suffix(files: dict[str, bytes], suffix: str) -> tuple[str, bytes]: + found = [(name, data) for name, data in files.items() if name.endswith(suffix)] + if len(found) != 1: + raise VerificationError(f"expected exactly one {suffix}, found {len(found)}") + return found[0] + + +def _json_bytes(payload: bytes, label: str) -> dict[str, Any]: + try: + data = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise VerificationError(f"invalid JSON in {label}") from exc + if not isinstance(data, dict): + raise VerificationError(f"{label} must be a JSON object") + return data + + +def _validate_build( + data: dict[str, Any], version: str, source_ref: str, source_commit: str +) -> None: + expected = { + "schema_version": 1, + "source_commit": source_commit, + "source_ref": source_ref, + "source_repository": REPOSITORY_URL, + } + if data != expected: + raise VerificationError("distribution build metadata does not match release") + if source_ref != f"refs/tags/v{version}" or not _COMMIT_RE.fullmatch(source_commit): + raise VerificationError("invalid expected release identity") + + +def _validate_manifest_shape( + data: dict[str, Any], version: str +) -> list[dict[str, Any]]: + if set(data) != {"schema_version", "package_version", "skills"}: + raise VerificationError("content manifest has unknown or missing fields") + if data["schema_version"] != 1 or data["package_version"] != version: + raise VerificationError("content manifest version mismatch") + records = data["skills"] + if not isinstance(records, list) or not records: + raise VerificationError("content manifest has no skills") + expected_fields = { + "name", + "version", + "canonical_sha256", + "meta_sha256", + "body_sha256", + "claude_rendered_sha256", + } + names: list[str] = [] + for record in records: + if not isinstance(record, dict) or set(record) != expected_fields: + raise VerificationError("invalid skill record in content manifest") + if not isinstance(record["name"], str) or not isinstance( + record["version"], str + ): + raise VerificationError("invalid skill identity in content manifest") + for field in ( + "canonical_sha256", + "meta_sha256", + "body_sha256", + "claude_rendered_sha256", + ): + if not isinstance(record[field], str) or not _DIGEST_RE.fullmatch( + record[field] + ): + raise VerificationError(f"invalid {field} in content manifest") + names.append(record["name"]) + if names != sorted(names) or len(names) != len(set(names)): + raise VerificationError("content manifest skills are unsorted or duplicated") + return records + + +def _distribution_skills( + files: dict[str, bytes], marker: str +) -> dict[str, dict[str, str]]: + pattern = re.compile( + rf"(?:^|/){re.escape(marker)}/skills/([^/]+)/(meta\.yaml|skill\.md)$" + ) + skills: dict[str, dict[str, str]] = {} + for name, payload in files.items(): + match = pattern.search(name) + if not match: + continue + skill_name, filename = match.groups() + record = skills.setdefault(skill_name, {}) + if filename in record: + raise VerificationError(f"duplicate {filename} for {skill_name}") + try: + record[filename] = payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise VerificationError(f"non-UTF-8 canonical skill: {skill_name}") from exc + return skills + + +def validate_distribution( + files: dict[str, bytes], + *, + marker: str, + version: str, + source_ref: str, + source_commit: str, +) -> dict[str, Any]: + _, manifest_bytes = _one_suffix(files, f"{marker}/_content_manifest.json") + _, build_bytes = _one_suffix(files, f"{marker}/_build_metadata.json") + manifest = _json_bytes(manifest_bytes, "content manifest") + build = _json_bytes(build_bytes, "build metadata") + records = _validate_manifest_shape(manifest, version) + _validate_build(build, version, source_ref, source_commit) + + skills = _distribution_skills(files, marker) + expected_names = {record["name"] for record in records} + if set(skills) != expected_names: + raise VerificationError( + "distribution skill set does not match content manifest" + ) + for record in records: + name = record["name"] + source = skills[name] + if set(source) != {"meta.yaml", "skill.md"}: + raise VerificationError(f"incomplete canonical skill: {name}") + meta_text = source["meta.yaml"] + body_text = source["skill.md"] + actual = { + "canonical_sha256": canonical_skill_digest(meta_text, body_text), + "meta_sha256": sha256_text(meta_text), + "body_sha256": sha256_text(body_text), + } + for field, digest in actual.items(): + if record[field] != digest: + raise VerificationError(f"{name} {field} does not match manifest") + return manifest + + +def validate_plugin( + plugin_dir: Path, + manifest: dict[str, Any], + canonical_skills: dict[str, dict[str, str]], + version: str, +) -> None: + manifest_path = plugin_dir / ".skilldeck" / "content-manifest.json" + plugin_json_path = plugin_dir / ".claude-plugin" / "plugin.json" + for path in (manifest_path, plugin_json_path): + if path.is_symlink() or not path.is_file(): + raise VerificationError(f"missing or unsafe plugin file: {path}") + plugin_manifest = _json_bytes(manifest_path.read_bytes(), "plugin manifest") + if plugin_manifest != manifest: + raise VerificationError("plugin and Python content manifests differ") + plugin_json = _json_bytes(plugin_json_path.read_bytes(), "plugin.json") + + records = _validate_manifest_shape(plugin_manifest, version) + expected = {record["name"] for record in records} + skills_root = plugin_dir / "skills" + expected_paths = { + ".claude-plugin/plugin.json", + ".skilldeck/content-manifest.json", + *(f"skills/{name}/SKILL.md" for name in expected), + } + actual_paths = { + path.relative_to(plugin_dir).as_posix() + for path in plugin_dir.rglob("*") + if path.is_file() and not path.is_symlink() + } + if actual_paths != expected_paths: + raise VerificationError("plugin file set does not match generated contract") + if any( + path.is_symlink() or not (path.is_file() or path.is_dir()) + for path in plugin_dir.rglob("*") + ): + raise VerificationError("plugin contains unsafe filesystem entry") + actual = {path.parent.name for path in skills_root.glob("*/SKILL.md")} + if actual != expected: + raise VerificationError("plugin skill set does not match content manifest") + derived: list[Skill] = [] + for record in records: + path = skills_root / record["name"] / "SKILL.md" + if path.is_symlink() or not path.is_file(): + raise VerificationError(f"missing or unsafe plugin skill: {record['name']}") + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise VerificationError( + f"non-UTF-8 plugin skill: {record['name']}" + ) from exc + if sha256_text(text) != record["claude_rendered_sha256"]: + raise VerificationError( + f"plugin rendering does not match manifest: {record['name']}" + ) + source = canonical_skills.get(record["name"]) + if source is None or set(source) != {"meta.yaml", "skill.md"}: + raise VerificationError(f"canonical skill unavailable: {record['name']}") + try: + meta = yaml.safe_load(source["meta.yaml"]) + except yaml.YAMLError as exc: + raise VerificationError( + f"invalid canonical metadata: {record['name']}" + ) from exc + agents = meta.get("supported-agents") if isinstance(meta, dict) else None + if ( + not isinstance(meta, dict) + or meta.get("name") != record["name"] + or not isinstance(agents, list) + or not all(isinstance(agent, str) for agent in agents) + ): + raise VerificationError(f"invalid canonical metadata: {record['name']}") + skill = Skill( + name=record["name"], + description=str(meta.get("description")), + category=str(meta.get("category")), + version=record["version"], + supported_agents=tuple(agents), + body=source["skill.md"], + path=Path(record["name"]), + ) + rendered_digest = sha256_text(ADAPTERS["claude"].render(skill)) + if rendered_digest != record["claude_rendered_sha256"]: + raise VerificationError( + "plugin rendering is not derived from canonical skill: " + f"{record['name']}" + ) + derived.append(skill) + if plugin_json != claude_plugin_metadata(version, derived): + raise VerificationError("plugin metadata does not match generated contract") + + +def verify( + wheel: Path, + sdist: Path, + plugin_dir: Path, + version: str, + source_ref: str, + source_commit: str, +) -> None: + wheel_files = read_zip(wheel) + wheel_manifest = validate_distribution( + wheel_files, + marker="skilldeck", + version=version, + source_ref=source_ref, + source_commit=source_commit, + ) + sdist_manifest = validate_distribution( + read_tar(sdist), + marker="src/skilldeck", + version=version, + source_ref=source_ref, + source_commit=source_commit, + ) + if wheel_manifest != sdist_manifest: + raise VerificationError("wheel and sdist content manifests differ") + validate_plugin( + plugin_dir, + wheel_manifest, + _distribution_skills(wheel_files, "skilldeck"), + version, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, required=True) + parser.add_argument("--sdist", type=Path, required=True) + parser.add_argument("--plugin-dir", type=Path, required=True) + parser.add_argument("--expected-version", required=True) + parser.add_argument("--expected-ref", required=True) + parser.add_argument("--expected-commit", required=True) + args = parser.parse_args() + try: + verify( + args.wheel, + args.sdist, + args.plugin_dir, + args.expected_version, + args.expected_ref, + args.expected_commit, + ) + except (OSError, VerificationError, tarfile.TarError, zipfile.BadZipFile) as exc: + parser.error(str(exc)) + print( + f"ok: wheel, sdist, and plugin match {args.expected_ref} " + f"at {args.expected_commit}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_sbom.py b/scripts/verify_sbom.py new file mode 100644 index 0000000..455b5b8 --- /dev/null +++ b/scripts/verify_sbom.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Validate that a release SPDX SBOM covers runtime, not development, packages.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +MAX_SBOM_BYTES = 16 * 1024 * 1024 +REQUIRED_PACKAGES = {"skilldeck", "click", "pyyaml"} +FORBIDDEN_PACKAGES = {"pytest", "ruff", "mypy"} + + +class SbomError(ValueError): + """The generated SBOM does not describe the release runtime.""" + + +def verify(path: Path) -> None: + if path.is_symlink() or not path.is_file(): + raise SbomError(f"SBOM is not a regular file: {path}") + if path.stat().st_size > MAX_SBOM_BYTES: + raise SbomError("SBOM exceeds GitHub attestation size limit") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SbomError("SBOM is not valid UTF-8 JSON") from exc + if not isinstance(data, dict) or data.get("spdxVersion") != "SPDX-2.3": + raise SbomError("SBOM must use SPDX-2.3") + packages = data.get("packages") + if not isinstance(packages, list): + raise SbomError("SBOM has no package list") + names: set[str] = set() + identifiers: set[str] = set() + for package in packages: + if not isinstance(package, dict): + raise SbomError("SBOM package entry is not an object") + name = package.get("name") + identifier = package.get("SPDXID") + if not isinstance(name, str) or not isinstance(identifier, str): + raise SbomError("SBOM package is missing name or SPDXID") + if identifier in identifiers: + raise SbomError(f"duplicate SBOM package identifier: {identifier}") + names.add(name.casefold()) + identifiers.add(identifier) + missing = sorted(REQUIRED_PACKAGES - names) + if missing: + raise SbomError(f"SBOM is missing runtime package(s): {', '.join(missing)}") + forbidden = sorted(FORBIDDEN_PACKAGES & names) + if forbidden: + raise SbomError(f"SBOM contains development package(s): {', '.join(forbidden)}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("sbom", type=Path) + args = parser.parse_args() + try: + verify(args.sbom) + except (OSError, SbomError) as exc: + parser.error(str(exc)) + print(f"ok: {args.sbom} is an SPDX-2.3 runtime SBOM") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write_checksums.py b/scripts/write_checksums.py new file mode 100644 index 0000000..00b7795 --- /dev/null +++ b/scripts/write_checksums.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Write or verify the exact standard checksum set for release artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import os +import re +from pathlib import Path + +CHECKSUMS_NAME = "SHA256SUMS" +_LINE_RE = re.compile(r"^([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._+-]*)$") + + +class ChecksumError(ValueError): + """The release checksum set is unsafe or incomplete.""" + + +def _exact_one(paths: list[Path], label: str) -> Path: + if len(paths) != 1: + raise ChecksumError(f"expected exactly one {label}, found {len(paths)}") + return paths[0] + + +def release_assets(directory: Path) -> list[Path]: + if not directory.is_dir(): + raise ChecksumError(f"release directory not found: {directory}") + wheel = _exact_one(sorted(directory.glob("*.whl")), "wheel") + sdist = _exact_one(sorted(directory.glob("*.tar.gz")), "source distribution") + sbom = _exact_one(sorted(directory.glob("*.spdx.json")), "SPDX SBOM") + expected = sorted((wheel, sdist, sbom), key=lambda path: path.name) + allowed = {path.name for path in expected} | {CHECKSUMS_NAME} + extras = sorted( + path.name for path in directory.iterdir() if path.name not in allowed + ) + if extras: + raise ChecksumError(f"unexpected release artifact(s): {', '.join(extras)}") + for path in expected: + if path.is_symlink() or not path.is_file(): + raise ChecksumError(f"release artifact is not a regular file: {path.name}") + return expected + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def write(directory: Path) -> Path: + assets = release_assets(directory) + output = directory / CHECKSUMS_NAME + if output.is_symlink(): + raise ChecksumError(f"refusing to replace symlink: {output}") + text = "".join(f"{sha256_file(path)} {path.name}\n" for path in assets) + temporary = directory / f".{CHECKSUMS_NAME}.tmp" + if temporary.exists() or temporary.is_symlink(): + raise ChecksumError(f"temporary checksum path already exists: {temporary}") + try: + temporary.write_text(text, encoding="ascii") + os.replace(temporary, output) + finally: + if temporary.exists() and not temporary.is_symlink(): + temporary.unlink() + return output + + +def verify(checksums: Path) -> None: + if checksums.is_symlink() or not checksums.is_file(): + raise ChecksumError(f"checksum file is not a regular file: {checksums}") + directory = checksums.parent + expected = {path.name: path for path in release_assets(directory)} + try: + lines = checksums.read_text(encoding="ascii").splitlines() + except UnicodeDecodeError as exc: + raise ChecksumError("checksum file must be ASCII") from exc + found: dict[str, str] = {} + for line in lines: + match = _LINE_RE.fullmatch(line) + if not match: + raise ChecksumError(f"malformed checksum line: {line!r}") + digest, name = match.groups() + if name in found: + raise ChecksumError(f"duplicate checksum entry: {name}") + found[name] = digest + if set(found) != set(expected): + raise ChecksumError( + "checksum entries do not match the exact release artifact set" + ) + for name, expected_digest in found.items(): + actual = sha256_file(expected[name]) + if not hmac.compare_digest(actual, expected_digest): + raise ChecksumError(f"checksum mismatch: {name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=Path, help="release directory or SHA256SUMS file") + parser.add_argument("--verify", action="store_true") + args = parser.parse_args() + try: + if args.verify: + verify(args.path) + print(f"ok: verified exact release checksum set in {args.path.parent}") + else: + output = write(args.path) + print(f"wrote {output}") + except (OSError, ChecksumError) as exc: + parser.error(str(exc)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/skilldeck/_build_metadata.json b/src/skilldeck/_build_metadata.json new file mode 100644 index 0000000..d614afc --- /dev/null +++ b/src/skilldeck/_build_metadata.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "source_commit": null, + "source_ref": null, + "source_repository": "https://github.com/IcebergAI/skilldeck" +} diff --git a/src/skilldeck/_content_manifest.json b/src/skilldeck/_content_manifest.json new file mode 100644 index 0000000..e3775bb --- /dev/null +++ b/src/skilldeck/_content_manifest.json @@ -0,0 +1,86 @@ +{ + "package_version": "0.3.0", + "schema_version": 1, + "skills": [ + { + "body_sha256": "sha256:a7ad8a0b2d441b65941db1c33ede8b5c79a10c8a2d4953e726b451421825535f", + "canonical_sha256": "sha256:959b8fdb94da830415e760084c484932bbf951815c944e80d4e016a3fd8835ee", + "claude_rendered_sha256": "sha256:5ff974113d642db2b6870369ec49692c0838b76cc677912d75f40d058c1d180b", + "meta_sha256": "sha256:4adf117b42241e18cf31a5add5fc081c2014d7769c5c9b2136b0bbfddc6ad024", + "name": "authentication-review", + "version": "0.1.0" + }, + { + "body_sha256": "sha256:e6d57912d945ae733a2dbfe4349e395ef42555eec1fe0b5912482d8169796a20", + "canonical_sha256": "sha256:dc4b75a427098e1ae692339d44b767f9b2883dd8653f2d1382dd469584dac1f7", + "claude_rendered_sha256": "sha256:7ed6934ffb5d5b03fe3aaabe5892d9c36bd8373ca36a63f8b3820d53261c1853", + "meta_sha256": "sha256:9646bc2a752745b5b9a872b778371afb259541f6a416c8c759c3ddf8aee8c69a", + "name": "ci-workflow-review", + "version": "0.2.0" + }, + { + "body_sha256": "sha256:1a40a16b2afc5fae7940a6d1b2e4b218c46171d74916683c6651582b92f46387", + "canonical_sha256": "sha256:604df1564c70febf7d147218a4325e46e3184df87f612880abfea67ab7880881", + "claude_rendered_sha256": "sha256:fe580e51da98d73ec079af315d95334f6a332b2b1342eb23427b40d2b2543ada", + "meta_sha256": "sha256:9b1ccd5172280cfcec8430801239d44e5a4955f7d4a8aad7ff25e8253303aad1", + "name": "code-smells", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:5f5e00ff451b7589751b00062573d781942dd10610a590e9fba89db3417b994d", + "canonical_sha256": "sha256:b5c2fa47d57e9d1265d909c4b96438839f35f39b1543245be8ede2b69b24fdeb", + "claude_rendered_sha256": "sha256:68ac07cdd373871e1ddbd3f675b01229f1feddea37f7c59c91da6869a469fc9e", + "meta_sha256": "sha256:0b19ff954bb43cea8a6cefe329a7b4687ec255100ce98fb95181d2141abf2732", + "name": "dependency-review", + "version": "0.2.2" + }, + { + "body_sha256": "sha256:18bf52096e596422434d0536db59cf3a06ee75f264e2951925a251fd1b6def64", + "canonical_sha256": "sha256:8e2cf5f056673cf89173d1f564090d448c30d61d81c3e6c53dc70f09db9a2c99", + "claude_rendered_sha256": "sha256:4bcde77df9bc3aa34d18c16872e88fff3d5b70ce7237ed16411959c88a51f183", + "meta_sha256": "sha256:b08a37404286edf6f8426bb35023d13f95dced3f62d03edf638de3ebf25ec90b", + "name": "iac-review", + "version": "0.1.0" + }, + { + "body_sha256": "sha256:c1dc5d41396e4757f4d83f1bafeee57b5faae2ec1dfd6cafd1424e016e2ef168", + "canonical_sha256": "sha256:b3bdb8524872d1ea6c5a3e613bb846a3255999f5aafcf41b6c5dbb2b589a228e", + "claude_rendered_sha256": "sha256:dbda296679e4ab283b60319d69be37f6bde7153b1743f0f72c2cbfb024580519", + "meta_sha256": "sha256:792a7147a855a1d0723e625e00f800ebe1a543370a9ba93e6d9b5ae279f059a7", + "name": "logging", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:da723ff2f114ec22ca316c25323272398349c39d07ebb303d7c96cbc74ad6c5d", + "canonical_sha256": "sha256:c3feb88053c1ea2c49775f4926c55362d8a1e102e722e0918ed5672dbf89ea7c", + "claude_rendered_sha256": "sha256:4d2c8be475467890a06380836239ef40bdb15c87d7f466aa33c36593ce7821d5", + "meta_sha256": "sha256:b734d846c2b777e43beaa06a4660f30c8814edbaf44c3635d8a52847cc64bbe8", + "name": "migration-review", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:d4f858b08536c4a7a011e28bcd099f44c044981bc2bd2b1c72bff28df715e03b", + "canonical_sha256": "sha256:b17a68d643eb66ba7a279b14a0e3e419850ea30c0a5676346b2725635ccd3836", + "claude_rendered_sha256": "sha256:204996856a605b57cf5424473ba7939d8ea10e00170bc207508ac4e4a81d9787", + "meta_sha256": "sha256:d7a52f983ab69b6bbb13631e7f5062bfbfbb24d78e727c760c2a3d7b01613fae", + "name": "resilience-review", + "version": "0.2.1" + }, + { + "body_sha256": "sha256:e28cb0a29f2f2827d9d221a79c4b3bcb5459b45628521628604111bef8addf76", + "canonical_sha256": "sha256:f5de69e8353cac96cd2d347f795626745cfb955c234191aabf6c29cd6251be1e", + "claude_rendered_sha256": "sha256:ae6d042864de193ab3a220d4929b902a69fc24ea7ec983968d1bc864b20d245f", + "meta_sha256": "sha256:2b91647dcfe0fe755be71d17d0824b0d9bc7eaab9ffdc542c74e5889aee60e15", + "name": "security-review", + "version": "0.3.2" + }, + { + "body_sha256": "sha256:9f7a3b0643d9f0bab1cd18171e6e1e96b5e5b1317037fcd0c41f678fb1bf6f84", + "canonical_sha256": "sha256:c602673b548a095676aad417b396e7749c2f9cd28717d04ea2333a1a70fca5cc", + "claude_rendered_sha256": "sha256:08ef05a1c1dd331dde481b237ade94df7825885f173aaa22f3f0f0c0106455a1", + "meta_sha256": "sha256:b873ae05416991b7eef527cd0897ef8d4517e4dea29f33add321ee20473f2c33", + "name": "test-review", + "version": "0.2.1" + } + ] +} diff --git a/src/skilldeck/cli.py b/src/skilldeck/cli.py index ccbde9a..33db479 100644 --- a/src/skilldeck/cli.py +++ b/src/skilldeck/cli.py @@ -2,11 +2,13 @@ from __future__ import annotations +import json from itertools import groupby import click from .adapters import ADAPTERS, InstallState +from .provenance import distribution_provenance from .registry import Skill, SkillError, discover_skills from .stamp import parse as parse_stamp from .targets import Scope @@ -162,6 +164,34 @@ def show(name: str, agent: str | None) -> None: click.echo(text if text.endswith("\n") else text + "\n", nl=False) +@cli.command() +@click.option( + "--json", + "as_json", + is_flag=True, + help="Emit deterministic machine-readable provenance metadata.", +) +def provenance(as_json: bool) -> None: + """Show the package source and bundled skill identities.""" + data = distribution_provenance() + if as_json: + click.echo(json.dumps(data, indent=2, sort_keys=True)) + return + + package = data["distribution"] + click.echo(f"{package['name']} {package['version']}") + click.echo(f"repository: {package['source_repository']}") + click.echo(f"source ref: {package['source_ref'] or 'unavailable'}") + click.echo(f"source commit: {package['source_commit'] or 'unavailable'}") + click.echo("bundled skills:") + width = max(len(skill["name"]) for skill in data["skills"]) + for skill in data["skills"]: + click.echo( + f" {skill['name']:<{width}} {skill['version']} " + f"{skill['canonical_sha256']}" + ) + + @cli.command() @click.option("--agent", required=True, type=AGENT_CHOICE, help="Target agent.") @click.option( diff --git a/src/skilldeck/provenance.py b/src/skilldeck/provenance.py new file mode 100644 index 0000000..1452582 --- /dev/null +++ b/src/skilldeck/provenance.py @@ -0,0 +1,218 @@ +"""Release and bundled-skill provenance metadata. + +The structures here deliberately answer only distribution-identity questions. +The richer, compatibility-aware public catalog is a separate product contract. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Iterable +from importlib.resources import files +from typing import TypedDict + +from . import __version__ +from .adapters import ADAPTERS +from .registry import Skill, discover_skills + +SCHEMA_VERSION = 1 +PACKAGE_NAME = "skilldeck" +REPOSITORY_URL = "https://github.com/IcebergAI/skilldeck" +PLUGIN_NAME = "skilldeck" +_CONTENT_MANIFEST = "_content_manifest.json" +_BUILD_METADATA = "_build_metadata.json" +_CANONICAL_DOMAIN = b"skilldeck-canonical-skill-v1\0" +_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") + + +class ContentSkill(TypedDict): + name: str + version: str + canonical_sha256: str + meta_sha256: str + body_sha256: str + claude_rendered_sha256: str + + +class ContentManifest(TypedDict): + schema_version: int + package_version: str + skills: list[ContentSkill] + + +class BuildMetadata(TypedDict): + schema_version: int + source_repository: str + source_ref: str | None + source_commit: str | None + + +class DistributionSkill(TypedDict): + name: str + version: str + canonical_sha256: str + + +class Distribution(TypedDict): + name: str + version: str + source_repository: str + source_ref: str | None + source_commit: str | None + + +class DistributionProvenance(TypedDict): + schema_version: int + distribution: Distribution + skills: list[DistributionSkill] + + +def normalise_text(text: str) -> str: + """Use one cross-platform newline representation before hashing text.""" + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def sha256_text(text: str) -> str: + digest = hashlib.sha256(normalise_text(text).encode("utf-8")).hexdigest() + return f"sha256:{digest}" + + +def canonical_skill_digest(meta_text: str, body_text: str) -> str: + """Hash unambiguous, domain-separated canonical metadata and body bytes.""" + digest = hashlib.sha256() + digest.update(_CANONICAL_DOMAIN) + for label, text in ((b"meta.yaml", meta_text), (b"skill.md", body_text)): + payload = normalise_text(text).encode("utf-8") + digest.update(len(label).to_bytes(4, "big")) + digest.update(label) + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + return f"sha256:{digest.hexdigest()}" + + +def content_manifest( + package_version: str, + skills: Iterable[Skill] | None = None, +) -> ContentManifest: + """Build the deterministic identity shared by Python and plugin outputs.""" + selected = list(skills or discover_skills(known_agents=set(ADAPTERS))) + selected.sort(key=lambda skill: skill.name) + names = [skill.name for skill in selected] + if len(names) != len(set(names)): + raise ValueError("duplicate skill name in content manifest") + + claude = ADAPTERS["claude"] + records: list[ContentSkill] = [] + for skill in selected: + meta_text = (skill.path / "meta.yaml").read_text(encoding="utf-8") + records.append( + { + "name": skill.name, + "version": skill.version, + "canonical_sha256": canonical_skill_digest(meta_text, skill.body), + "meta_sha256": sha256_text(meta_text), + "body_sha256": sha256_text(skill.body), + "claude_rendered_sha256": sha256_text(claude.render(skill)), + } + ) + return { + "schema_version": SCHEMA_VERSION, + "package_version": package_version, + "skills": records, + } + + +def claude_plugin_metadata( + package_version: str, skills: Iterable[Skill] +) -> dict[str, object]: + """Return the exact generated Claude plugin metadata contract.""" + selected = sorted(skills, key=lambda skill: skill.name) + description = "Security and code-review skills for Claude Code: " + ", ".join( + skill.name for skill in selected + ) + return { + "name": PLUGIN_NAME, + "version": package_version, + "description": description, + "author": {"name": "Richard Hope", "url": REPOSITORY_URL}, + "homepage": REPOSITORY_URL, + "repository": REPOSITORY_URL, + "license": "MIT", + "keywords": ["security", "code-review", "skills"], + } + + +def canonical_json(data: object) -> str: + """Serialize generated provenance deterministically with one final newline.""" + return json.dumps(data, indent=2, sort_keys=True) + "\n" + + +def _load_json_resource(name: str) -> object: + try: + return json.loads(files("skilldeck").joinpath(name).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid packaged provenance resource: {name}") from exc + + +def load_content_manifest() -> ContentManifest: + data = _load_json_resource(_CONTENT_MANIFEST) + if not isinstance(data, dict) or data.get("schema_version") != SCHEMA_VERSION: + raise ValueError("unsupported packaged content manifest") + if data.get("package_version") != __version__: + raise ValueError("content manifest version does not match installed package") + skills = data.get("skills") + if not isinstance(skills, list) or not skills: + raise ValueError("packaged content manifest has no skills") + names = [record.get("name") for record in skills if isinstance(record, dict)] + if len(names) != len(skills) or len(names) != len(set(names)): + raise ValueError("packaged content manifest has invalid skill identities") + return data # type: ignore[return-value] + + +def load_build_metadata() -> BuildMetadata: + data = _load_json_resource(_BUILD_METADATA) + if not isinstance(data, dict) or data.get("schema_version") != SCHEMA_VERSION: + raise ValueError("unsupported packaged build metadata") + if data.get("source_repository") != REPOSITORY_URL: + raise ValueError("unexpected source repository in build metadata") + source_ref = data.get("source_ref") + source_commit = data.get("source_commit") + if source_ref is None or source_commit is None: + if source_ref is not None or source_commit is not None: + raise ValueError( + "source ref and commit must both be available or unavailable" + ) + else: + if source_ref != f"refs/tags/v{__version__}": + raise ValueError("source ref does not match installed package version") + if not isinstance(source_commit, str) or not _COMMIT_RE.fullmatch( + source_commit + ): + raise ValueError("invalid source commit in build metadata") + return data # type: ignore[return-value] + + +def distribution_provenance() -> DistributionProvenance: + """Return the narrow installed-distribution identity exposed by the CLI.""" + content = load_content_manifest() + build = load_build_metadata() + return { + "schema_version": SCHEMA_VERSION, + "distribution": { + "name": PACKAGE_NAME, + "version": __version__, + "source_repository": build["source_repository"], + "source_ref": build["source_ref"], + "source_commit": build["source_commit"], + }, + "skills": [ + { + "name": record["name"], + "version": record["version"], + "canonical_sha256": record["canonical_sha256"], + } + for record in content["skills"] + ], + } diff --git a/tests/test_build_metadata.py b/tests/test_build_metadata.py new file mode 100644 index 0000000..d78973e --- /dev/null +++ b/tests/test_build_metadata.py @@ -0,0 +1,60 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parent.parent +_SCRIPT = _ROOT / "scripts" / "stamp_build_metadata.py" +_spec = importlib.util.spec_from_file_location("stamp_build_metadata", _SCRIPT) +assert _spec and _spec.loader +stamp_build_metadata = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(stamp_build_metadata) + + +def _root(tmp_path: Path, version: str = "1.2.3") -> Path: + (tmp_path / "src" / "skilldeck").mkdir(parents=True) + (tmp_path / "pyproject.toml").write_text( + f'[project]\nname = "skilldeck"\nversion = "{version}"\n' + ) + (tmp_path / "src" / "skilldeck" / "_build_metadata.json").write_text("{}\n") + return tmp_path + + +def test_stamp_build_metadata_requires_exact_tag_and_full_commit(tmp_path): + root = _root(tmp_path) + commit = "a" * 40 + stamp_build_metadata.stamp(root, "refs/tags/v1.2.3", commit) + data = json.loads(stamp_build_metadata.metadata_path(root).read_text()) + assert data == { + "schema_version": 1, + "source_commit": commit, + "source_ref": "refs/tags/v1.2.3", + "source_repository": "https://github.com/IcebergAI/skilldeck", + } + stamp_build_metadata.stamp(root, "refs/tags/v1.2.3", commit, check=True) + + +@pytest.mark.parametrize( + ("source_ref", "commit"), + [ + ("refs/heads/main", "a" * 40), + ("refs/tags/v1.2.4", "a" * 40), + ("refs/tags/v1.2.3", "a" * 39), + ("refs/tags/v1.2.3", "A" * 40), + ("refs/tags/v1.2.3", "g" * 40), + ], +) +def test_stamp_build_metadata_rejects_ambiguous_source(tmp_path, source_ref, commit): + root = _root(tmp_path) + with pytest.raises(ValueError): + stamp_build_metadata.stamp(root, source_ref, commit) + + +def test_stamp_build_metadata_check_does_not_rewrite(tmp_path): + root = _root(tmp_path) + path = stamp_build_metadata.metadata_path(root) + before = path.read_bytes() + with pytest.raises(ValueError): + stamp_build_metadata.stamp(root, "refs/tags/v1.2.3", "b" * 40, check=True) + assert path.read_bytes() == before diff --git a/tests/test_cli.py b/tests/test_cli.py index d32540f..e30c4f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import json import sys from pathlib import Path @@ -139,6 +140,36 @@ def test_show_unknown_skill_fails(): assert isinstance(result.exception, Exception) +def test_provenance_reports_package_source_and_bundled_versions(): + result = CliRunner().invoke(cli, ["provenance", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["schema_version"] == 1 + assert data["distribution"]["name"] == "skilldeck" + assert data["distribution"]["source_repository"] == ( + "https://github.com/IcebergAI/skilldeck" + ) + assert data["distribution"]["source_ref"] is None + assert data["distribution"]["source_commit"] is None + expected = {skill.name: skill.version for skill in discover_skills()} + actual = {skill["name"]: skill["version"] for skill in data["skills"]} + assert actual == expected + assert all( + skill["canonical_sha256"].startswith("sha256:") + and len(skill["canonical_sha256"]) == 71 + for skill in data["skills"] + ) + + +def test_provenance_human_output_is_readable(): + result = CliRunner().invoke(cli, ["provenance"]) + assert result.exit_code == 0, result.output + assert "repository: https://github.com/IcebergAI/skilldeck" in result.output + assert "source ref: unavailable" in result.output + assert "security-review" in result.output + assert "sha256:" in result.output + + def test_install_over_modified_file_fails_without_force(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) runner = CliRunner() diff --git a/tests/test_plugin_build.py b/tests/test_plugin_build.py index 7c04579..827c672 100644 --- a/tests/test_plugin_build.py +++ b/tests/test_plugin_build.py @@ -52,3 +52,22 @@ def test_plugin_skills_match_bundled_skills(): p.parent.name for p in (_ROOT / "claude-plugin" / "skills").glob("*/SKILL.md") } assert committed == bundled + + +def test_plugin_provenance_matches_python_distribution(): + from skilldeck.provenance import canonical_json, content_manifest + from skilldeck.registry import discover_skills + + plugin_provenance = json.loads( + (_ROOT / "claude-plugin" / ".skilldeck" / "content-manifest.json").read_text() + ) + package_text = (_ROOT / "src" / "skilldeck" / "_content_manifest.json").read_text() + assert package_text == canonical_json(plugin_provenance) + assert plugin_provenance == content_manifest(build_plugin.project_version()) + + by_name = {skill.name: skill for skill in discover_skills()} + for record in plugin_provenance["skills"]: + rendered = ( + _ROOT / "claude-plugin" / "skills" / record["name"] / "SKILL.md" + ).read_text() + assert rendered.endswith(by_name[record["name"]].body) diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..4e31520 --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,65 @@ +from pathlib import Path + +import pytest + +from skilldeck.provenance import ( + canonical_skill_digest, + content_manifest, + normalise_text, + sha256_text, +) +from skilldeck.registry import Skill + + +def _skill(tmp_path: Path, name: str) -> Skill: + root = tmp_path / name + root.mkdir() + (root / "meta.yaml").write_text( + f"name: {name}\ndescription: Example\ncategory: review\n" + "version: 1.2.3\nsupported-agents:\n - claude\n" + ) + body = f"# {name}\n\nReview carefully.\n" + (root / "skill.md").write_text(body) + return Skill( + name=name, + description="Example", + category="review", + version="1.2.3", + supported_agents=("claude",), + body=body, + path=root, + ) + + +def test_provenance_hashes_normalise_newlines(): + assert normalise_text("a\r\nb\rc\n") == "a\nb\nc\n" + assert sha256_text("a\r\nb\r") == sha256_text("a\nb\n") + assert canonical_skill_digest("name: x\r\n", "body\r\n") == ( + canonical_skill_digest("name: x\n", "body\n") + ) + + +def test_canonical_digest_is_domain_separated_and_sensitive(): + baseline = canonical_skill_digest("ab", "c") + assert baseline != canonical_skill_digest("a", "bc") + assert baseline != canonical_skill_digest("ab ", "c") + assert baseline != canonical_skill_digest("ab", "c ") + assert baseline.startswith("sha256:") + + +def test_content_manifest_is_sorted_and_deterministic(tmp_path): + second = _skill(tmp_path, "zeta") + first = _skill(tmp_path, "alpha") + left = content_manifest("9.8.7", [second, first]) + right = content_manifest("9.8.7", [first, second]) + assert left == right + assert [record["name"] for record in left["skills"]] == ["alpha", "zeta"] + assert all( + record["canonical_sha256"].startswith("sha256:") for record in left["skills"] + ) + + +def test_content_manifest_rejects_duplicate_skill_names(tmp_path): + skill = _skill(tmp_path, "same") + with pytest.raises(ValueError, match="duplicate skill"): + content_manifest("1.0.0", [skill, skill]) diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 0000000..4c827e0 --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,174 @@ +import importlib.util +import io +import json +import tarfile +import zipfile +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parent.parent + + +def _script(name: str): + path = _ROOT / "scripts" / name + spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +checksums = _script("write_checksums.py") +identity = _script("verify_distribution_identity.py") +sbom = _script("verify_sbom.py") + + +def _release_dir(tmp_path: Path) -> Path: + (tmp_path / "skilldeck-1.2.3-py3-none-any.whl").write_bytes(b"wheel") + (tmp_path / "skilldeck-1.2.3.tar.gz").write_bytes(b"sdist") + (tmp_path / "skilldeck-1.2.3.spdx.json").write_text("{}\n") + return tmp_path + + +def test_checksum_round_trip_and_tamper_failure(tmp_path): + directory = _release_dir(tmp_path) + output = checksums.write(directory) + checksums.verify(output) + wheel = directory / "skilldeck-1.2.3-py3-none-any.whl" + wheel.write_bytes(wheel.read_bytes() + b"tampered") + with pytest.raises(checksums.ChecksumError, match="checksum mismatch"): + checksums.verify(output) + + +def test_checksum_set_rejects_missing_extra_and_malformed_entries(tmp_path): + directory = _release_dir(tmp_path) + output = checksums.write(directory) + (directory / "unexpected.txt").write_text("no") + with pytest.raises(checksums.ChecksumError, match="unexpected"): + checksums.verify(output) + (directory / "unexpected.txt").unlink() + output.write_text("not a checksum\n") + with pytest.raises(checksums.ChecksumError, match="malformed"): + checksums.verify(output) + + +def test_checksum_set_rejects_symlinked_artifact(tmp_path): + directory = _release_dir(tmp_path) + wheel = directory / "skilldeck-1.2.3-py3-none-any.whl" + wheel.unlink() + wheel.symlink_to(directory / "skilldeck-1.2.3.tar.gz") + with pytest.raises(checksums.ChecksumError, match="regular file"): + checksums.write(directory) + + +def test_archive_reader_rejects_traversal_and_links(tmp_path): + unsafe_zip = tmp_path / "unsafe.whl" + with zipfile.ZipFile(unsafe_zip, "w") as archive: + archive.writestr("../escape", b"bad") + with pytest.raises(identity.VerificationError, match="unsafe"): + identity.read_zip(unsafe_zip) + + unsafe_tar = tmp_path / "unsafe.tar.gz" + with tarfile.open(unsafe_tar, "w:gz") as archive: + link = tarfile.TarInfo("safe-link") + link.type = tarfile.SYMTYPE + link.linkname = "/tmp/target" + archive.addfile(link) + with pytest.raises(identity.VerificationError, match="non-regular"): + identity.read_tar(unsafe_tar) + + +def test_archive_reader_rejects_duplicate_member(tmp_path): + duplicate = tmp_path / "duplicate.whl" + with ( + pytest.warns(UserWarning, match="Duplicate name"), + zipfile.ZipFile(duplicate, "w") as archive, + ): + archive.writestr("same", b"one") + archive.writestr("same", b"two") + with pytest.raises(identity.VerificationError, match="duplicate"): + identity.read_zip(duplicate) + + +def test_archive_reader_accepts_regular_files(tmp_path): + archive_path = tmp_path / "safe.tar.gz" + payload = b"content" + with tarfile.open(archive_path, "w:gz") as archive: + member = tarfile.TarInfo("root/file.txt") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + assert identity.read_tar(archive_path) == {"root/file.txt": payload} + + +@pytest.mark.parametrize( + "suffix, writer, reader", + [ + ("whl", zipfile.ZipFile, identity.read_zip), + ("tar.gz", tarfile.open, identity.read_tar), + ], +) +def test_archive_reader_rejects_excessive_member_count( + tmp_path, suffix, writer, reader +): + path = tmp_path / f"many.{suffix}" + if suffix == "whl": + with writer(path, "w") as archive: + for index in range(identity.MAX_ARCHIVE_MEMBERS + 1): + archive.writestr(f"root/{index}", b"x") + else: + with writer(path, "w:gz") as archive: + for index in range(identity.MAX_ARCHIVE_MEMBERS + 1): + member = tarfile.TarInfo(f"root/{index}") + member.size = 1 + archive.addfile(member, io.BytesIO(b"x")) + with pytest.raises(identity.VerificationError, match="too many"): + reader(path) + + +def _sbom(tmp_path: Path, names: list[str]) -> Path: + path = tmp_path / "release.spdx.json" + path.write_text( + json.dumps( + { + "spdxVersion": "SPDX-2.3", + "packages": [ + {"name": name, "SPDXID": f"SPDXRef-{index}"} + for index, name in enumerate(names) + ], + } + ) + ) + return path + + +def test_sbom_requires_runtime_and_excludes_development_packages(tmp_path): + valid = _sbom(tmp_path, ["skilldeck", "click", "PyYAML"]) + sbom.verify(valid) + valid.unlink() + + missing = _sbom(tmp_path, ["skilldeck", "click"]) + with pytest.raises(sbom.SbomError, match="missing runtime"): + sbom.verify(missing) + missing.unlink() + + polluted = _sbom(tmp_path, ["skilldeck", "click", "PyYAML", "pytest"]) + with pytest.raises(sbom.SbomError, match="development"): + sbom.verify(polluted) + + +def test_verifiers_reject_symlink_inputs(tmp_path): + directory = tmp_path / "bundle" + directory.mkdir() + directory = _release_dir(directory) + checksums_path = checksums.write(directory) + checksum_link = tmp_path / "SHA256SUMS" + checksum_link.symlink_to(checksums_path) + with pytest.raises(checksums.ChecksumError, match="regular file"): + checksums.verify(checksum_link) + + valid = _sbom(tmp_path, ["skilldeck", "click", "PyYAML"]) + sbom_link = tmp_path / "linked.spdx.json" + sbom_link.symlink_to(valid) + with pytest.raises(sbom.SbomError, match="regular file"): + sbom.verify(sbom_link)