Pin the runtime inputs, attest the release, and audit dependencies in CI - #105
Merged
Merged
Conversation
4 tasks
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
force-pushed
the
colonizer/issue-89-d96f11bd
branch
from
September 18, 2026 04:16
2414443 to
77a43a3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #89.
The audit's finding was that a release verified only what it shipped alongside itself. The checksums in
SHA256SUMScame 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.rscompiles the lock in withinclude_str!— the same pattern asheadroom.lock— and colonies bootnode:24-bookworm@sha256:…. The lock lives inside the crate deliberately:colonizer-harnessis 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'sstablechannel. 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.shsaid 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.mjsand.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.ymlandheadroom-bundle.yml, which were moving tags.Release provenance
release.ymlnow attests the release artifacts withactions/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, andSHA256SUMS.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.shverifiesSHA256SUMSagainst that attestation withgh attestation verifywhenghis 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
shinstaller cannot verify a Sigstore bundle, so requiringghwould 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=1turns skips into failures for anyone who wants the stronger guarantee.SBOM and dependency scanning
.github/workflows/supply-chain.yml(new) runscargo auditandnpm 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|| trueanywhere 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
--lockedrather 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 --lockedandcd web && npm run buildboth clean.shellcheckclean on every touched script;sh -nanddash -nclean.actionlintclean apart from one false positive (below).update-runtime-pins.mjswas 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.msb pullwith bothtag@sha256:…andname@sha256:…succeeds, a sandbox boots from a digest reference, and a malformed digest is rejected at parse time.gh/curl, throwawayHOME): 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.
actionlint1.7.7 reportsunknown permission scope "artifact-metadata"onrelease.yml. It is a real scope — it's in GitHub's current workflow-syntax list andactions/attestv4.2.2 requires it to create the artifact storage record. actionlint added it in v1.7.10. Worth confirming independently before merge, since an invalidpermissions: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
SHA256SUMStogether. 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 whyCOLONIZER_REQUIRE_ATTESTATION=1exists. The reasoning is written out aboveverify_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
ghinstalled, from the moment this merges until the next release is cut. So it skips, and only under strict mode is it fatal. An unrecognisedghfailure still falls through to fatal — a futureghrewording 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 --draftand publishing, the release stays an invisible draft — the safe direction — but the rerun then fails atcreatewith "already exists" until someone deletes the draft. Recovery is manual.One thing untested in CI. No workflow exercises
msb pullwith 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:
services/telemetrygained aversionfield 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.mdanddocs/protocol.mdare updated to match. Two claims were already false before this change and are corrected: "No CI yet, and nothing is published to crates.io" (thecratesjob 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