Linux sandboxing
The supervisor starts the VM worker and, with networking enabled, the network
broker under Bubblewrap, which ships inside the terra executable. Every role
also runs under seccomp filters; host process sandboxing
covers what each role holds.
Process layout
terra (CLI) your user, unconfined
└─ terra __supervisor role filter + allowlist once the workers
│ start; holds the run lock and listeners
├─ bwrap ─► __vm own user, PID, mount, IPC, UTS and
│ network namespaces; role filter and its
│ allowlist; /dev/kvm, box state, shares
└─ bwrap ─► __network own user, PID, mount, IPC and UTS
namespaces; host network; role filter
and its allowlist; no /dev, no /tmp
__vm ◄── socket pair (fd 7) ──► __network
__supervisor ── startup channel (fd 8) ──► __network
__supervisor ── run lock (fd 3) ──► __vm
terra (CLI) ── agent and control sockets ──► __vm
Namespaces and capabilities
- Both workers get separate mount, user, PID, IPC and UTS namespaces and drop
all capabilities. The VM also gets a private network namespace, so its native
sockets cannot reach host networking; the broker keeps the host network
namespace.
- The broker gets no
/dev device nodes and no /tmp. It does get /proc,
where /proc/net lists the host's connections and Unix socket names.
- The launcher clears the inherited environment and forwards only diagnostic and
worker metadata. Guest environment grants travel in the boot plan; broker
policy and listener grants arrive on a supervisor-controlled startup channel
that VM requests cannot alter.
Seccomp filters
- Bubblewrap installs each worker's role filter after namespace setup and before
exec; the worker installs its compiled-in syscall allowlist as it starts. The
supervisor installs its own lifecycle filter and allowlist.
Allowlists are generated by statically scanning each role's code
(host sandbox policy).
- Workers stay in the host session, so the role filters and allowlists deny the
TIOCSTI and TIOCLINUX terminal-injection ioctls.
- The broker shares the host network namespace, where abstract Unix sockets
(such as an X11 display) and host netlink live. Its filters allow only IPv4
and IPv6 sockets and Unix stream socket pairs, and deny
listen; the
supervisor reads host interface addresses for it.
Lifetime
host.pid names the VM in the host PID namespace and supervisor.pid the
process owning box lifetime. Workers cannot rewrite these through their
filesystem grants. Stop and failed startup clean up the whole process tree.
- The VM holds a duplicate run-lock descriptor, so supervisor death cannot let
another boot reuse the disks before the old VM exits. The lock is cooperative:
compromised native VM code can unlock its own box.
- Broker failure after startup leaves the VM and agent running with networking
unavailable; there is no reconnect or fallback to direct networking.
Validation
The built-in launcher targets the statically linked musl release executable.
Native KVM and negative enforcement tests are required per architecture.
macOS sandboxing
Each worker runs under a Seatbelt profile that a trusted launcher applies before
the worker's code runs; the supervisor stays unconfined. Host process
sandboxing covers what each role holds.
Process layout
terra (CLI) your user, unconfined
└─ terra __supervisor unconfined; prebinds the agent, stop and
│ published listeners
├─ __sandbox_launcher trusted; applies the VM profile, then execs:
│ └→ __vm Hypervisor.framework, granted files and
│ shares; no network, no child processes
└─ __sandbox_launcher trusted; applies the broker profile, then execs:
└→ __network network connections, resolver files; no
disks, shares or child processes
└→ is an exec: the launcher becomes the worker and keeps its PID and descriptors.
__vm ◄── socket pair ──► __network
__supervisor ── startup channel ──► __network
__supervisor ── agent and stop listeners, run lock ──► __vm
terra (CLI) ── agent and control sockets ──► __vm
Profiles
Every profile denies by default, imports Apple's system.sb baseline and is
built into terra. A role adds these rules to the shared base, which also lets
the worker exec its own executable.
| Worker |
Allowed on top of the base |
| VM |
Its granted files and shares, and the FSEvents daemon for share notifications; no network, no child processes |
| Broker |
Name resolution through mDNSResponder, outbound connections to any IP (including host loopback), UDP binds and inbound UDP, socket creation, granted resolver files; no TCP binds, so it cannot open TCP listeners; no disks, shares or child processes |
The broker accepts on listeners the supervisor binds and hands over, which
needs no network rule.
Launcher
The supervisor writes an owner-only spec (role, canonical grants) under
~/.terra. The launcher renders the profile with the executable and every
grant passed as parameters, so no path from the spec enters the profile text.
It applies the profile with sandbox_init_with_parameters and execs the
worker. There is no per-launch signing. The shipped executable is signed once
with Hardened Runtime and packaging/macos.entitlements, so both workers carry
the hypervisor and unsigned-executable-memory entitlements. Hypervisor.framework
is not gated by Seatbelt, so no profile keeps the broker from creating a VM.
Activation check
Before initializing, each worker must fail to open a private canary file (the
launch spec) outside its grants, and the VM must fail IPv4 and IPv6 loopback
TCP connects and listens. Any other outcome fails startup and reaps the worker;
a successful profile call alone is not trusted.
Files
Grants are profile rules on canonical paths: Seatbelt matches resolved paths, so
/tmp grants become /private/tmp. Each grant's ancestor directories also get
a metadata-only rule, because the file-events watcher canonicalizes each share
path. The VM can also send IPC to the FSEvents daemon, which that watcher
subscribes through; the daemon delivers only events for paths the VM's file
rules allow. A read-only grant inside a writable grant is still rejected; no
caller needs it. The VM gets the box directory read-only plus specific writable
disk, diagnostics and runtime-logs/ paths. File rules do not grant Unix-socket
authority, so the supervisor prebinds the agent and stop listeners and passes
them to the VM as descriptors.
Limits
- Both profiles include Apple's
system.sb baseline. There are no namespaces,
seccomp filters or mount remapping.
- The broker keeps outbound network access, including host loopback, and can
reach mDNSResponder. It cannot bind TCP sockets, so it cannot open TCP
listeners; UDP binds work.
- Both workers are the same signed binary and carry the hypervisor
entitlement, and Seatbelt does not gate Hypervisor.framework, so a
compromised broker could create a VM.
- Seatbelt's profile interface is private and deprecated, so a macOS update could
change it.
- Boxes start over SSH: Seatbelt needs no GUI login session.
Validation
Native acceptance on Apple Silicon macOS 15+, from an SSH session:
make TERRA_TARGET=aarch64-apple-darwin host-dist
codesign --force --sign - --options runtime --entitlements packaging/macos.entitlements dist/terra
TERRA_BIN=$PWD/dist/terra CARGO_TARGET_AARCH64_APPLE_DARWIN_RUNNER="sh $PWD/scripts/toolchain/run-macos-vm-test" \
cargo test -p terra-sandbox --lib macos -- --ignored --test-threads=1 --nocapture
Then run the packaged VM tests and exercise guest TCP, UDP, DNS, published
listeners, shares, stop, detached lifetime and abrupt supervisor or broker death.
Windows sandboxing
Each worker runs in its own Less-Privileged AppContainer, created per launch with
a unique profile and package SID. A trusted wrapper launches it suspended inside a
single-process job and checks the result before the worker starts.
Host process sandboxing covers what each role holds.
Process layout
terra.exe (CLI) your user, unconfined
└─ terra.exe __supervisor unconfined; binds published listeners
├─ __sandbox_launcher trusted wrapper: DACL grants, console
│ │ relay, guest symlinks
│ └─ __vm AppContainer and job; HypervisorPlatform,
│ registryRead; granted box files, shares
└─ __sandbox_launcher trusted wrapper: opens TCP and UDP
│ sockets for the broker
└─ __network AppContainer and job, 512-MiB commit
limit; internetClient, registryRead
__vm ◄── socket pair ──► __network
__supervisor ── startup channel ──► __network
__network ── socket requests ──► wrapper ── sockets ──► __network
__vm ── symlink requests ──► wrapper
terra.exe (CLI) ── agent and control sockets ──► __vm
Capabilities
| Worker |
Capabilities |
Files |
Networking |
Other limits |
| VM |
HypervisorPlatform, registryRead |
Approved grants |
None |
Dynamic code allowed for Wasmtime's AOT components |
| Broker |
internetClient, registryRead |
Its executable only |
Sockets opened by its wrapper, name resolution |
512-MiB commit limit, dynamic code disabled |
The role policies are built in and cannot be overridden.
Both workers
The job kills the worker when the last job handle closes, permits
no child processes, and blocks desktop, clipboard, global atom and USER-handle
access. Workers get no Win32k system calls, forced and high-entropy ASLR,
permanent strict handle checks, heap termination, module tampering protection,
branch-target isolation and speculative store bypass disable. They load no
legacy extension DLLs, non-system fonts, or images from remote shares or with a
low integrity label, and resolve DLLs from System32 first. Workers are never
attached to a console: console input becomes NUL, and console output passes
through a wrapper relay as text with control characters replaced, so a worker
cannot queue keystrokes for the user's shell or send terminal escape sequences.
Before running, each worker must find its AppContainer token with exactly its
role's capabilities, its job, and no reachable console; any other result fails
startup.
VM grants
The VM's package SID is the only one added to approved file DACLs;
orderly exit removes the grants and deletes the profile. Read-only grants carry
explicit write/delete/permission denies; writable directories omit
FILE_DELETE_CHILD. The VM gets no grant on the box directory itself, only on
the files its box configuration names: the root disk, configured volumes and logs
read-write, and box metadata read-only. Grant traversal skips reparse
points, and share roots cannot be reparse points. Filesystem operations resolve
guest paths within the approved share; a launch may grant at most 16,384 objects.
WHP
The VM's HypervisorPlatform capability permits access to Windows
Hypervisor Platform; registryRead lets Winsock initialize for inherited IPC.
The VM receives no network capabilities. A WHP failure aborts the launch without
retrying.
Broker sockets
AppContainer network isolation blocks a container's own
sockets from host loopback, which published listeners and HOST_LOOPBACK rules
need, and the broker's token lacks the capability to accept connections on its
own sockets. Its published listeners come from the supervisor. For outbound
traffic the broker asks its wrapper, over an inherited channel, to connect a TCP
socket to a peer its policy approved or for a UDP socket bound to an ephemeral
port; the wrapper completes the connection, within 30 seconds and at most 64 at a
time, and duplicates the socket into the broker. No socket the broker can obtain
is able to listen. The broker has no file grants beyond its
executable, so it cannot read user files, box state or the user's other
processes, and its commit limit bounds memory.
The broker accepts on the listeners the supervisor bound and
opens IPv4 or IPv6 TCP/UDP sockets through its wrapper. It applies destination
policy before requesting them; published listeners claim their ports with
SO_EXCLUSIVEADDRUSE, so no other local process can bind the same port. The VM receives no native sockets and uses
the bounded broker IPC protocol for network operations.
Lifetime
The wrapper watches the supervisor process and stops the worker if
the supervisor exits. Only standard I/O, the IPC sockets, the broker's socket
channel, the VM's symlink channel, approved listeners and run-lock handle are
inherited. If the
wrapper itself is killed, the job still ends the worker, but DACL and profile
cleanup cannot run; profile names are never reused.
Symlinks
Guest symlinks in host-shared directories are stored the way WSL
stores them (IO_REPARSE_TAG_LX_SYMLINK). AppContainer can neither set nor read
any reparse point, so a VM with mounts passes link requests to its wrapper over
an inherited channel. Each request names a share the supervisor validated and a
path beneath it: the wrapper creates links only beneath writable shares, reads
them beneath any of the VM's shares, and does nothing else. Windows never follows
these links, so a guest-planted link cannot redirect host programs; Windows tools
other than WSL see them as files they cannot open. The wrapper refuses absolute
targets, as other hosts do. Windows symlinks already in a share still read as symlinks.
Limits
No mount, PID or user namespaces and no seccomp. A compromised broker
can request any number of wrapper connections and UDP sockets, so it keeps
outbound host network access, including host loopback, and can receive on its
UDP sockets.
Validation
The compiled probe runs both workers under these policies, reads each worker's
mitigations back from the kernel, and checks VM grants, WHP access, symlinks
through the wrapper and denied VM networking:
$env:TERRA_BIN = (Resolve-Path dist\terra.exe).Path
cargo test -p terra-sandbox --lib windows::tests::native_roles_enforce_vm_grants_and_broker_job -- --ignored
Native validation must also show that VM TCP/UDP fails for loopback, private
and public destinations while broker IPC works; that host AF_UNIX paths in
shares give no tunnel; that the VM cannot replace the PID files; and that
killing the supervisor or wrapper at any phase leaves no worker or live socket.