8000 GitHub - gmmeyer/grix · GitHub
[go: up one dir, main page]

Skip to content
gmmeyerPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

grix

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 imported

Status: 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.

Quick start

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 8000

On 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.

Using it from an agent (Rust)

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 host

Signals and timeouts

A 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 and mount_on_exit changes come back. The status is Timeout either way. A server has none: no_timeout() (--timeout 0, a manifest's timeout_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 SECS and the manifest's grace_secs set the grace period.
  • Job control works as on Linux: SIGTSTP (or SIGSTOP, SIGTTIN, SIGTTOU) stops a process and SIGCONT continues it, wait reports both (WUNTRACED, WCONTINUED), and the parent gets SIGCHLD. From the host, c.signal(20) stops the program's process group and c.signal(18) continues it; GuestEvent::Stopped and Continued say when the program did. RunControl::on_event sees events as they happen.

Terminals

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.

Working on a host directory

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_APPEND writes go to the host file's end;
    • fsync reaches 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 chown can't change it.
  • Any size. Runtimes work from a live mount too: Python runs entirely from read-only mounts of its /lib and /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_write are 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.

Networking

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_host rules, 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/0 and ::/0 don'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.conf and /etc/hosts unless 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-bundle to the runtime (the test fixture has it).
  • forward_listener takes a TcpListener you bound (port 0 to let the OS pick), for tests; forward_udp_socket a UdpSocket.
  • 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 to 127.0.0.1 only, 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). recvfrom shows 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).

MCP server and Python

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 1024

Tool 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)

Snapshots

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 s
let 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 5 that 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, then grix 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 an IN_Q_OVERFLOW for 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, --publish as 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 --timeout says.

Monitoring

Every run says what it cost the host and what its kernel saw:

  • RunOutput::host_usage (host_usage in --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 on host_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.

In a container

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 MIB caps 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's kvm-optional target (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=tcg chooses 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.

In Kubernetes

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.yaml runs squat/generic-device-plugin on every node, unprivileged. A pod chooses with its image and limits:

    image limit runs on gets
    grix devic.es/kvm: 1 (grix.yaml's) only nodes with KVM (Pending where there are none) /dev/kvm
    grix:kvm-optional devic.es/kvm-optional: 1 any node /dev/kvm if the node has it, and grix emulates if not
    grix:kvm-optional none (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_CACHE on an emptyDir). A privileged pod with a hostPath of /dev/kvm works 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-optional pod 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.

Images

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 build builds for the host's (or GRIX_ARCH's, or --arch aarch64|x86_64), pulling the Docker/OCI image's linux/arm64 or linux/amd64 variant and packing that architecture's kernel, so an image built on an x86_64 Linux host runs there natively on KVM. grix run --image runs an image on its own architecture whatever the host is: in grix-vmm where that's the host's, in QEMU (emulated) otherwise; --arch or GRIX_ARCH naming another is an error. grix inspect shows it. A base (image:FILE) must be for the build's architecture, and an image whose command or /bin/sh is another architecture's than its kernel is refused (as older grix built them on x86_64 hosts: build it again).

  • from takes 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 save output), docker:NAME, dir:DIR, or image: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/hostname and /etc/resolv.conf are left out, as Docker's runtime puts its own over them: the kernel makes them, and its /etc/hosts names the sandbox (grix, at 10.0.2.15). A command that's a bare name is looked up on the image's PATH. grix pull REF --oci DIR [--arch ARCH] saves an image (the host's architecture's by default) for offline builds. docker:NAME asks Docker for the architecture's (docker save --platform).

  • Private images: grix login ghcr.io -u NAME --password-stdin (Docker Hub is docker.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.io for registry-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, or GRIX_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-cache runs them all.

  • Build steps (run) run in grix itself, with fs_write, process, threads and net, and three times the image's size plus 512 MiB of memory, or memory_mb if that's more (up to 4 GiB unless BuildPolicy::step_memory_mb says 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 install works 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 (setuid and friends follow Linux's rules), and nothing honours setuid bits.

  • grix build flags (--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.grix shows the settings. From Rust: Sandbox::builder().image(&grix::Image::open("app.grix")?)?, then sandbox.run(sandbox.command()). A manifest can say image = "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). net and net_raw it 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.

Manifests

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)

What you can import

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 for ruby, perl, lua, git, sqlite, jq, ...
  • Libraries and code: a site-packages or node_modules tree, a project checkout, scripts written by the agent (file() takes inline contents).
  • Data and config: inputs, CA certificates (/etc/ssl), timezone data, /etc/passwd stubs. 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).

Capability model

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.

How it works

 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.

Security model

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.

Compatibility

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.

Roadmap

  1. Networking: done. Sockets are proxied through the host, which enforces the allow-list and forwards ports. --network vm runs TCP/IP in the guest instead, behind the same proxy.
  2. Host directories: done. Live mounts (read-write or read-only, any size) and mounts whose changes are applied when the program ends.
  3. Signals from the host: done (RunControl, Ctrl-C in the CLI, SIGTERM and a grace period on timeout).
  4. Own VMM: grix-vmm on Hypervisor.framework and KVM is done (the default where it runs: about twice as fast to start as QEMU, and confined), with snapshots.
  5. Agent integrations: images (grix build), the MCP server, Python bindings and snapshots for instant start are done.
  6. 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.

Repository layout

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0