Skip to content

Pin the runtime inputs, attest the release, and audit dependencies in CI - #105

Merged
ParallelEntrepreneur merged 2 commits into
mainfrom
colonizer/issue-89-d96f11bd
Sep 18, 2026
Merged

ParallelEntrepreneur merged 2 commits into
mainfrom
colonizer/issue-89-d96f11bd

Conversation

@ParallelEntrepreneur

Copy link
Copy Markdown
Collaborator

Closes #89.

The audit's finding was that a release verified only what it shipped alongside itself. The checksums in SHA256SUMS came from the same release as the download, so anyone who could rewrite the release could rewrite both. Meanwhile the things a colony actually runs — the base image, the guest Claude Code — followed moving tags and a moving channel, so two installs of the same Colonizer version could run different code.

This change closes both, and adds the dependency checks the issue asked for.

Pinned runtime inputs

Colony images (crates/colonizer/images.lock, new). Each stack preset's base image is pinned by its multi-arch OCI index digest, so one pin serves both x86_64 and aarch64 colonies. presets.rs compiles the lock in with include_str! — the same pattern as headroom.lock — and colonies boot node:24-bookworm@sha256:…. The lock lives inside the crate deliberately: colonizer-harness is published to crates.io, and a path outside the crate directory would not be packaged.

The guest Claude Code (vendor/claude-code.lock, new). Pinned by version and per-platform sha256 instead of following Anthropic's stable channel. Both installers read the lock, and both locks now ship inside the installed app so the mothership and the installer agree on the same reference.

This reverses a stance the code previously argued for in comments: fetch-agent-binary.sh said the agent was "deliberately not pinned" because "shipping colonies a stale agent is worse than following the channel". That trade-off is what the daily refresh below removes, and those comments are rewritten rather than left contradicting the code.

Refreshed by pull request (scripts/update-runtime-pins.mjs and .github/workflows/runtime-pin-updates.yml, new). Both locks are re-resolved daily and a bump is proposed as a PR that a person reviews and merges — the same model as the vendored plugins, which is what bounds staleness. The workflow stages the newly pinned agent binary and verifies its checksum before proposing, so a bad pin never becomes a PR.

Also pinned: the CI build containers in release.yml and headroom-bundle.yml, which were moving tags.

Release provenance

