Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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. |

Expand Down
25 changes: 24 additions & 1 deletion agent-vm.nix
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/rootcell/integration/common/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export async function expectPrivateNetworkRouting(flow: IntegrationFlow): Promis
export async function expectGuestTools(flow: IntegrationFlow): Promise<void> {
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<void> {
Expand Down
17 changes: 15 additions & 2 deletions src/rootcell/providers/lima.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
6 changes: 3 additions & 3 deletions src/rootcell/providers/macos-lima-user-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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\"");
});
Expand Down