From 7ead5f3eaa6428cdafc360bcc2a999d59fceded6 Mon Sep 17 00:00:00 2001 From: Jim Pudar Date: Sun, 31 May 2026 08:14:46 -0400 Subject: [PATCH] Enable Docker in agent VM --- README.md | 3 ++- agent-vm.nix | 25 ++++++++++++++++++- src/rootcell/integration/common/assertions.ts | 8 ++++++ src/rootcell/providers/lima.ts | 17 +++++++++++-- .../providers/macos-lima-user-v2/README.md | 6 ++--- src/rootcell/rootcell.test.ts | 9 +++++-- 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 48da90a..5aec6c5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ rootcell is early and intentionally narrow. Today it supports: | VM providers | `lima` for local macOS + Lima, and `aws-ec2` for AWS EC2 | | Guest OS | AARCH64 NixOS agent and firewall VMs | | Coding harness | [Pi](https://pi.dev) inside the agent VM | +| Containers | Rootful Docker, enabled at boot inside the agent VM | | Network policy | DNS, HTTPS, and SSH egress through the firewall VM | | Secrets | Host-side secret providers, including macOS Keychain and AWS Secrets Manager | @@ -85,7 +86,7 @@ The two VMs have the same roles in either provider: | Piece | What it does | | --- | --- | -| `agent` VM | Runs the coding harness, shell commands, Git, build tools, and project work. It has root inside the VM, but no direct public internet route. | +| `agent` VM | Runs the coding harness, shell commands, Git, Docker containers, build tools, and project work. It has root inside the VM, but no direct public internet route. | | `firewall` VM | Owns the public egress path. It runs `dnsmasq` for DNS allowlisting and `mitmproxy` for HTTPS interception and SSH CONNECT policy. | | `./rootcell` | Host-side wrapper that creates, provisions, updates, and enters the VMs. It also syncs allowlists and injects configured provider secrets for each session. | diff --git a/agent-vm.nix b/agent-vm.nix index 14bc9ce..080aaad 100644 --- a/agent-vm.nix +++ b/agent-vm.nix @@ -1,4 +1,4 @@ -{ config, pkgs, lib, ... }: +{ config, pkgs, lib, username, ... }: # Agent VM: where the coding agent runs. The agent has root inside this VM, # so this VM is treated as untrusted from the host's perspective. Its only @@ -23,6 +23,29 @@ in # anyway. All meaningful filtering happens in the firewall VM. networking.firewall.enable = false; + # Rootful Docker is part of the agent surface area. Access to the Docker + # socket is equivalent to root in this VM, which matches rootcell's threat + # model: the VM boundary matters, not privilege separation inside it. + virtualisation.docker = { + enable = true; + enableOnBoot = true; + storageDriver = "overlay2"; + logDriver = "local"; + daemon.settings = { + "log-opts" = { + "max-size" = "10m"; + "max-file" = "3"; + }; + }; + autoPrune = { + enable = true; + dates = "weekly"; + flags = [ "--all" ]; + }; + }; + + users.users.${username}.extraGroups = [ "docker" ]; + # Networking: only the per-instance private Lima user-v2 link is configured. # Lima's VZ hostagent still needs a DHCP lease on that link before it opens # the VSOCK SSH control path after restarts, but Rootcell keeps ownership of diff --git a/src/rootcell/integration/common/assertions.ts b/src/rootcell/integration/common/assertions.ts index af0d06d..e5bcae4 100644 --- a/src/rootcell/integration/common/assertions.ts +++ b/src/rootcell/integration/common/assertions.ts @@ -42,6 +42,14 @@ export async function expectPrivateNetworkRouting(flow: IntegrationFlow): Promis export async function expectGuestTools(flow: IntegrationFlow): Promise { await flow.agentSh("command -v pi && command -v rg && command -v gh && command -v jq >/dev/null"); await flow.agentSh("out=$(pi --help) && [ -n \"$out\" ]"); + await flow.agentSh("systemctl is-active docker >/dev/null && docker version >/dev/null && docker compose version >/dev/null"); + await flow.agentSh([ + "tmp=$(mktemp -d)", + "trap 'rm -rf \"$tmp\"; docker image rm -f rootcell-docker-smoke:latest >/dev/null 2>&1 || true' EXIT", + "mkdir -p \"$tmp/empty\"", + "tar -C \"$tmp/empty\" -cf - . | docker import - rootcell-docker-smoke:latest >/dev/null", + "docker run --rm --network=none -v /nix/store:/nix/store:ro -v /run/current-system/sw/bin:/host-bin:ro rootcell-docker-smoke:latest /host-bin/true", + ].join("\n")); } export async function expectProxyPolicy(flow: IntegrationFlow): Promise { diff --git a/src/rootcell/providers/lima.ts b/src/rootcell/providers/lima.ts index fe721e4..ebcd226 100644 --- a/src/rootcell/providers/lima.ts +++ b/src/rootcell/providers/lima.ts @@ -625,10 +625,23 @@ export function userV2ProofScript(input: { `firewall_ip=${shellQuote(input.firewallIp)}`, `prefix=${shellQuote(input.networkPrefix)}`, `iface=${shellQuote(input.agentPrivateInterface)}`, + "container_iface_re='^(docker0|docker_gwbridge|br-[0-9a-f]+|veth.*)$'", "test -d \"/sys/class/net/$iface\"", - "test \"$(find /sys/class/net -mindepth 1 -maxdepth 1 ! -name lo | wc -l | tr -d ' ')\" = 1", + "for path in /sys/class/net/*; do", + " name=${path##*/}", + " case \"$name\" in", + " lo|\"$iface\") continue ;;", + " esac", + " printf '%s\\n' \"$name\" | grep -Eq \"$container_iface_re\"", + "done", "ip -4 addr show dev \"$iface\" | grep -q \" $agent_ip/$prefix\"", - "! ip -4 -o addr show scope global | grep -v \"^[0-9]\\+: $iface\\b\" | grep -q .", + "while read -r _ name _ _; do", + " name=${name%%@*}", + " name=${name%:}", + " if [ \"$name\" != \"$iface\" ]; then", + " printf '%s\\n' \"$name\" | grep -Eq \"$container_iface_re\"", + " fi", + "done < <(ip -4 -o addr show scope global)", "test \"$(ip route show default | wc -l | tr -d ' ')\" = 1", "ip route show default | grep -q \"^default via $firewall_ip dev $iface\\b\"", "! ip route show default | grep -qv \"via $firewall_ip dev $iface\"", diff --git a/src/rootcell/providers/macos-lima-user-v2/README.md b/src/rootcell/providers/macos-lima-user-v2/README.md index 269dfde..70a8b2d 100644 --- a/src/rootcell/providers/macos-lima-user-v2/README.md +++ b/src/rootcell/providers/macos-lima-user-v2/README.md @@ -129,9 +129,9 @@ Rootcell static address, firewall DNS, and default route remain authoritative. The firewall VM keeps the same route-free, DNS-free DHCP lease on its private user-v2 interface for the same Lima VSOCK startup path. During startup, rootcell runs a proof gate inside the agent that checks there is -exactly one non-loopback interface, that all global IPv4 addresses are on that -interface, that the Rootcell static address is present, and that there is no -default-route bypass. +no extra provider-facing interface beyond the private Rootcell link. Docker's +local bridge/veth interfaces are allowed, but the proof still verifies that the +Rootcell static address is present and that there is no default-route bypass. The host connects to the firewall through Lima's generated localhost SSH endpoint. The agent is reached through SSH ProxyJump via the firewall over the diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index de33ed3..77e57fe 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -1466,6 +1466,10 @@ describe("VM and network providers", () => { expect(agentModule).toContain("UseDNS = false;"); expect(agentModule).toContain("UseRoutes = false;"); expect(agentModule).toContain("PreferredSource = net.agentIp;"); + expect(agentModule).toContain("virtualisation.docker = {"); + expect(agentModule).toContain("enableOnBoot = true;"); + expect(agentModule).toContain("storageDriver = \"overlay2\";"); + expect(agentModule).toContain("users.users.${username}.extraGroups = [ \"docker\" ];"); const homeModule = readFileSync("home.nix", "utf8"); expect(homeModule).toContain("extensions-home-manager.nix"); @@ -1479,9 +1483,10 @@ describe("VM and network providers", () => { networkPrefix: "24", agentPrivateInterface: "enp0s1", }); - expect(script).toContain("find /sys/class/net -mindepth 1 -maxdepth 1 ! -name lo"); + expect(script).toContain("container_iface_re='^(docker0|docker_gwbridge|br-[0-9a-f]+|veth.*)$'"); + expect(script).toContain("for path in /sys/class/net/*; do"); expect(script).toContain("ip -4 addr show dev \"$iface\" | grep -q \" $agent_ip/$prefix\""); - expect(script).toContain("! ip -4 -o addr show scope global | grep -v \"^[0-9]\\+: $iface\\b\" | grep -q ."); + expect(script).toContain("done < <(ip -4 -o addr show scope global)"); expect(script).toContain("test \"$(ip route show default | wc -l | tr -d ' ')\" = 1"); expect(script).toContain("ip route show default | grep -q \"^default via $firewall_ip dev $iface\\b\""); });