release.yml now attests the release artifacts with actions/attest-build-provenance (Sigstore, signed under the workflow's identity and recorded in a public transparency log). The signed subjects are the tarballs, install.sh, and SHA256SUMS.

The release is now created as a draft and then published, which is what GitHub's immutable releases require. Immutability itself is a repository setting (Settings → Releases → "Enable release immutability") that no workflow can commit — this PR makes the workflow compatible with it, but someone has to turn it on. Until then the attestation is doing the work alone.

install-release.sh verifies SHA256SUMS against that attestation with gh attestation verify when gh is present. Verifying the checksums file rather than each artifact means one small attested file transitively covers every digest it lists, since the mandatory checksum check already gates the tarball.

Verification is opportunistic by necessity: a POSIX sh installer cannot verify a Sigstore bundle, so requiring gh would mean requiring a Go toolchain's worth of dependency to install a binary. When it cannot reach a verdict it skips with a note and the checksum-verified install proceeds. COLONIZER_REQUIRE_ATTESTATION=1 turns skips into failures for anyone who wants the stronger guarantee.

SBOM and dependency scanning

.github/workflows/supply-chain.yml (new) runs cargo audit and npm audit --audit-level=high, and produces CycloneDX 1.5 SBOMs for both Rust crates and all three Node packages, on pull requests, pushes to main, and weekly. Nothing is currently vulnerable — 0 findings across 241 crates and all npm trees — so there are no ignore-lists and no || true anywhere in it.

Two jobs rather than one, so a red audit still leaves the SBOM artifact to look at. The tools install from crates.io with --locked rather than through third-party actions, on the grounds that a supply-chain workflow adding another party to trust would be its own finding.

How this was verified

  • cargo test --workspace --locked: 114 passed, 0 failed (109 at baseline; the new tests cover lock parsing, the digest fallback, and that the real lock pins every preset).
  • cargo build --workspace --locked and cd web && npm run build both clean.
  • shellcheck clean on every touched script; sh -n and dash -n clean.
  • All four workflows parse; actionlint clean apart from one false positive (below).
  • The pins were independently re-resolved. update-runtime-pins.mjs was run against the live registry and Anthropic's manifest and found all four image digests and both agent checksums already current — a different code path than the one that set them, agreeing.
  • Digest references were tested against the real sandbox, not assumed: msb pull with both tag@sha256:… and name@sha256:… succeeds, a sandbox boots from a digest reference, and a malformed digest is rejected at parse time.
  • The installer's provenance logic has a throwaway harness covering 21 cases (stub gh/curl, throwaway HOME): verified, skipped for each of four reasons, fatal on a bad signature, and strict mode for each.

Worth a closer look

The one false positive. actionlint 1.7.7 reports unknown permission scope "artifact-metadata" on release.yml. It is a real scope — it's in GitHub's current workflow-syntax list and actions/attest v4.2.2 requires it to create the artifact storage record. actionlint added it in v1.7.10. Worth confirming independently before merge, since an invalid permissions: key would fail the workflow outright; omitting the scope would still produce working attestations and only skip the storage record.

The limit of opportunistic verification. An attacker who can rewrite release assets can rewrite the archive and SHA256SUMS together. The checksum check passes, and the attestation lookup for their tampered file legitimately finds nothing — which lands in the "no attestation found" skip. That skip is deliberate and it is why enabling immutable releases matters, and why COLONIZER_REQUIRE_ATTESTATION=1 exists. The reasoning is written out above verify_provenance(), because the next reader will otherwise read the skip as a hole.

"No attestation found" is a skip, not a failure. Every release published before this merges has no attestation. Treating that as fatal would hard-fail the installer for every user who happens to have gh installed, from the moment this merges until the next release is cut. So it skips, and only under strict mode is it fatal. An unrecognised gh failure still falls through to fatal — a future gh rewording the message would resurface the breakage, which is the correct direction to fail.

A draft can wedge a retry. If a step fails between gh release create --draft and publishing, the release stays an invisible draft — the safe direction — but the rerun then fails at create with "already exists" until someone deletes the draft. Recovery is manual.

One thing untested in CI. No workflow exercises msb pull with a digest reference. It was tested by hand against the pinned msb binary (above), but a manual pull before cutting the next release would close the last untested link.

Scope

Two things in the issue are deliberately not here:

  • Gating merges on the full test suites is Engineering quality: CI, real-colony smoke tests, split sessions.rs, typed events, web tests #73, not this PR. CI still does not run the test suites; the README says so rather than implying otherwise.
  • services/telemetry gained a version field and a lockfile. That is scope beyond the issue, but without it one of three Node packages could not produce an SBOM at all, and the workflow had to generate a throwaway lockfile just to audit it. Both workarounds are gone.

Separately filed rather than fixed here: the telemetry worker deploys via unpinned npx wrangler, which is the repo's one remaining unverified dependency and is invisible to any dependency audit.

Docs across README.md, docs/install.md, docs/architecture.md and docs/protocol.md are updated to match. Two claims were already false before this change and are corrected: "No CI yet, and nothing is published to crates.io" (the crates job predates this), and "Nothing is downloaded at runtime" (the colony image and Headroom bundle were always lazy downloads). Note that the docs served at colonizer.dev/docs/* are mirrored from this repo by something that does not live in it, so those pages will stay stale until that mirror runs.


🤖 Generated by Colonizer in a microVM

ParallelEntrepreneur added a commit that referenced this pull request Sep 18, 2026
Part of #45. The check and the version it compares; applying an update is not
here.

#45 opens with "there are no versions to compare", which has since stopped being
true: v0.1.0 to v0.1.4 are tagged and Cargo.toml tracks them. What was still
missing is that a build records nothing about where it came from, so "is there
an update?" had no answer.

build.rs stamps `git describe --tags --always --dirty`, the commit, and a build
time. Everything degrades rather than fails: a source package with no .git
compiles and reports the crate version with no commit. The timestamp honours
SOURCE_DATE_EPOCH, because an unconditional clock reading would give every
rebuild a different binary, which is what #89/#105's attestation needs not to
happen.

GET /api/version returns that. GET/PUT /api/update carries the check: on by
default, a switch in Settings, and COLONIZER_UPDATE_CHECK=0 to keep it off from
the environment. It asks GitHub a minute after start and every six hours, only
while it is on — switched off it makes no request and forgets the last answer,
so no banner lingers for a check that is not running. Drafts and prereleases are
ignored. COLONIZER_RELEASES_URL points it elsewhere for a fork or a test.

A build after a tag is measured against the tag it contains, as the issue asks:
v0.1.4-12-gabc1234 is told about v0.1.5 and not about v0.1.4. A version that
cannot be placed is never told it is behind — better silence than crying wolf.

Settings gains an Updates section: the installed version, its commit, whether it
was built from a modified tree, and the release notes when there is a newer one.
It says plainly that updating means re-running the installer for now.

Checked in a browser against the mock: the section reads "v0.1.4 available",
the banner shows the notes, and switching the check off replaces it with "the
Mothership makes no request to GitHub about releases". The mock now clears its
cached release on switch-off too, which it did not before, so it no longer shows
a banner the real backend would have dropped.

Not done here, deliberately: applying the update. It wants the versioned app
directory from #90/#104 first, so an old release stays readable while running
colonies still mount plugins out of it.

Co-authored-by: Nick <40026523+Nick-CHI@users.noreply.github.com>
Refs #89

Co-Authored-By: Colonizer <noreply@colonizer.dev>
The bundle job runs `scripts/install.sh --bundle` directly and died with
"Permission denied" and exit 126: both scripts are 100644 on this branch and
100755 on main, so rewriting them here dropped the mode.

Restoring the bit rather than invoking them through `sh` keeps them working the
way the README documents them, and the same way every other script in the
directory is called.
@ParallelEntrepreneur
ParallelEntrepreneur merged commit e06d8aa into main Sep 18, 2026
9 checks passed
@ParallelEntrepreneur
ParallelEntrepreneur deleted the colonizer/issue-89-d96f11bd branch September 18, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release provenance: immutable releases, attestations and pinned runtime inputs

1 participant