grix (greg + unix) is an agentbox: a Rust unikernel for running Linux programs on behalf of AI agents, with least privilege by default. Each run boots a fresh microVM whose kernel runs exactly one unmodified Linux binary. The program sees only the files you import, and every syscall is checked against the capabilities you grant. Anything it tries beyond that is denied and reported back, so the agent can see exactly what the program needed.
$ grix run -i ./build/net:/bin/net -- /bin/net
socket: Operation not permitted
grix: denied socket: needs capability `net`
$ grix run -i busybox.static:/bin/busybox -- /bin/busybox cat /etc/passwd
cat: can't open '/etc/passwd': No such file or directory
grix: missing /etc/passwd: not importedStatus: early prototype. It runs unmodified Python 3.12, Node 22 (Alpine's builds), Go programs, busybox, and static, static-PIE or dynamically linked musl programs, with threads, signal handlers, pipes, eventfd, epoll and (with
fs_write) a writable in-memory filesystem. With the matching capabilities it also runs processes (fork/exec) and networks through the host: clients, servers on forwarded ports, and DNS, under an allow-list. Host directories can be mounted live, read-write or read-only, without copying them in. See Compatibility and Roadmap.
Every commit to main is a release
(releases): grix, grix-vmm
and the grix kernel for arm64 macOS (15+) and arm64 Linux (static).
Unpack one and run ./grix; it finds the kernel and grix-vmm beside
itself. To build it yourself:
Requirements: macOS 15+ on Apple silicon or arm64 Linux with KVM (grix's own
VMM), or QEMU (qemu-system-aarch64) on anything else; Rust (stable);
zig to build the C test programs, and Go to build the
Go ones. make test needs QEMU too, since some tests run on both VMMs.
make # kernel + host CLI + grix-vmm + test fixtures (downloads Alpine busybox, python3, nodejs)
make test # unit tests + end-to-end tests that boot the kernel
./target/release/grix run -i fixtures/build/hello:/bin/hello -e GREETING=hi -- /bin/hello world
./target/release/grix run -m examples/busybox.toml
./target/release/grix run --json -m examples/busybox.toml # machine-readable result
./target/release/grix caps # list capabilities
# stdin is passed through when it isn't a terminal (--no-stdin turns that off).
echo 'a b c' | ./target/release/grix run -i fixtures/build/alpine/bin/busybox.static:/bin/busybox -- /bin/busybox wc -w
# Interactive, as `docker run -t`: the program gets a terminal (/dev/pts/0),
# with job control, Ctrl-C and Ctrl-Z.
./target/release/grix run -t -a process -i fixtures/build/alpine/bin/busybox.static:/bin/busybox -- /bin/busybox sh -i
# A host name of its own, and HOME, PATH, LANG and TMPDIR as a login would set them
# (otherwise the program gets only the environment it's given).
./target/release/grix run --hostname worker-1 --default-env -a process \
-i fixtures/build/alpine/bin/busybox.static:/bin/busybox -- /bin/busybox sh -c 'hostname; env'
# The host's time zone (a sandbox is in UTC otherwise, as a zone says something
# of where the host is), from TZ or /etc/localtime. Node's Intl and Python see it.
./target/release/grix run --host-timezone -a process \
-i fixtures/build/alpine/bin/busybox.static:/bin/busybox -- /bin/busybox date
# ping: ICMP echo takes net_raw on top of net, and goes through the host, which
# checks it as it does a connection (a rule without a port). The host has to be
# able to send it: root, or net.ipv4.ping_group_range including your group.
./target/release/grix run -a net -a net_raw --allow-host 1.1.1.1 \
-i fixtures/build/alpine/bin/busybox.static:/bin/busybox -- /bin/busybox ping -c 3 1.1.1.1
# Results: the program fills /out, and it's copied to ./results when it ends.
./target/release/grix run -a fs_write -x /out:./results \
-i fixtures/build/alpine/bin/busybox.static:/bin/busybox -- /bin/busybox cp /etc/passwd /out/
# Language runtimes: import a whole root filesystem and allow threads.
./target/release/grix run -a threads -i fixtures/build/python:/ -- /usr/bin/python3 -c 'print(6*7)'
./target/release/grix run -a threads -i fixtures/build/node:/ -- /usr/bin/node -e 'console.log(6*7)'
# More CPUs: a process's threads run in parallel (separate processes take turns).
./target/release/grix run --cpus 4 -a threads -i fixtures/build/smp:/bin/smp -- /bin/smp
# An x86_64 guest: the x86_64 kernel, in QEMU microvm (emulated on an arm64
# host; KVM on an x86 one). Programs built for x86_64 run as they are.
./target/release/grix run --arch x86_64 -i fixtures/build/amd64/hello:/bin/hello -- /bin/hello world
# x86_64 programs in the arm64 guest, through a translator (here Alpine's arm64 qemu-x86_64).
./target/release/grix run --x86-64 fixtures/build/qemu-x86_64/usr/bin/qemu-x86_64 -a threads \
-e QEMU_LD_PREFIX=/ -i fixtures/build/x86_64:/ -- /usr/bin/python3 -c 'import platform; print(platform.machine())'
# Networking: only to example.com, port 443.
./target/release/grix run -a net -a threads --allow-host example.com:443 -i fixtures/build/python:/ -- \
/usr/bin/python3 -c 'import urllib.request as u; print(u.urlopen("https://example.com").status)'
# A server: host port 8080 goes to port 8000 in the sandbox.
./target/release/grix run -a net -a threads -p 8080:8000 -i fixtures/build/python:/ -- \
/usr/bin/python3 -m http.server 8000On arm64 macOS 15+ and on arm64 or x86_64 Linux with KVM, grix runs its
kernel in its own VMM, grix-vmm (Hypervisor.framework or KVM, no QEMU
needed): an arm64 guest on an arm64 host, an x86_64 guest on an x86
host. A run that boots, runs a program and exits takes
about 0.1 s. Elsewhere it uses QEMU, with its emulator if the host can't
virtualize the guest (and grix run says so when that's for want of
/dev/kvm). GRIX_ACCEL=tcg|hvf|kvm chooses QEMU with that
accelerator, and GRIX_VMM=PATH|qemu picks the VMM. A Node script that
boots, starts a worker thread and exits takes about 0.9s with HVF, 9s
emulated.
grix-vmm lives next to grix or in this tree's target/release/. On
macOS it must be signed with the hypervisor entitlement (make vmm does
it, ad hoc). make test-kvm checks the KVM side from an M3 (or later)
Mac: it boots Linux in QEMU with nested virtualization and runs grix there.
use grix::{Capability, Sandbox};
let sandbox = Sandbox::builder()
.import("./tools/busybox.static", "/bin/busybox")? // a host file or directory
.file("/data/input.csv", csv_bytes, 0o644)? // or inline contents
.env("LANG", "C")
.memory_mb(256)
.timeout(std::time::Duration::from_secs(10))
.allow(Capability::FsWrite) // grant only what's needed
.export("/out")? // sent back when the program ends
.build()?;
let out = sandbox.run(&["/bin/busybox", "sort", "-o", "/out/sorted.csv", "/data/input.csv"])?;
println!("{}", out.stdout_str()); // also: stderr, status, kernel_log, duration
for event in &out.events {
eprintln!("{event}"); // denied / missing / unsupported / faults
}
let needed = out.missing_capabilities(); // e.g. ["net"] -> ask the user, then re-run
let results = out.export("/out").unwrap(); // entries in memory (state says if it's all there)
results.write_to("./results")?; // or recreate it on the hostA RunControl sends the program signals while it runs, as a terminal
would: to its whole process group, so a shell's children get Ctrl-C too.
use grix::RunControl;
let control = RunControl::new();
let sandbox = Sandbox::builder().import("./server", "/bin/server")?.control(&control).build()?;
let c = control.clone();
std::thread::spawn(move || {
// ... later, from anywhere:
c.signal(2); // SIGINT: what Ctrl-C would send
c.signal(15); // SIGTERM: shut down
c.stop(); // or just end the run (ExitStatus::Stopped)
});
let out = sandbox.run(&["/bin/server"])?;- When the time limit runs out (
timeout, 60 seconds by default), the program gets SIGTERM and a grace period (grace, 5 seconds by default) to end. If it does, its exports andmount_on_exitchanges come back. The status isTimeouteither way. A server has none:no_timeout()(--timeout 0, a manifest'stimeout_secs = 0). - A program whose threads are all blocked is ended at once and reported as
a deadlock, unless one waits for a signal (
pause,sigsuspend,sigwait): one may yet come, from a control or the time limit. - The CLI passes on Ctrl-C, SIGTERM, SIGHUP and SIGQUIT (QEMU runs in its
own process group, so a terminal's Ctrl-C is the program's alone). The
third Ctrl-C stops the sandbox.
--grace SECSand the manifest'sgrace_secsset the grace period. - Job control works as on Linux: SIGTSTP (or SIGSTOP, SIGTTIN, SIGTTOU)
stops a process and SIGCONT continues it,
waitreports both (WUNTRACED,WCONTINUED), and the parent gets SIGCHLD. From the host,c.signal(20)stops the program's process group andc.signal(18)continues it;GuestEvent::StoppedandContinuedsay when the program did.RunControl::on_eventsees events as they happen.
SandboxBuilder::tty(rows, cols) (grix run -t) is TTY mode, as with
docker run -t: the program's stdin, stdout and stderr are a terminal,
/dev/pts/0, its controlling terminal. What the host sends as stdin is
what's typed: Ctrl-C, Ctrl-Z and the rest are bytes the terminal turns into
signals for its foreground process group, and a shell's line editing,
echo and job control work. stdout is what the terminal shows (CR LF line
ends, echo); there's no separate stderr. RunControl::resize changes the
size (the foreground group gets SIGWINCH), and the CLI follows its own
terminal's. When the host's stdin ends, a program reading a line gets
end-of-file, as with util-linux's script. In the CLI, a program that
stops itself (Ctrl-Z with nothing to catch it, or vim's suspend) stops
grix too, until fg. A snapshot can be taken in TTY mode (grix snapshot -t): an interactive shell or REPL at its prompt.
Programs can make their own terminals too: /dev/ptmx and /dev/pts/N
(posix_openpt, openpty, Python's pty), with Linux's line discipline
(canonical mode with erase, kill, word erase and literal next, echo,
VMIN/VTIME, output processing, IXON), termios and window sizes,
controlling terminals and /dev/tty, foreground process groups, SIGTTIN
and SIGTTOU for background jobs, and SIGHUP when the master closes. At
most 64 exist at once.
mount makes a host directory live in the sandbox, like a bind mount:
the program sees it as it is, and what it changes there changes on the
host at once. Nothing is copied in up front. The kernel asks the host for
directory listings and file contents as the program needs them, so the
directory can be any size.
let sandbox = Sandbox::builder()
.import("./tools/busybox.static", "/bin/busybox")?
.mount("./my-project", "/work")? // read-write; grants fs_write
.mount_read_only("./datasets", "/data")? // EROFS on any change
.cwd("/work")
.build()?;
let out = sandbox.run(&["/bin/busybox", "sed", "-i", "s/foo/bar/g", "/work/src/main.c"])?;import_lazy (--mount HOST:GUEST:lazy, a manifest's mode = "lazy")
is an import that isn't copied in: the guest reads the files from the
host as the program uses them, so a runtime or dataset can be larger than
memory_mb (a 400 MB file hashes in 256 MB). Otherwise it is an import:
the host's directory never changes, and with fs_write whatever the
program changes (a file it writes, a directory it adds to) becomes the
sandbox's and stays there, as for imports: a file's bytes are read in when
it's first written (a truncate reads only what stays). The program sees
the directory as it found it, even after the guest forgets and asks
again; a file changed on the host after it first saw it reads as EIO
rather than a mixture. Exports skip what is still the host's.
- Confined. The host serves each request by walking down from the mounted directory one checked name at a time, never following a link. A link the program makes (or finds) can't lead it out.
- Fresh within a second. The kernel keeps what the host told it for a second, so changes made on the host during the run show up within one. Its own changes are visible at once, to it and to the host.
- Linux semantics:
- files removed while open stay usable;
O_APPENDwrites go to the host file's end;fsyncreaches the host's disk;- the mount point itself can't be removed or renamed (EBUSY);
- renames and hard links into or out of the mount fail with EXDEV, and hard links within it with EPERM.
- Files' times and modes are the host's. Ownership shows as the sandbox
user's, and
chowncan't change it. - Any size. Runtimes work from a live mount too: Python runs entirely
from read-only mounts of its
/liband/usr, mapping its shared libraries page by page from the host. A tree or a single directory can be larger than the kernel can hold at once: it forgets entries it's done with and asks again. - Limits:
- A file the host replaces while the program has it open may show the new contents (the host reopens it by name once its descriptor ages out).
- On a read-only mount, changes that need
fs_writeare reported as denied (needs capability fs_write) when it isn't granted, instead of EROFS.
mount_on_exit is the other kind of read-write mount: each run imports the
directory, and what the program changed is applied to the host only when it
ends, all at once. Only what changed is sent back, and nothing at all is
applied if the changes don't all arrive (over max_export, or the run was
cut off). Applying never follows a link and replaces files by renaming new
ones into place. Anything that also changed on the host during the run is
left as the host has it and listed in RunOutput::mount_changes[i].conflicts.
The directory lives in guest memory, so memory_mb bounds it.
The CLI has --mount HOST:GUEST[:rw|ro|on-exit], and manifests [[mount]]
tables with host, guest and mode ("rw", "ro" or "on_exit").
An exported directory comes back whenever the program ends (exit, crash,
deadlock, or a timeout it ends within the grace period of; see below). If
nothing is imported there, it starts out empty. The program's status is reported first, and the export then has
30 seconds. The guest leaves out entries over the budget (max_export, 256
MiB by default) or nested deeper than 4096 bytes of path, and state says
when anything is missing. write_to builds the tree beside its target and
renames it into place. It never writes through a symlink, drops
setuid/setgid and group/other write, and leaves nothing behind if it fails.
The CLI checks -x destinations before the run.
Every run boots a new VM from the same configuration, so runs don't share
state. run_streaming delivers stdout/stderr as they arrive.
A sandbox has one of three network stacks, chosen with --network (or
.network(...), or network = in a manifest or in Python):
--network |
Sockets | Who runs TCP/IP |
|---|---|---|
none |
None. The default without net |
Nobody |
native |
The host's own, one per connection. What -a net gives |
The host |
vm |
The sandbox's own, in its kernel | The sandbox, as IP packets |
native and vm both grant net, and the same rules hold on both: the host
checks every connection, datagram and DNS query against your policy. On vm
a packet stops at a gateway on the host, which asks the same proxy for the same
connection, so what a program puts in a packet reaches nothing the policy
refuses, and packets it forges go no further. The VM has no network card on
either: vm's packets travel on the console it already has.
What a program notices on vm: a datagram to a refused address leaves and is
dropped, as a firewall would (no EPERM, though the denial is still reported),
and a connection takes more of the kernel's memory, so fewer fit (over 400 at
the default memory, each with 16 KiB buffers).
With Capability::Net, sockets work, but the VM still has no network card.
Each connection the program makes is made by the host on its behalf, after
a policy check:
let sandbox = Sandbox::builder()
.import("./fixtures/build/python", "/")?
.allow(Capability::Net)
.allow(Capability::Threads)
.allow_host("pypi.org")? // names, *.wildcards, IPs, CIDRs, each with an optional :port
.allow_host("*.pythonhosted.org:443")?
.allow_host("host.grix.internal:5432")? // the host's own 127.0.0.1:5432
.forward("127.0.0.1:8080", 8000)? // host 8080 -> sandbox port 8000
.forward_udp("127.0.0.1:5353", 53)? // UDP: host 5353 -> sandbox port 53
10000
.build()?;- Without
allow_hostrules, the program may connect anywhere except the host itself and special addresses: loopback, link-local such as cloud metadata at 169.254.169.254, multicast, IPv6 unique local addresses (fc00::/7, where AWS's and GCP's metadata services also are; IPv4's private ranges aren't special), and any IPv6 address that carries a special IPv4 one (NAT64, 6to4, Teredo and the like). With rules, only what they match, and DNS answers only names they cover, so lookups can't carry data out either. Special addresses need a rule for them specifically:0.0.0.0/0and::/0don't open them. - The host's own services are
host.grix.internal(10.0.2.2) and need a rule naming them. - Connections inside the sandbox (127.0.0.1,
::1, its own 10.0.2.15) stay in the sandbox, so a server and its client in one sandbox work with no rules. - DNS goes to the host's resolver: the sandbox gets an
/etc/resolv.confand/etc/hostsunless you import your own. - Denials come back as events:
denied connect (93.184.216.34:80 isn't in the allow-list): not allowed by the network policy (allow_host). - HTTPS needs CA certificates in the sandbox. Node has its own; for Python
or curl, add Alpine's
ca-certificates-bundleto the runtime (the test fixture has it). forward_listenertakes aTcpListeneryou bound (port 0 to let the OS pick), for tests;forward_udp_socketaUdpSocket.- A forwarded TCP port reaches a server listening on all addresses
(
0.0.0.0,::) or the sandbox's own (10.0.2.15), not one bound to127.0.0.1only, as with Docker's published ports: such a server is reachable only from inside the sandbox. - A forwarded UDP port's datagrams go to the socket bound at the guest
port (on any address or 10.0.2.15).
recvfromshows each sender's own address, except the host's loopback (127.0.0.1,::1), which is 10.0.2.2 (host.grix.internal) as everywhere in the sandbox; senders the sandbox would take for its own addresses are dropped. Replies to a sender leave from the forwarded port, but only to senders (the most recent 256 of each port): anything else the socket sends goes through the policy as any socket's does, so a forward doesn't open the host or anywhere else. Datagrams over one frame (about 4 KiB) are dropped on the way in.
The CLI has --allow-host RULE and -p/--publish [ADDR:]HOSTPORT:GUESTPORT[/udp] (Docker's spelling: -p 5353:53/udp);
manifests have allow_hosts = [...] and [[forward]] tables with host,
guest and protocol ("tcp", the default, or "udp"); Python's
publish takes the CLI's specs. TCP, UDP and ICMP echo are supported (other
raw sockets are reported as unsupported). On native a UDP socket's first
datagram to a destination waits for the host's say, and a refused one gets
EPERM, as from a firewall; only the destinations it sent to can answer it. Datagrams
between the sandbox's own sockets stay in it; ones larger than about 4 KiB
get EMSGSIZE (they'd be fragments on a real network).
grix mcp serves AI agents over the Model Context Protocol (stdio). Its
run tool runs a program in a fresh sandbox: imports, inline files, a
grix image, mounts, env, stdin, capabilities and exports. It answers with
the exit status, output, events, missing capabilities and the exported
files, and what writable mounts changed on the host. Its build_image
tool builds an image from a Grixfile. Agents get only what the server
allows:
grix mcp --root ~/projects/demo # host paths only under the roots (default: the current directory)
grix mcp -a fs_write -a process # capabilities agents may grant (default: fs_write, threads, process)
grix mcp -a net --allow-host pypi.org # net only if allowed, and only where the rules say
grix mcp --writable # rw/on_exit mounts and build_image (default: agents only read the host)
grix mcp --host-timezone # runs may ask for the host's time zone (default: UTC)
grix mcp --registry-credentials # build_image may pull with your registry logins (default: anonymously)
grix mcp --max-parallel 4 # tool calls at once (default 2; more wait their turn)
grix mcp --max-timeout 120 --max-mem 1024Tool calls run in parallel (up to --max-parallel). A call sent with a
progressToken gets the program's output (and a build's) as it comes, in
notifications/progress; notifications/cancelled stops its sandbox,
and it isn't answered. When stdin closes, calls in progress finish and
are answered before the server exits.
Images an agent runs get the server's kernel, and may ask only for
capabilities the server allows. Builds follow the same policy: their steps
get net (under the same host rules) and from may pull from a registry
only if the server allows net, anonymously unless it says
--registry-credentials, and only from hosts its rules allow (the
registry, its token service and where it sends downloads, such as
registry-1.docker.io, auth.docker.io and
production.cloudflare.docker.com for Docker Hub); docker: sources and
registries on the host itself are refused.
For example, in a Claude Code or other MCP client config:
{"mcpServers": {"grix": {"command": "grix", "args": ["mcp", "--root", "/path/to/project"]}}}Python: make python builds grix's native module (PyO3) into the
package, and pip install ./python installs it (a release has it built,
in python/). Runs happen in the Python process, with the GIL released, so
threads run sandboxes side by side; without the module the package drives
the grix command instead, the same way.
import grix
sb = grix.Sandbox(imports={"./busybox": "/bin/busybox"}, files={"/data/in.txt": "b\na\n"},
capabilities=["process"], timeout=30)
r = sb.run(["/bin/busybox", "sort", "/data/in.txt"])
print(r.exit_code, r.stdout, r.events, r.missing_capabilities)
# Output as it comes, and a run stopped from another thread (or by Ctrl-C).
control = grix.Control()
r = sb.run(["/bin/busybox", "sh", "-c", "tail -f /dev/null"], control=control,
on_output=lambda stream, data: print(stream, data))
# elsewhere: control.signal(15), control.stop(), control.cancel()
grix.build("app.grix", grixfile="Grixfile") # images too
print(grix.Sandbox(image="app.grix").run().stdout)A runtime that takes seconds to start can be started once and snapshotted the moment it first waits for input. Each run from the snapshot then goes on from there, with its own stdin, in a tenth of a second:
# Python with its libraries imported, waiting for code on stdin: 2.6 s, once.
grix snapshot -o py.snap -a threads -i fixtures/build/python:/ -- \
/usr/bin/python3 -c 'import json, decimal, asyncio, sys; exec(sys.stdin.read())'
echo 'print(json.dumps({"sum": sum(range(10))}))' | grix run --snapshot py.snap # 0.13 slet snap = sandbox.snapshot(&["/usr/bin/python3", "-c", "...; exec(sys.stdin.read())"], "py.snap")?;
let out = snap.run("print(6 * 7)")?; // or grix::Snapshot::open("py.snap")?Also from MCP (the snapshot tool, and run's snapshot) and Python
(Sandbox.snapshot(), Snapshot.run()).
- The VMM saves every vCPU (a sandbox with more CPUs has them all in each run), the GIC, the console device and RAM. A run maps that RAM copy-on-write from the file, so runs never see each other's changes, and only what a run touches is read. The file is sparse: 80 MB on disk for that Python.
- Each run gets fresh entropy for the kernel's random number generator.
As after a suspend, the time of day moves on by however long the
snapshot sat there, and the monotonic clock doesn't (so a
read -t 5that was waiting still has its 5 seconds). What the program drew for itself before the snapshot (a random seed, say) is the same in every run. - The snapshot is taken once the program has found stdin empty and waits
(or, in TTY mode, its terminal:
grix snapshot -t, thengrix run --snapshot FILE -t, which sends the terminal's size). It waits until the program has no connections or DNS lookups open through the host, whose part isn't saved. Live mounts are (the names the host gave ids, and what the guest first saw of lazy imports): each run gives them host directories again (--mount HOST:GUEST[:ro],Snapshot::mount), and what the guest knew of them is fetched again. inotify watches on them are set up again too, with anIN_Q_OVERFLOWfor what changed while the snapshot sat there. Sandboxes with on-exit mounts can't take one. - The file holds the program's memory, so it's created readable only by you, and written fully before it replaces the old one.
- Snapshots need grix-vmm: Hypervisor.framework on macOS 15+, KVM on
arm64 Linux 6.4+ (for its SMCCC filter), or KVM on x86_64 Linux. A
snapshot runs only on the kind of host that took it. A file may be
anyone's, so runs from it get the network only as an image's program
does: with
-a net(and--allow-host,--publishas for any run;Snapshot::allow_net,forward,forward_udp), never the file's own rules or forwarded ports (TCP or UDP). Over MCP they get the server's. Output, export and grace limits are a sandbox's defaults (or the file's, if lower) unless set, and never more than the file's; the time limit is the file's or the default's, whichever is shorter, unless--timeoutsays.
Every run says what it cost the host and what its kernel saw:
RunOutput::host_usage(host_usagein--json): the VM process's CPU time and peak resident memory, measured by the host when it ends.RunOutput::guest_stats(guest_stats): what the guest kernel reports of itself as the program ends. That covers the program's CPU time, system calls, page faults, context switches and forks. For memory, kernel memory, file data, file metadata, processes and threads, it gives what's in use now, the peak, and the limit. And for each kernel limit (threads,file_data,open_files,kernel_memory, ...), how often it refused something (limits_hit). Over MCP, the limits a program ran into are in the answer. It's the guest's own say-so, which a program that took over its kernel could make anything: alert and bill onhost_usage.
Metrics counts runs for Prometheus. grix run --metrics ADDR and grix mcp --metrics ADDR serve them at http://ADDR/metrics:
let metrics = grix::Metrics::new();
metrics.serve("0.0.0.0:9090")?; // or metrics.render(), the text
let sandbox = Sandbox::builder().import("./server", "/bin/server")?.metrics(&metrics).build()?;| Metric | What |
|---|---|
grix_runs_started_total, grix_runs_active |
runs started, and in progress |
grix_runs_total{status} |
runs ended: ok, exit_nonzero, signaled, timeout, stopped, kernel_panic, vm_failed, error |
grix_run_duration_seconds |
histogram of how long runs took |
grix_events_total{kind,capability} |
denied (by capability, or allow_host), unsupported, missing_path, fault, exec_failed, stopped, continued |
grix_output_bytes_total{stream} |
what programs wrote to stdout and stderr |
grix_vmm_cpu_seconds_total{mode}, grix_vmm_peak_memory_bytes |
the VM processes' cost, measured by the host |
grix_guest_resource_used{resource}, grix_guest_resource_limit{resource} |
what running sandboxes use now, and have (bytes, or a count for processes and threads) |
grix_guest_peak_usage_ratio{resource} |
histogram of each run's peak as a fraction of its limit |
grix_guest_limit_hits_total{limit} |
times a kernel limit refused a program something |
grix_guest_cpu_seconds_total, grix_guest_syscalls_total, grix_guest_page_faults_total, grix_guest_context_switches_total, grix_guest_processes_started_total |
what programs did |
While a run goes on, the host asks its kernel for a report every 5
seconds, so the grix_guest_* gauges and counters move during long runs.
They show what the program uses now. A container's memory metric can't:
guest memory the program frees stays resident in the VMM's process until
the run ends. Every label comes from a fixed set (a capability name the
guest makes up is other), so no program can make the series grow.
grix run can be a container's command, as for a server in Kubernetes
(the Dockerfile makes grix's image):
grix run --timeout 0 --metrics 0.0.0.0:9090 -p 0.0.0.0:8080:8000 -a net -a threads --image app.grix--timeout 0: no time limit (the default is 60 seconds).- The program's output goes to grix's stdout and stderr as it comes, all of
it (
--max-output MIBcaps that), and grix keeps none of it. - Events (
grix: denied ...) are printed as they happen. - Forward ports on
0.0.0.0: probes and Services connect to the pod's address, not its loopback. - Kubernetes' SIGTERM goes to the program, and grix's exit code is the program's (128 + signal if a signal killed it, 125 if the sandbox failed).
- The container's memory limit needs room for the guest's (
--mem, 512 MiB by default) and grix's own. - The image (FROM scratch: grix, grix-vmm and the kernels) runs the
machine on KVM, so the container needs
/dev/kvm(docker run --device /dev/kvm). - Where there may be no KVM, the
Dockerfile'skvm-optionaltarget (docker build --target kvm-optional -t grix:kvm-optional .) adds QEMU: grix uses KVM when the container has/dev/kvm, and emulates the machine when it doesn't, saying so (grix: no KVM ...;GRIX_ACCEL=tcgchooses emulation, without the notice). Emulated, it boots as quickly, but programs run 20–40× slower than natively (a Python loop 20×, Node's startup 30×), and QEMU doesn't count its emulator as a security boundary, so the container is the boundary to rely on.
There's no need for KubeVirt: grix's VM runs inside an ordinary pod,
and the pod only needs /dev/kvm, which a device plugin provides.
examples/kubernetes has one, and a Deployment whose pods ask for it:
kubectl apply -f examples/kubernetes/kvm-device-plugin.yaml # once per cluster
kubectl apply -f examples/kubernetes/grix.yaml # with your image in it-
kvm-device-plugin.yamlruns squat/generic-device-plugin on every node, unprivileged. A pod chooses with its image and limits:image limit runs on gets grixdevic.es/kvm: 1(grix.yaml's)only nodes with KVM (Pending where there are none) /dev/kvmgrix:kvm-optionaldevic.es/kvm-optional: 1any node /dev/kvmif the node has it, and grix emulates if notgrix:kvm-optionalnone (and GRIX_ACCEL=tcg)any node, with no device plugin emulation Either way the pod gets no other device of the node's, so it needn't be privileged:
grix.yaml's has no capabilities and a read-only root (GRIX_CACHEon anemptyDir). A privileged pod with ahostPathof/dev/kvmworks too, but then the pod has the whole node. -
Nodes with KVM are bare-metal machines, or VMs with nested virtualization, which most clouds offer only on some machine types. In a cluster with both kinds, nothing makes a
kvm-optionalpod prefer the nodes with KVM. -
grix listens on a published port before the program does, so make the readiness probe ask the program (
httpGet), not just open the port.
A grix image is a file tree, the settings to run it with, and the grix
kernel, in one file (.grix, a gzipped tar) you can copy anywhere grix
runs. Build one from a Docker/OCI image and your own files, or from
nothing but the grix kernel and a static program: no other OS needed.
# From Docker Hub's python image, with a dependency installed at build time:
cat > Grixfile <<'TOML'
from = "python:3.12-alpine"
command = ["python3", "/app/main.py"]
capabilities = ["threads"]
run = ["pip install --no-cache-dir requests"]
[[copy]]
host = "app"
guest = "/app"
TOML
grix build -o app.grix
grix run --image app.grix # its command, settings and kernel
grix run --image app.grix -- python3 -V # or another command
# Nothing but grix and one static program, without a Grixfile:
grix build -o hello.grix --copy ./hello:/bin/hello -- /bin/hello
# For the other architecture than this host's (it runs emulated):
grix build --arch x86_64 --from alpine:3.21 -o alpine-x86_64.grix
grix run --image alpine-x86_64.grix -a process -- uname -m # x86_64-
An image is for one architecture, arm64 or x86_64: its kernel's, and its programs'.
grix buildbuilds for the host's (orGRIX_ARCH's, or--arch aarch64|x86_64), pulling the Docker/OCI image'slinux/arm64orlinux/amd64variant and packing that architecture's kernel, so an image built on an x86_64 Linux host runs there natively on KVM.grix run --imageruns an image on its own architecture whatever the host is: in grix-vmm where that's the host's, in QEMU (emulated) otherwise;--archorGRIX_ARCHnaming another is an error.grix inspectshows it. A base (image:FILE) must be for the build's architecture, and an image whose command or/bin/shis another architecture's than its kernel is refused (as older grix built them on x86_64 hosts: build it again). -
fromtakes a registry reference (alpine:3.21,ghcr.io/owner/repo:tag; the build's architecture's image),oci:DIR(an OCI image layout),docker-archive:FILE(docker saveoutput),docker:NAME,dir:DIR, orimage:FILE(another grix image: its files and settings, to build on). Layers (tar, gzip or zstd) are applied with their whiteouts (without setuid and setgid bits, which nothing in the sandbox honours), every blob is checked against its digest, and the image's entrypoint, command, environment and working directory become the grix image's, and so does its user (root unless it names one, as in Docker). Its/etc/hosts,/etc/hostnameand/etc/resolv.confare left out, as Docker's runtime puts its own over them: the kernel makes them, and its/etc/hostsnames the sandbox (grix, at 10.0.2.15). A command that's a bare name is looked up on the image'sPATH.grix pull REF --oci DIR [--arch ARCH]saves an image (the host's architecture's by default) for offline builds.docker:NAMEasks Docker for the architecture's (docker save --platform). -
Private images:
grix login ghcr.io -u NAME --password-stdin(Docker Hub isdocker.io, the default) saves a login in~/.config/grix/auth.json; Docker's and Podman's logins (and credential helpers) work too. A login goes only to the registry that asks for it, and its token service if that's on the registry's own site (its host, a parent domain, or a host beside it:auth.docker.ioforregistry-1.docker.io), over TLS (or to a registry on this machine, which may speak plain HTTP); another token service is asked without it. A pull connects as the build's steps may (--allow-host), never to special addresses (this machine, cloud metadata) unless a rule names them, whatever names resolve to them; a registry on this machine is the exception, where builds allow one. The caches are private (0700). -
Builds are cached in
~/.cache/grix/trees(at most 10 GiB, orGRIX_TREE_CACHE_MB): what an image's layers add up to, and what each build step left, by everything that went into it (the files before it, the step, its environment, user and working directory, the kernel, the architecture and the network rules). A step whose inputs haven't changed doesn't run again;grix build --no-cacheruns them all. -
Build steps (
run) run in grix itself, withfs_write,process,threadsandnet, and three times the image's size plus 512 MiB of memory, ormemory_mbif that's more (up to 4 GiB unlessBuildPolicy::step_memory_mbsays otherwise; files written take at most half). A string runs with/bin/sh -c; a list such as["/bin/setup", "--fast"]runs as it is, without a shell. The file tree a step leaves is the image's, minus what the kernel makes at boot (/etc/passwd,/tmp, ...).apt-get installworks in a Debian or Ubuntu image.user = "uid[:gid]"(or names from the image's/etc/passwd) says who the program runs as; build steps run as the base image's user. Without one or the other, it's 1000:1000. Ids mean something only inside the sandbox: root may switch to any user (setuidand friends follow Linux's rules), and nothing honours setuid bits. -
grix buildflags (--from,--arch,--copy,--env,--cwd,--allow,--run,--exec,--mem,-- COMMAND) add to./Grixfile, or make the whole image without one. Like Docker's build context, a Grixfile reads host files only under its own directory; paths given as flags may be anywhere. -
grix inspect app.grixshows the settings. From Rust:Sandbox::builder().image(&grix::Image::open("app.grix")?)?, thensandbox.run(sandbox.command()). A manifest can sayimage = "app.grix". -
An image runs only with a grix of the same ABI version as its kernel (the kernel carries a marker that grix checks). Running it grants the capabilities it lists that stay in the sandbox (
threads,process,fs_write).netandnet_rawit can only ask for: grant them with-a net/allow(Capability::Net). Its kernel runs inside the VM like any other:net, the network policy, mounts and exports are checked by the host, whatever the guest kernel does.
The same sandbox written as TOML (grix run -m file.toml [-- cmd...]):
command = ["/bin/busybox", "sha256sum", "/data/hello.c"]
capabilities = [] # fs_write, threads, process, net, net_raw
# network = "vm" # none, native (what net gives) or vm: TCP/IP in the sandbox
on_deny = "errno" # "errno" (EPERM) or "kill" (SIGSYS, like seccomp)
# allow_hosts = ["pypi.org", "*.pythonhosted.org"] # with net: only these
memory_mb = 256
timeout_secs = 30 # 0: no time limit
[env]
LANG = "C"
[[import]]
host = "../fixtures/build/alpine/bin/busybox.static" # relative to the manifest
guest = "/bin/busybox"
[[export]] # copied out when the program ends (needs fs_write to fill it)
guest = "/out"
host = "results" # relative to the manifest; must not exist yet, or be empty
# [[mount]] # live; mode = "rw" (default), "ro" or "on_exit"
# host = "../my-project"
# guest = "/work"
# [[forward]] # with net: host 127.0.0.1:8080 -> port 8000 in the sandbox
# host = "127.0.0.1:8080"
# guest = 8000
# protocol = "udp" # UDP datagrams instead ("tcp" by default)Anything that is a file. import copies host files and directories
(recursively, keeping modes and symlinks) into the sandbox's filesystem:
- Programs: any AArch64 Linux executable, static or dynamic. For a dynamic one, also import its loader and libraries.
- Whole runtimes:
tools/alpine.py --out DIR pkg...resolves Alpine packages and their dependencies into a directory you import at/. This is how the Python and Node fixtures are built. The same works forruby,perl,lua,git,sqlite,jq, ... - Libraries and code: a
site-packagesornode_modulestree, a project checkout, scripts written by the agent (file()takes inline contents). - Data and config: inputs, CA certificates (
/etc/ssl), timezone data,/etc/passwdstubs.missing ...events tell you what a program looked for and didn't find.
Everything imported is read-only and lives in guest memory, so total size is
bounded by memory_mb (Python's root is 40 MB, Node's 73 MB).
| What | Default | How to get more |
|---|---|---|
| Files | Only imported paths exist (read-only), plus /dev/{null,zero,random,urandom}, /proc/self/exe, empty /tmp |
import / file; mount / mount_read_only for a live host directory |
| Environment | Empty | env |
| Memory, time, randomness, stdout/stderr | Always available | memory_mb, timeout limit them |
Local IPC: pipes, eventfd, epoll, AF_UNIX sockets (socket pairs, abstract names; a path needs fs_write) |
Always available | — |
fs_write |
Denied: the filesystem is read-only | allow(Capability::FsWrite): the whole tree becomes writable in guest memory (copy-on-write over the imports, up to half of RAM). The host's files never change. |
threads |
Denied | allow(Capability::Threads) |
process (fork/exec/wait) |
Denied | allow(Capability::Process) |
net |
Denied, and the VM has no network device | allow(Capability::Net): TCP, UDP and DNS through the host, which checks allow_host rules; forward and forward_udp for servers |
| Privileged (mount, ptrace, bpf, module loading, capabilities, …) | Always denied | — |
A program can't get around the policy by making the call itself (with
syscall() or inline assembly). The policy is enforced by the kernel the
program runs on.
Events tell you what happened:
denied <syscall>: needs capability <cap>: blocked by policy.missing <path>: not imported: the program looked for a file you didn't provide.unsupported <syscall>: granted (or harmless), but grix doesn't implement it yet.killed by signal N: crash or abort, with the faulting address.could not start: missing or invalid program.
host process (your agent) microVM (grix-vmm or QEMU, 1 vCPU)
┌──────────────────────────────┐ ┌─────────────────────────────────┐
│ grix crate │ boot image │ grix kernel (no_std Rust) │
│ - collects imports │ ───────────────► │ - boots at EL1, high-half MMU │
│ - encodes policy+argv+env │ @0x4800_0000 │ - reads image, builds RO fs │
│ - starts the VMM │ │ - loads ELF (+ interpreter) │
│ - decodes framed console │ ◄─────────────── │ - runs program at EL0 │
│ stdout/stderr/events/exit │ virtio frames │ - Linux syscall ABI + policy │
└──────────────────────────────┘ └─────────────────────────────────┘
- One program, one address space. The kernel implements the Linux AArch64
syscall ABI directly (
kernel/src/syscall/). Memory is demand-paged. ELF segments are paged in straight from the boot image, and anonymous memory is zero-filled on first touch. - The filesystem is the import list. It's built in memory from the boot image. File contents aren't copied: they're referenced in place.
- Talking to the host. Everything the guest says (program output, kernel log, events, exit status) travels in length-prefixed frames on a virtio console, and stdin comes back the same way: tens of MB/s, with backpressure in both directions. Early boot messages and panics use the serial port. The host treats both streams as untrusted input.
- The VM's devices. The VM gets RAM, the virtio console, a serial port, and nothing else: no network card, no disks, no QEMU monitor, and no semihosting (which would expose host files).
- Its own VMM.
grix-vmm(crates/vmm) makes that machine on Hypervisor.framework or KVM: one vCPU, the hypervisor's own GICv3, a PL011 and the virtio console, emulated in a few hundred lines. It runs as a child process like QEMU would, and once the VM is set up it confines itself (a deny-everything macOS sandbox; a seccomp allowlist on Linux): no files, no network, no new processes, only its stdin, stdout and the serial socket. - Host directories without a disk. A live mount is served by the host over the console too: the kernel asks for listings and bytes as the program needs them, and sends its changes as it makes them.
- Networking without a network card. The guest kernel turns sockets into messages on the console. The host makes the connections (after the policy check), forwards TCP and UDP ports, and answers DNS. Flow control is by credit in both directions, so a slow connection never stalls the console.
The isolation boundary is the hypervisor. A program that fully compromised the grix kernel would still be inside a VM with no network device, no disk, and only the imported files in its memory. The guest kernel's policy checks are what make the sandbox least privilege and observable, but they aren't the only wall.
Where it runs (arm64 macOS 15+, and Linux with KVM), the VMM is grix's own: small, and confined itself, so a guest that escaped the hypervisor into it would still have no files or network. Elsewhere it's QEMU, which is large. Without hardware virtualization there's no hypervisor either: QEMU's emulator (TCG) isn't a security boundary by QEMU's own account, so the walls are grix's kernel and whatever holds QEMU, such as a container. Either way, don't treat grix as hardened against a determined attacker yet: it hasn't had an outside security review.
Linux compatibility means binary compatibility: unmodified AArch64 Linux ELF executables, the same files you'd run on an arm64 Linux machine.
| Works today | Not yet |
|---|---|
Static, static-PIE and dynamic executables, musl and glibc (Debian's bash, coreutils, perl, apt, git, curl; the official python and node images) |
|
Terminals: pseudo-terminals (/dev/ptmx, /dev/pts/N, Python's pty), termios, controlling terminals and /dev/tty, TTY mode (grix run -t, tty) with an interactive shell's job control |
Packet mode (TIOCPKT), TIOCSTI; VMIN and VTIME together return at the first byte; orphaned process groups aren't treated specially |
argv/envp/auxv, HWCAP, ID registers read from user mode on arm64 (emulated as Linux's HWCAP_CPUID, sanitized to what HWCAP says), AT_RANDOM, TLS, stdin from the host (run_with_stdin, or piped into the CLI), output limits (max_output), fast stdio with backpressure (a slow reader blocks the writer, or EAGAIN) |
Raw sockets, ICMP (ping); UDP datagrams over about 4 KiB |
With net: TCP clients and servers (AF_INET/AF_INET6, blocking and non-blocking, epoll, SO_ERROR, SO_RCVTIMEO/SO_SNDTIMEO, half-close), UDP (each destination asked of the host once), DNS through the host, loopback inside the sandbox, TCP and UDP ports forwarded from the host, an allow-list |
Socket options beyond timeouts (SO_RCVBUF and TCP options are accepted and ignored); bandwidth limits |
Interface listing: netlink route sockets, getifaddrs, if_nameindex, interface ioctls, /proc/net/dev, /proc/net/route and /sys/class/net report lo, and with net also eth0 (10.0.2.15/24 and fd00::15/64, the default routes via the host, 10.0.2.2 and fd00::2), which describes the host's proxy: there's no device. SIOCETHTOOL answers as a veth |
Interface counters |
Threads: clone/clone3, futexes (incl. requeue, wake-op, timeouts), preemption, deadlock detection |
|
With process: fork/vfork/posix_spawn (copy-on-write), execve of imported and written programs and #! scripts, wait4/waitid, SIGCHLD, zombies and orphans, process groups and sessions, MAP_SHARED anonymous memory shared across fork. Shell pipelines, Python subprocess, Node child_process, Go os/exec. Job control: stopping and continuing, WUNTRACED/WCONTINUED, CLD_STOPPED. pidfds (pidfd_open, pidfd_send_signal, pidfd_getfd, CLONE_PIDFD, waitid(P_PIDFD)). clone/clone3's CLONE_FILES, CLONE_FS, CLONE_SIGHAND (with vfork), CLONE_PARENT, CLONE_CLEAR_SIGHAND, and unshare(2) of them. /proc/<pid> for every process (ps), zombies included until they're reaped |
CLONE_VM without vfork or threads; new namespaces |
Linux AIO (io_setup, io_submit of reads, writes, syncs and polls, io_getevents/io_pgetevents, io_cancel): requests complete as they're submitted, into Linux's ring in the program's memory, with eventfd notification |
io_uring (ENOSYS on purpose, as Docker's default seccomp profile has it); AIO on a pipe or socket that isn't ready completes with EAGAIN instead of waiting |
Pipes, eventfd, epoll (level/edge/oneshot), poll/select, AF_UNIX sockets (streams, datagrams and seqpackets; paths, abstract and autobound names; SO_PEERCRED; descriptors passed with SCM_RIGHTS, to other processes too), signalfd, memfd_create (with se
843D
als), named pipes (mkfifo), System V shared memory, O_ASYNC (SIGIO to an owner, F_SETSIG), sendfile to pipes and sockets |
System V semaphores and message queues |
Timers: alarm/setitimer (SIGALRM, and ITIMER_VIRTUAL/ITIMER_PROF on CPU time), POSIX timers (signal values, overruns, SIGEV_THREAD_ID, absolute times; on CPU-time clocks too), timerfd (blocking, non-blocking, epoll) |
Timers on another process's CPU clock |
Signal handlers: Linux arm64 frames with FP/SIMD state, SA_SIGINFO/SA_RESTART/SA_ONSTACK/SA_NODEFER/SA_RESETHAND, sigaltstack, per-thread masks, queued real-time signals, sigsuspend/sigtimedwait, faults delivered to handlers, EINTR and restart of blocked calls |
|
seccomp self-sandboxing: strict mode and classic-BPF filters (seccomp(2), prctl(PR_SET_SECCOMP), PR_SET_NO_NEW_PRIVS), every action (TRAP's SIGSYS with si_syscall, ERRNO, KILL_THREAD/KILL_PROCESS, ...), stacking, TSYNC, inherited across fork, threads and execve. Filters run after the policy's gates and can only take away |
User notification (SECCOMP_RET_USER_NOTIF, SECCOMP_FILTER_FLAG_NEW_LISTENER: reported) |
Signals from the host: RunControl::signal and the CLI's Ctrl-C reach the program's process group (SIGTSTP and SIGCONT stop and continue it); a timeout sends SIGTERM and a grace period first, so exports still come back |
|
read/write/readv/writev/pread/lseek, openat, openat2 (every RESOLVE_* flag), stat*, getdents64, readlink, fcntl, dup*, ioctl; the host's file times. Up to 65,536 descriptors (RLIMIT_NOFILE is 1024, raisable to 65536, as Docker gives) |
|
Live host directory mounts (mount, mount_read_only, --mount), of any size; or changes applied when the program ends (mount_on_exit). inotify events for changes there, the host's too (fs.watch, watchdog), with rename cookies |
Events for a change the program makes arrive just after its call returns |
With fs_write: creating, writing, truncating, renaming and deleting files and directories, hard and symbolic links, O_TMPFILE, unlinked-but-open files, chmod/chown/utimensat with Unix permissions, sendfile/copy_file_range, mmap of written files, MAP_SHARED file mappings (the file's own pages, across fork too). flock, POSIX record locks and OFD locks, between processes (SQLite in WAL mode from several). Exporting directories back to the host when the program ends (export, --export). Extended attributes (user.*; trusted.* and security.* for root): cp -a, tar --xattrs |
Writable MAP_SHARED mappings of live-mounted files (reported); locks the host sees; inotify events for the sandbox's own files; xattrs on live mounts (EOPNOTSUPP), in exports, and POSIX ACLs |
mmap/munmap/mprotect/mremap/madvise/brk (lazy paging), a stack as big as RLIMIT_STACK says; more CPUs (--cpus): threads and processes run in parallel |
MAP_GROWSDOWN; finer locking than one kernel lock |
Clocks (including per-thread and per-process CPU time; a vDSO reads them without a system call), blocking sleeps, getrandom (ChaCha20, host-seeded + RNDR), membarrier, mincore |
|
| Unhandled faults and signals end the program with the signal (SIGSEGV/SIGILL/SIGTRAP/SIGBUS/SIGPIPE/...) | |
x86_64 guests (--arch x86_64, GRIX_ARCH, SandboxBuilder::arch): the same kernel built for x86_64, in grix-vmm on x86 KVM or in QEMU microvm, with x86_64's system calls (the older ones too) and structures; every end-to-end test passes on it, CPython and Node included, and snapshots in grix-vmm |
The old getdents and utime* calls |
Verified programs: the official images of nginx, Apache httpd, Caddy,
HAProxy, Traefik, PostgreSQL, MariaDB, Redis, Memcached, MongoDB,
RabbitMQ, etcd, Prometheus and Elasticsearch, and Debian's and Alpine's
toolchains and tools (see COMPAT.md); the C fixtures in
fixtures/src; Alpine's busybox (static
and dynamic: sha256sum, sort, grep, awk, ls, sh builtins, …);
Alpine's python3 (threads, asyncio, hashlib, json, signal handlers,
KeyboardInterrupt, tempfile, shutil, an on-disk sqlite database) and
nodejs (timers, crypto, fs reads and writes, worker_threads,
process.on('SIG…'), os.networkInterfaces(), child_process, http
servers and fetch); Python urllib and asyncio servers; busybox wget and
nslookup; static Go binaries
(goroutine preemption, nil dereference panics, os/signal, os/exec); busybox
cp -r, mv, rm -rf, tar, dd, find, and sh scripts with pipelines and
subshells.
Networking: done. Sockets are proxied through the host, which enforces the allow-list and forwards ports.--network vmruns TCP/IP in the guest instead, behind the same proxy.Host directories: done. Live mounts (read-write or read-only, any size) and mounts whose changes are applied when the program ends.Signals from the host: done (RunControl, Ctrl-C in the CLI, SIGTERM and a grace period on timeout).Own VMM:grix-vmmon Hypervisor.framework and KVM is done (the default where it runs: about twice as fast to start as QEMU, and confined), with snapshots.Agent integrations: images (grix build), the MCP server, Python bindings and snapshots for instant start are done.SMP and x86_64 guests: more CPUs (--cpus N, threads of a process run in parallel) and x86_64 programs, through a user-mode translator in the arm64 sandbox (--x86-64 qemu-x86_64: x86_64 Alpine and CPython run). A native x86_64 kernel runs in grix-vmm on x86 KVM or in QEMU microvm (--arch x86_64), and releases include x86_64 Linux, with AVX and AVX-512 state, more CPUs and snapshots.
crates/abi/ shared host<->guest definitions: boot image, policy, wire protocol (no_std)
crates/grix/ host library + `grix` CLI (builds images, runs the VMM, decodes output)
crates/netstack/ the vm network stack: TCP/IP for the guest kernel and the host's gateway
crates/vmm/ grix-vmm: the VMM (Hypervisor.framework, KVM), PL011 + virtio console
kernel/ the guest kernel (no_std, aarch64-unknown-none-softfloat and x86_64-unknown-none)
src/arch/ per architecture (aarch64/, x86_64/): boot, traps, page tables, interrupts, signal frame, ABI layouts
src/mm/ frame allocator, page tables, address spaces / VMAs
src/sched.rs threads: round-robin scheduler, blocking, timer interrupts
src/signal.rs signal queues, rt_sigframe delivery, rt_sigreturn, EINTR/restart
src/timer.rs setitimer, POSIX timers and timerfds, fired by the scheduler
src/netlink.rs NETLINK_ROUTE answers and interface ioctls: lo, and eth0 with net
src/kmem.rs the budget for kernel memory programs can make the kernel hold
src/fs.rs the filesystem: imports plus copy-on-write pages, permissions, size limits
src/ipc.rs pipes, eventfds, socket pairs
src/syscall/ Linux syscall table (+ capability gates) and implementations
fixtures/ C (src/, built with zig) and Go (go/) test programs; build/ also gets Alpine runtimes
tools/alpine.py Alpine package resolver for building importable root filesystems
examples/ example manifests, and Kubernetes manifests (kubernetes/)
See AGENTS.md for contributor and agent guidelines.