From b199b17e3a55ecc951d6f26848f5e87fff95060c Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 15 Sep 2026 20:25:37 -0400 Subject: [PATCH 1/3] feat(guest-image): derive the guest rootfs from the pinned agent OCI image The guest userland was a second nix evaluation of the agent image's expressions under a different nixpkgs pin, so the microVM guest and the published container could drift silently. The rootfs now unpacks the published image itself: per-layer fixed-output fetches keyed by the locked descriptor digests, applied in manifest order with each layer's whiteouts resolved against the lower layers before its own content lands. A boot layer is overlaid on top and wins every conflict: guestd as /sbin/init, modprobe, /lib/modules from the root-pinned kernel, and a writable /etc/resolv.conf. Two build-time gates keep it honest. The eval re-checks the lock's shape, and the userland contract is verified on the assembled tree by resolving every path component inside it -- the way the kernel will after switch_root -- so a missing /bin/sh breaks the build instead of shipping a guest that cannot arm egress. Refs RIG-3787 Co-authored-by: Matt Wilkinson --- guest-image/default.nix | 346 ++++++++++++++++++++++++---------------- guest-image/moon.yml | 34 ++-- 2 files changed, 223 insertions(+), 157 deletions(-) diff --git a/guest-image/default.nix b/guest-image/default.nix index e06bb6ac1..db2011386 100644 --- a/guest-image/default.nix +++ b/guest-image/default.nix @@ -1,19 +1,11 @@ # The Compass microVM guest image: the three nix attrs V2a's cloud-hypervisor -# runtime consumes to boot a session guest — a direct-boot kernel, a packed erofs -# root filesystem, and a module initramfs. It reuses agent-image/'s toolchain -# closure, so the guest ships the same agent runtime as the container path — one -# closure, two artifact shapes (OCI layers there, a bootable erofs image here). -# -# Pin divergence (a parity note V2a honors): `agent-image/toolchain.nix` is -# called here with ROOT's `pkgs` (the root devenv.lock), NOT agent-image's own -# pin, so the whole rootfs closure resolves from the root pin. Deliberate: the -# guest-image moon gate's `inputs` track the root devenv.lock, so the gate -# reschedules on a root-pin move. agent-image's OCI build keeps its own pin; the -# two closures are the same shape through two nixpkgs revisions. +# runtime consumes to boot a session guest. The rootfs userland IS the published +# agent OCI image unpacked, fetched fixed-output against `agent-oci.lock`, so +# guest/container drift is not expressible. Only the boot layer is added on top. let # The root devenv.lock-pinned nixpkgs, resolved as the other plain nix gates do - # (read the lock, fetch that rev, import it). This is the "root's pkgs" the pin - # divergence turns on. + # (read the lock, fetch that rev, import it). Supplies the BOOT layer only: the + # agent userland comes from the OCI image, not from this pin. lock = builtins.fromJSON (builtins.readFile ../devenv.lock); node = lock.nodes.nixpkgs.locked; nixpkgsSrc = builtins.fetchTarball { @@ -23,24 +15,65 @@ let pkgs = import nixpkgsSrc { }; lib = pkgs.lib; - # The SAME bundled agent entrypoint and toolchain closure the agent image ships, - # imported unchanged and fed root's `pkgs`. Their own relative imports resolve - # against agent-image/, not this file, so importing them here is transparent. - compassAgent = import ../agent-image/entrypoint.nix { inherit pkgs lib; }; - toolchain = import ../agent-image/toolchain.nix { inherit pkgs compassAgent; }; - - # The real guest init (T2, go/cmd/compass-guestd): the guest-side supervisor — - # mounts the API filesystems, brings networking up (in-process DHCP), mounts the - # virtio-fs workspace, serves the vsock Health handshake as guest PID 1. - # buildGoModule of the backend module; static (CGO_ENABLED=0) so it needs no - # in-guest libc a switch_root'd PID 1 cannot assume. - # * src is renamed off `go` so buildGoModule's $GOPATH unpack does not collide - # ("go.mod file not found"). - # * proxyVendor is required: wails/secretspec //go:embed patterns reference - # darwin/windows-only files a vendor-tree build would fail on; proxyVendor - # touches only the packages actually compiled for linux/amd64. - # * vendorHash pins the fetched module set; recompute with `lib.fakeHash` on a - # go.mod/go.sum move. + # The pinned agent image, written only by tools/guest-image/pin-agent-image.ts. + # The shape is re-checked HERE as well as in the pin tool: the tool guards the + # write path, this guards the eval, and a hand-edited lock has to defeat both. + agentLock = builtins.fromJSON (builtins.readFile ./agent-oci.lock); + + # Split registry host from repository path: the lock stores the full + # reference, the v2 API needs the two separately. + agentRegistry = "ghcr.io"; + agentPath = "rigelbuild/compass-agent"; + agentRepo = "${agentRegistry}/${agentPath}"; + + # A digest is only a pin if it is a real sha256. `match` returns null on any + # deviation, so a truncated or hex-invalid digest fails eval instead of + # reaching fetchurl as an unenforceable hash. + isSha256 = s: builtins.isString s && builtins.match "sha256:[0-9a-f]{64}" s != null; + + # Fail at eval, naming the field, rather than letting a malformed lock surface + # as an opaque fetch or hash error deep in the build. + checkedLock = + let + bad = + if agentLock.repo or null != agentRepo then + "repo must be ${agentRepo}, got ${toString (agentLock.repo or "")}" + else if builtins.match "git-[0-9a-f]{12}" (agentLock.tag or "") == null then + "tag must match git-, got ${toString (agentLock.tag or "")}" + else if !isSha256 (agentLock.digest or "") then + "digest must be sha256:<64 hex>, got ${toString (agentLock.digest or "")}" + else if !builtins.isList (agentLock.layers or null) || agentLock.layers == [ ] then + "layers must be a non-empty list" + else if !builtins.all isSha256 agentLock.layers then + "every layer must be sha256:<64 hex>" + else + null; + in + if bad == null then + agentLock + else + throw "guest-image: agent-oci.lock is not a valid pin: ${bad}. Rewrite it with `bun tools/guest-image/pin-agent-image.ts --relock`, never by hand."; + + # Each layer blob, fetched fixed-output against its descriptor digest -- the + # registry's own content address, where one archive's narHash would track + # skopeo's byte layout. The bearer is GHCR's literal anonymous public-read + # token, not a credential: an unauthenticated blob GET 401s demanding one. + agentLayers = map ( + digest: + pkgs.fetchurl { + url = "https://${agentRegistry}/v2/${agentPath}/blobs/${digest}"; + curlOptsList = [ + "-H" + "Authorization: Bearer QQ==" + ]; + hash = digest; + } + ) checkedLock.layers; + + # The guest-side supervisor, running as guest PID 1: mounts the API + # filesystems, brings networking up, serves the vsock Health handshake. Static + # since a switch_root'd PID 1 cannot assume a libc; src is renamed off `go` so + # buildGoModule's unpack cannot collide. guestd = pkgs.buildGoModule { pname = "compass-guestd"; version = "0-v2a"; @@ -56,8 +89,8 @@ let "-s" "-w" ]; - # This slice only packages the binary; guestd's logic is unit-tested under the - # backend gate and the real boot is T4's KVM-gated proof. + # This derivation only packages the binary; guestd's logic is unit-tested + # under the backend gate and the real boot is proved by the KVM-gated test. doCheck = false; }; @@ -76,16 +109,10 @@ let # the separate `modules` output, consumed by both the rootfs and the initrd. kernel = pkgs.linuxPackages.kernel; - # The module set the initramfs loads before switch_root, via kmod modprobe from - # the shrunk closure, because the guest has no udev/systemd-modules-load to - # autoload post-switch_root (guestd IS init). These loads persist across - # switch_root, so every driver the guest needs is bound by the time guestd - # starts: the boot-critical set mounts the root overlay (virtio transport + - # block, erofs, overlayfs); the runtime set covers guestd's net/workspace/vsock - # and af_packet (its in-process DHCP client's raw socket — without it the lease - # fails EAFNOSUPPORT). Every one is `=m` in the pinned kernel; the check below - # fails the build on a pin move that flips one to `=y` or drops it, rather than - # shipping a guest that boots but cannot reach network, workspace, or host. + # The initramfs modprobes these before switch_root: nothing autoloads + # afterwards (guestd IS init) and the loads persist across it. af_packet is + # load-bearing -- guestd's DHCP raw socket fails EAFNOSUPPORT without it. + # The check below breaks the build if a pin move flips one to `=y`. bootModules = [ "virtio_pci" "virtio_blk" @@ -121,7 +148,7 @@ let ${lib.concatMapStringsSep "\n" (sym: '' if ! grep -qx '${sym}=m' ${kernel.configfile}; then echo "guest-image: BUILD-BREAK — kernel .config lacks '${sym}=m'." >&2 - echo " The initramfs assumes ${sym} is a loadable module (record §(a))." >&2 + echo " The initramfs assumes ${sym} is a loadable module." >&2 echo " A kernel-pin move flipped it to =y or dropped it; the initrd would" >&2 echo " not boot. Re-audit guest-image/default.nix bootModules against the" >&2 echo " new kernel before proceeding." >&2 @@ -156,7 +183,7 @@ let fail() { echo "compass-guest-initrd: $1" >&2 # Give the console a moment to flush before PID 1 exits and the kernel - # panics, so the cause is visible in T4's captured serial log. + # panics, so the cause is visible in the captured serial log. exec sh -c 'echo "compass-guest-initrd: boot aborted"; exit 1' } @@ -183,13 +210,10 @@ let -o lowerdir=/mnt/lower,upperdir=/mnt/rw/upper,workdir=/mnt/rw/work \ /mnt/root || fail "mount whole-root overlay failed" - # No pre-switch_root existence check on /mnt/root/sbin/init: it is an - # ABSOLUTE store symlink (-> /nix/store/…-compass-guestd/bin/compass-guestd), - # so `test -x` would follow the symlink and resolve its absolute target - # against the CURRENT process root — still the initramfs, where guestd is - # absent — and fail-close on every correct image. switch_root below is the - # gate: it chroots into /mnt/root first, so /sbin/init resolves in the - # overlay where guestd exists, and it is itself `|| fail`-closed. + # No pre-switch_root check on /mnt/root/sbin/init: it is an ABSOLUTE store + # symlink, so `test -x` resolves it against the CURRENT root (still the + # initramfs, where guestd is absent) and fail-closes on every correct image. + # switch_root chroots first, and is itself `|| fail`-closed. # Hand off to the real guest init. switch_root tears down the initramfs and # execs /sbin/init as PID 1 in the overlay root. @@ -215,39 +239,12 @@ let ]; }; - # The rootfs contents tree: a store-path symlink farm + a real writable - # resolv.conf + the kernel's full /lib/modules tree; the erofs step below packs - # its store closure into the bootable image. Assembled by hand (not `buildEnv`) - # so resolv.conf lands as a real file and the closure references stay explicit. - rootfsTree = pkgs.runCommand "compass-guest-rootfs-tree" { } '' - mkdir -p $out/bin $out/sbin $out/etc $out/lib - - # The agent-image toolchain closure: its /bin and /etc, symlinked in. These - # point into the store closure the packed image ships — the same - # relocated-/etc + store-closure shape nix2container gives the OCI artifact. - for f in ${toolchain}/bin/*; do - ln -s "$f" "$out/bin/$(basename "$f")" - done - if [ -d ${toolchain}/etc ]; then - cp -a ${toolchain}/etc/. $out/etc/ - # cp -a preserves the store's read-only dir/file modes; make the staged - # /etc writable so the resolv.conf install below lands cleanly. - chmod -R u+w $out/etc - fi - - # The egress prerequisites (microvm-runner.md:446-449) plus /bin/sh. Already - # present via the toolchain closure above (agent-image/toolchain.nix:144-147), - # linked again here explicitly so the guest's contract does not depend on the - # toolchain's internal package list. `ln -sf` because the toolchain loop may - # already have created these names. /bin/sh is load-bearing under always-arm - # (record §(e)): every microVM Start spawns `/bin/sh -c