Skip to content

release security: bind the qualified wheel to the tagged source tree #342

Description

@pengfei-threemoonslab

Summary

The wheel published to PyPI is never bound to the tagged source tree. The release job does not build what it ships — it downloads a pre-built wheel from a repository variable URL and publishes that file.

Evidence

.github/workflows/release.yml establishes three bindings:

  1. tag ↔ pyproject.toml version — the "Verify tag matches package version" step compares GITHUB_REF_NAME against the checkout's version.
  2. qualification ↔ wheel bytesscripts/run_safety_qualification.py::inspect_wheel records the wheel's sha256, and the sigstore bundle signs the qualification JSON containing it.
  3. tag ↔ wheel version stringverify_safety_qualification_release.py:70: tag == f"v{wheel_version}", where wheel_version comes from the wheel's own METADATA.

What no step establishes is tagged commit ↔ wheel contents. inspect_wheel reads only Name, Version, and a digest:

name = str(metadata.get("Name", "")).strip()
version = str(metadata.get("Version", "")).strip()
...
return canonical_name, version, sha256_file(path)

Any wheel that declares Name: agents-shipgate and Version: 0.16.0 satisfies every check, regardless of what source produced it.

Meanwhile the "Lint and test" step runs ruff, compileall, and pytest against the checkout, while "Publish to PyPI" ships the downloaded wheel. The pipeline therefore tests one artifact and publishes a different one, with nothing asserting they correspond.

Impact

  • The trusted-publishing identity vouches for an artifact of unverified provenance.
  • A compromised or stale SAFETY_QUALIFICATION_WHEEL_URL publishes whatever it points at, as long as the metadata matches.
  • The green test run on the release commit is not evidence about the shipped bytes.
  • For a project whose entire thesis is deterministic, content-addressed verification, this is the one place the chain is not content-addressed back to source.

To be clear about severity bounds: the pypi environment is protected and the URLs are repository variables, so this requires maintainer-level access or a compromise of the hosting location to exploit. It is a missing control, not an open door.

Proposed fix

Build the wheel in-job from the tagged checkout and require byte equality with the qualified wheel:

  1. python -m build --wheel (or uv build) from the release checkout.
  2. cmp the locally built wheel against the downloaded qualified wheel, or compare sha256 against the digest recorded in the qualification artifact.
  3. Fail closed on mismatch, before uv publish.

This closes the provenance gap and, as a side effect, turns every release into a reproducible-build check — a claim this project is unusually well positioned to make.

If bit-for-bit reproducibility is not achievable yet (timestamps, wheel ordering), the interim control is to compare the unpacked content tree rather than the archive bytes, and to file the reproducibility gap separately rather than skipping the check.

Acceptance criteria

  • The release job builds a wheel from the tagged checkout.
  • Publication is gated on that wheel matching the qualified wheel; mismatch fails before uv publish.
  • A test or dry-run proves the mismatch path fails closed.
  • Documentation states which artifact is authoritative and how provenance is established.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Release-blocking: correctness of the gate or the primary adoption patharea:identityVerification identity, receipts, reproducibilityarea:releaseRelease pipeline, packaging, and safety qualificationbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions