cmd / sand

sand boots a Firecracker microVM for each agent task, runs the task in it, and destroys the VM. The agent is a language model that calls tools in a loop. Its tools include a shell, so the model writes and runs commands I did not write. Those commands need a boundary between what the model can reach and what I own.

Why not a container

A container shares the host kernel with every other container and with the host itself. A Firecracker guest runs its own Linux kernel behind a virtual machine boundary.

The split

The host holds the model API key and runs the agent loop. The guest runs commands and returns output. When the model calls a tool, the host sends the command to the guest over vsock, a socket between the VM and its host that no network route reaches.

host                          guest
  |  model API key              |  sh -c <command>
  |  agent loop                 |  stdout, stderr
  |  vsock connect  --------->  |  vsock listen :5000
  |  JSON request   --------->  |  run, stream, exit code

The guest has no network. The worker turns it off for each task, and no task argument can turn it on.

Two templates exist. The minimal template is Ubuntu with a shell and the exec agent, at 256 MiB of RAM. The Go template adds git, the Go toolchain, and a warm build cache, at 1 GiB.

Isolation

Each guest runs under the Firecracker jailer: a separate uid, its own network namespace, and cgroup limits. The rules in that namespace drop all input, output, and forwarded traffic for IPv4 and IPv6. One nft table on the host drops guest-to-guest traffic and traffic to private address ranges.

These rules do not isolate the agent loop on the host. That loop holds the keys, and it runs as root.

Guest input

The worker copies a repo into the guest from the committed HEAD of a clone on the host. Ignored files, untracked files, and uncommitted edits stay on the host. Before the guest boots, the worker records the commit and a manifest of the path, mode, and content hash of each file. A retry uses that commit, and a manifest that changed stops the job.

The export reads Git blobs directly, so a local Git attribute cannot add or swap a file. It refuses a submodule, a special file, an absolute link, and a link that climbs out with ... It also refuses an input larger than 64 MiB for one file, 256 MiB in total, or 10,000 paths. No task argument can raise those limits.

The worker resolves the job's repo path, with its symlinks, and refuses a path outside the clone. It runs tar as the owner of the clone, not as root. A file that only root can read then fails the export, and it does not reach the guest.

The worker gives the model API key and the sockeye token to the agent in its environment, not in its arguments. Any local user can read the arguments of a process in /proc/<pid>/cmdline. Only the owner can read its environment. The environment is a fixed list, so the agent gets none of the worker's own variables, such as DATABASE_URL.

The disk is a reflink clone of a template on btrfs. A clone takes about 3 ms and shares extents with the template until the guest writes to it.

Exec protocol

The host and the guest agent talk over one vsock connection per command. The host sends one JSON line. The agent streams back frames: one byte for the stream, a 4-byte length, and the payload.

type Request struct {
	Argv  []string `json:"argv"`
	Dir   string   `json:"dir,omitempty"`
	Env   []string `json:"env,omitempty"`
	Stdin bool     `json:"stdin,omitempty"`
}

The protocol has no authentication. The only way to reach the guest is through the host's vsock socket, which the jailer owns.

Snapshots

A snapshot captures the guest's full memory and device state. A restore from a local file takes about 560 ms. Snapshots and templates live in Cloudflare R2, compressed with zstd. A 512 MiB memory file compresses to about 33 MB. A snapshot is a checkpoint, not a backup.

The queue

A jobs table in Postgres holds queued work. A worker claims a job under a lease and renews the lease while it runs. Before the guest boots, the worker opens the job's change on a branch named for the job. The change exists even if everything after it dies.

The agent's push carries a Sand-Job trailer, and the trailer at the change's head is the completion marker. A change existing is not proof the patch landed, and a head on the branch is not proof it is this job's head, so a retry reads the trailer back instead of trusting either. A retry whose trailer already landed skips the guest.

A crash leaves the lease to expire. A worker's quiet poll moves expired leases back into the queue, and a retry clears a stale guest before booting its own. A worker that loses its lease stops its task: it cannot finish or fail the job, and it cannot down the guest, which may already belong to the new holder.

sand-timer enqueues one job. A systemd timer can call it on a schedule. No schedule is enabled yet.

What sand does not do

sand runs on one host and keeps its state in Postgres.

sand has no live migration, no guest-to-guest communication, and no persistent disk across tasks. A guest is disposable by design.

← All articles