Download and install the host platform's package from the release for HEAD,
or from its nearest released first-parent ancestor when HEAD has no such
package, before the first run:
python3 scripts/nvx.py download
python3 scripts/nvx.py runThis installs the packaged kernel and initramfs under build/ and OpenVMM
under openvmm/target/release/, so no local build is required. Set GH_TOKEN
to a token with contents read access when downloading from a private repository.
On Linux, pass --hypervisor mshv to both commands to use the MSHV package.
The CLI chooses WHP on Windows and KVM on Linux:
python3 scripts/nvx.py runA successful Alpine boot prints both ALPINE-MICROVM-BOOT-OK and
NVX-GUEST-BOOT-OK: alpine. Boot Ubuntu userland with the same NVX kernel:
python3 scripts/nvx.py run --guest ubuntuUbuntu defaults to 512 MiB and prints NVX-GUEST-BOOT-OK: ubuntu. It is
Ubuntu userland with the NVX kernel, not a stock Ubuntu kernel or systemd VM.
Boot Azure Linux 3.0 userland the same way with --guest azurelinux. The
kernel unpacks its rootfs into a RAM filesystem capped at half of guest memory,
so it defaults to 512 MiB, and it prints NVX-GUEST-BOOT-OK: azurelinux.
Exit cleanly from the guest with:
/sbin/nvx-exit 0Pass extra guest options without changing the generated device ABI:
python3 scripts/nvx.py run \
--memory-mib 256 \
--net 10.0.0.2/24 \
--network-profile portable \
--cmdline "quiet loglevel=0"Networking requires the explicit portable capability profile. It uses the
same in-process data plane on KVM, MSHV, and WHP; omitting either --net or
--network-profile portable is rejected before OpenVMM starts.
Select an explicit processor count after building the matching specialized guest kernel:
python3 scripts/nvx.py run --machine microvm --processors 8The microVM uses fixed device topology, reserves a shared interrupt-status page, and uses shared-status edge-triggered virtio interrupts with 1, 2, 4, or 8 vCPUs.
The platform release archives are self-contained; scripts/nvx.py is a
convenience wrapper and is not required at runtime. Extract the archive that
matches the host and run the following command from its
nvx-VERSION-PLATFORM directory. For Linux/KVM:
./bin/openvmm \
--single-process \
--machine microvm \
--processors 1 \
--hypervisor kvm \
--memory 128M \
--kernel guest/vmlinux \
--initrd guest/initramfs.cpio.gzFor Windows/WHP in PowerShell:
.\bin\openvmm.exe `
--single-process `
--machine microvm `
--processors 1 `
--hypervisor whp `
--memory 128M `
--kernel guest\vmlinux `
--initrd guest\initramfs.cpio.gzFor Linux/MSHV, use the linux-mshv archive and replace kvm with mshv.
If the artifacts are already installed in the repository layout, use
openvmm/target/release/openvmm[.exe], build/vmlinux, and
build/initramfs.cpio.gz instead of the paths above.
For Ubuntu userland, use 512 MiB and select
guest/initramfs-ubuntu.cpio.gz or
build/initramfs-ubuntu.cpio.gz as the initrd. Below 320 MiB, the kernel
cannot unpack the whole Ubuntu initramfs and boots a truncated root. The
kernel path remains unchanged.
Direct OpenVMM launches accept generic directional network defaults:
./bin/openvmm \
--single-process \
--machine microvm \
--hypervisor kvm \
--memory 128M \
--kernel guest/vmlinux \
--initrd guest/initramfs.cpio.gz \
--net 10.0.0.2/24 \
--network-profile portable \
--network-egress allow \
--network-ingress denyEgress defaults to allow and ingress defaults to deny. With ingress denied,
responses to guest-initiated connections remain available, while new inbound
connections do not. The portable profile supports egress allow or deny but
rejects ingress allow before the workload starts.
The NIC is dual-stack. The guest's IPv6 address embeds its IPv4 address in the
unique local prefix fd00::/96, with a prefix of 96 plus the IPv4 prefix, and
its IPv6 gateway embeds the IPv4 gateway in the same way: 10.0.0.2/24 gives
the guest fd00::a00:2/120 behind the gateway fd00::a00:1. The guest
configures both identities statically; it neither solicits router
advertisements nor autoconfigures addresses from them, so its only other IPv6
address is the kernel's link-local one in fe80::/64. Like the IPv4 gateway,
the IPv6 gateway answers ICMPv6 echo requests and maps TCP and UDP flows onto
host loopback, here ::1, when host-loopback access is allowed. Because the
guest's only IPv6 source address beyond its link is a unique local one,
standard address selection prefers IPv4 for destinations that have both.
For destination and port rules, select an explicit default and repeat generic allow/deny options:
./bin/openvmm \
--single-process \
--machine microvm \
--hypervisor kvm \
--memory 128M \
--kernel guest/vmlinux \
--initrd guest/initramfs.cpio.gz \
--net 10.0.0.2/24 \
--network-profile portable \
--network-egress deny \
--network-egress-allow 140.82.112.0/20:tcp:443 \
--network-egress-deny 140.82.114.0/24:tcp:443Rules match IPv4 or IPv6 addresses or CIDRs and may select one protocol: tcp,
udp, or icmp. A TCP or UDP rule may add one destination port, or an
inclusive range of them written FIRST-LAST; without a port, it matches every
port of that protocol. Ports are 1 through 65535, and a range cannot end
below its first port. ICMP rules take no port, and an IPv6 ICMP rule matches
ICMPv6. For example, 192.0.2.0/24:udp matches UDP on every port,
192.0.2.1:tcp:8000-8010 matches TCP ports 8000 through 8010 but not UDP,
192.0.2.1:icmp matches ICMP but not TCP or UDP, and 2001:db8::/32:tcp:443
matches TCP port 443 on that IPv6 network. Deny matches take precedence over
allow matches, so adding --network-egress-deny 192.0.2.1:tcp:8005 keeps port
8005 blocked inside that range.
A rule matches only destinations of its own address family, so IPv4 rules never
admit IPv6 traffic, or the reverse, and a deny default blocks every IPv6
destination that no IPv6 rule allows. Use 0.0.0.0/0 and ::/0 to match
every destination of one family. Like ARP for the IPv4 gateway, Neighbor
Discovery for the IPv6 gateway is allowed under an allow default or with any
IPv6 allow rule. The gateway never forwards IPv6 packets that carry extension
headers or that target IPv4-mapped addresses. The guest names the IPv4 gateway
as its DNS server when the policy allows TCP or UDP port 53 to it, and
otherwise the IPv6 gateway when the policy allows DNS to that one.
NVX can lower protocol selectors, inclusive TCP/UDP port ranges, and rule-local IPv4 or IPv6 exclusions to those native rules:
{
"allow": [
{
"to": [
{
"cidr": "192.0.2.0/24",
"except": ["192.0.2.128/25"]
}
],
"ports": [
{
"protocol": "tcp",
"port": 8000,
"endPort": 8010
}
]
},
{
"to": [
{
"cidr": "192.0.2.200/32"
}
]
},
{
"to": [
{
"cidr": "198.51.100.0/24"
}
],
"ports": [
{
"protocol": "any",
"port": 443
}
]
},
{
"to": [
{
"cidr": "198.51.100.7/32"
}
],
"ports": [
{
"protocol": "icmp"
}
]
},
{
"to": [
{
"cidr": "2001:db8::/48",
"except": ["2001:db8::/56"]
}
],
"ports": [
{
"protocol": "tcp",
"port": 443
}
]
}
],
"deny": [
{
"to": [
{
"cidr": "192.0.2.0/24"
}
],
"ports": [
{
"protocol": "tcp",
"port": 8005
}
]
}
]
}Pass the file with --network-egress-policy-file PATH on run, one-shot
sandbox run, or sandbox provision. An explicit --network-egress allow or
deny is required. The file option cannot be mixed with
--network-egress-allow or --network-egress-deny.
The root accepts only allow and deny arrays. Each element uses the MXC
NetworkRule shape: optional to and ports arrays. Omitting to matches
every IPv4 and IPv6 destination. Each to entry requires one IPv4 or IPv6
cidr; optional except entries must be CIDRs of the same family contained by
that parent, and the entries of each family lower to native rules of that
family. Host bits are normalized like the native CIDR syntax: 10.0.0.5/24
means 10.0.0.0/24, not one host. Use /32 to select one IPv4 address and
/128 to select one IPv6 address; IPv6 scope IDs are rejected.
Omitting ports matches every transport supported by the native rule. Each
port selector follows MXC: protocol defaults to any. Without port, tcp
and udp match every port of that protocol, icmp matches ICMP alone, or
ICMPv6 for IPv6 destinations, and any matches every protocol. A port in
1..65535 applies to tcp, udp, or any, which expands to TCP and UDP on
that port but not ICMP. Optional inclusive endPort requires port and cannot
be below it.
Duplicate JSON properties, unknown fields, and explicit null protocol values
are rejected. Policy files are limited to 1 MiB of UTF-8 input. The previous
flat cidr/except/protocol/port rule form remains accepted for
compatibility, with an IPv4 or IPv6 cidr, but new policy files should use the
MXC shape.
A port range lowers to one native FIRST-LAST rule for each destination
network. The policy above therefore allows 192.0.2.0/25:tcp:8000-8010, which
matches TCP ports 8000 through 8010 of that network but not UDP or ports 7999
and 8011, and its deny rule 192.0.2.0/24:tcp:8005 keeps port 8005 blocked
inside the range.
Exclusions affect only their containing rule: they never become global deny rules. A later allow rule may therefore match an address excluded from an earlier allow rule, while an address excluded from a deny rule falls through to other rules and the explicit default. Explicit deny matches still take precedence over allow matches.
NVX canonicalizes safely equivalent prefixes, merges the adjacent and
overlapping port ranges of each network, and rejects policies that lower to
more than 256 allow rules or 256 deny rules, counting both families together;
protocol any with a port or a port range lowers to one TCP and one UDP rule.
NVX rejects oversized expansions before launch rather than truncating or
widening them. Managed provision stores the validated lowered rules in sandbox
state, so later starts do not reread a mutable source policy file.
Host-loopback denial and deliberate localhost port publishing are separately controlled from ordinary egress:
./bin/openvmm \
--single-process \
--machine microvm \
--hypervisor kvm \
--memory 128M \
--kernel guest/vmlinux \
--initrd guest/initramfs.cpio.gz \
--net 10.0.0.2/24 \
--network-profile portable \
--host-loopback deny \
--network-proxy 10.0.0.1:3128With deny, general guest-to-host loopback and every host-to-guest forward are
blocked, while the exact TCP proxy endpoint remains available. UDP on that
same port and other host service ports remain blocked even with ordinary
egress allowed.
The portable profile does not support generic bidirectional host-loopback
allow. Explicit --host-loopback allow without any forward is rejected before
VM resources are opened. For deliberate port publishing, repeat
--host-loopback-forward tcp:HOST_PORT:GUEST_PORT or its UDP form to expose
only selected localhost ports toward the guest, with explicit
--host-loopback allow. These forwards do not satisfy a generic allow policy
that provides no port list. Omitting --host-loopback preserves existing
guest-to-host mapping without publishing guest ports. Guest-originated traffic
remains subject to egress policy.
Most nvx.py run options pass through unchanged: --machine, --processors,
--cpu-profile, --mount, --net, --network-profile, --cmdline,
--restore-snapshot, --restore-processors, and --restore-ready-path. The
wrapper performs these translations and additions:
nvx.py run |
Direct OpenVMM option |
|---|---|
--guest alpine |
--initrd .../initramfs.cpio.gz on a fresh boot |
--guest ubuntu |
--initrd .../initramfs-ubuntu.cpio.gz and a 512 MiB default on a fresh boot |
--guest azurelinux |
--initrd .../initramfs-azurelinux.cpio.gz and a 512 MiB default on a fresh boot |
--hypervisor auto |
--hypervisor kvm on Linux or --hypervisor whp on Windows |
--memory-mib N |
--memory NM |
--memory-capacity-mib N |
--memory-capacity NM on a fresh boot |
--restore-memory-mib N |
--restore-memory NM |
--dry-run |
No equivalent; this only prints the generated command |
Always include --single-process. When restoring, omit --memory, --kernel,
and --initrd. Every restore gives the guest fresh entropy through the time
ABI's restore packet, so --restore-entropy is no longer needed; OpenVMM still
accepts it without effect. Do not pass --guest ubuntu during restore; the
captured RAM and machine contract already identify the restored guest. For
example:
./bin/openvmm \
--single-process \
--machine microvm \
--processors 8 \
--hypervisor kvm \
--restore-snapshot /var/lib/nvx/snapshot \
--restore-processors 4 \
--restore-memory 1024M \
--restore-ready-path /run/nvx/restore-ready.sockmicrovm is now the only selector and launches the contract previously named
microvm-v2. The microvm-v2 spelling, the former one-vCPU ABI-1 behavior,
ABI-1 device-I/O control, TTRPC numeric value 1, ABI-1 snapshot restore, and
boot-layout-1 snapshot restore are removed. Snapshot metadata and performance
series continue to use numeric ABI value 2 and boot-layout value 2.
Use NVX commit cb52bcd454b454cb241096c33ed42a1dcdc65347 with OpenVMM commit
1b70365613517a10718e00284a62bdffbd80e41c, or an earlier compatible pair, to
run retired ABI-1 guests or snapshots.
Restore an existing microVM snapshot with an optional host-readiness endpoint:
python3 scripts/nvx.py run \
--machine microvm \
--processors 8 \
--restore-snapshot /var/lib/nvx/snapshot \
--restore-processors 4 \
--restore-memory-mib 1024 \
--restore-ready-path /run/nvx/restore-ready.sockThe endpoint must already be listening. OpenVMM connects to a Unix domain
socket on Linux or a //./pipe/... named pipe on Windows and writes exactly
OPENVMM_RESTORE_READY_V1\n after snapshot verification, attachment
resolution, worker startup, gated guest repair, and host-input re-enable
complete while the restored vCPU remains stopped. With
--restore-processors, the snapshot keeps its immutable eight-vCPU capacity;
the source must have captured a canonical maxcpus boot-online prefix, and the
guest onlines exactly the requested prefix before readiness and host input
release. Targets are limited to 1, 2, 4, or 8 and cannot be below the captured
boot-online count or above capacity. Legacy snapshots reject the option. The
peer must accept and read while startup is in progress; Windows flush
completion waits for the named-pipe peer to consume the frame. Failure to
write the complete event aborts and tears down the restore.
For restore-time memory expansion, capture a fresh snapshot with
--memory-mib 512 --memory-capacity-mib 2048, then select a target with
--restore-memory-mib 512, 1024, or 2048. The snapshot's memory.bin
remains exactly 512 MiB; selected expansion ranges receive fresh per-launch
backing and are onlined before restore readiness.
The microVM has one virtio-fs slot, with the fixed tag microvm. On a cold
boot with --mount, the initramfs mounts the mapping automatically:
python3 scripts/nvx.py run --mount "/mnt/host,/absolute/host/share,rw"PowerShell example:
python scripts\nvx.py run `
--mount "/mnt/host,C:\Users\me\microvm-share,rw"Use ro for read-only access. The guest target must be an absolute Linux path.
Host paths containing commas are unsupported. Repeat --mount to attach more
directories, each with its own target and access mode, for example a
read-write workspace next to a read-only tool cache:
python3 scripts/nvx.py run \
--mount "/workspace,/srv/checkout,rw" \
--mount "/opt/hostedtoolcache,/opt/hostedtoolcache,ro"A --mount may also share one regular host file. The guest then reaches that
file at its target, but neither its directory nor its siblings, for example a
read-only settings file next to a read-write results file:
python3 scripts/nvx.py run \
--mount "/config/settings.json,/srv/app/config/settings.json,ro" \
--mount "/results/output.txt,/srv/app/results/output.txt,rw"With several mappings, or a shared file, NVX attaches the slot as an
aggregate: OpenVMM's --mount-aggregate /run/nvx/shares with one
--mount-child NAME,HOST_PATH,MODE[,file] per mapping, in order, where
OpenVMM's file flag marks a shared file, so OpenVMM refuses a directory that
replaced the file rather than export all of it. The initramfs mounts
the aggregate at /run/nvx/shares, whose root lists one entry per mapping and
only root can enter, and binds each entry at its target with its mode and
nosuid,nodev, as an nvx_share=NAME,TARGET,MODE[,file] token on the kernel
command line describes. The file suffix marks a shared file, which the
initramfs binds at a regular file that it creates at the target, along with
the target's missing directories; an existing target must already be a
regular file. A
mapping's name is its index, a hyphen, and the first 32 hexadecimal digits of
the SHA-256 digest of its target. A snapshot records every name, so it pins
each target: a restore that requests another target fails before the guest
runs, rather than resume binds at the snapshot's targets.
OpenVMM enforces each mapping's access mode on the host, so a read-only mapping
rejects writes with EROFS even if guest root remounts its bind read-write or
writes through /run/nvx/shares. A rename between mappings fails with
EXDEV, and so does a hard link, unless its destination is read-only, which
fails with EROFS first. OpenVMM serves a shared file alone: the guest cannot
list, inspect, or change the file's directory or reach its siblings, a lookup
below the file fails with ENOTDIR, and a read-write file can be rewritten in
place but not replaced, renamed, or removed. OpenVMM follows the file's name,
so the guest sees the file that host software, such as an editor, saves over
it, and requests fail with EACCES while anything but a regular file holds the
name. Guest targets that equal or contain one another or /run/nvx/shares,
and host paths that equal or contain one another, such as a shared file inside
a shared directory or two hard links to one file, are rejected before boot.
The number of mappings is bounded only by the 1024-byte
budget that their tokens and --cmdline share on the kernel command line.
A snapshot captured with mappings requires the same mappings, in the same
order, with the same canonical host paths, kinds, targets, modes, and
filesystem identities, so a restore refuses a shared file that was replaced
since the capture.
A repeatable --mount-deny HOST_PATH hides an existing file or directory
inside a mapped root. With several mappings, or a shared file, each
--mount-deny, --mount-allow, and --mount-write path must be absolute, and
it applies to the mapping whose root contains it. A shared file takes none of
them, because its mode applies to the whole file. Denied
names are omitted from directory listings and remain
inaccessible through .., a symlink/junction, or another mount of the same
virtio-fs device. Names may contain spaces, but not begin or end with one.
--mount-deny may name a mapping's root itself when --mount-allow paths
inside it remain: the root then lists only the way to them. Unsafe, external,
duplicate, overlapping, and nested-mount
rules are rejected before boot. A repeatable --mount-allow HOST_PATH
exposes a path inside a denied path again, and a repeatable
--mount-write HOST_PATH makes a path one of the only writable parts of an
rw mapping; see Access policy. A snapshot captured with
these paths requires the same denied, allowed, and writable paths.
In an rw mapping, the guest can create symbolic links, and OpenVMM stores
each target exactly as given. The guest resolves links in its own namespace;
OpenVMM never follows a link while resolving a host path, so a link to an
absolute host path, outside the root, or into a denied path cannot reach host
data. An ro mapping rejects link creation with EROFS. On Windows, links
are WSL-style reparse points, which Windows path resolution never follows.
Treat links in a writable share as untrusted when host software later reads
the directory.
By default, OpenVMM performs every guest operation on the mapping as its own
user. On a Linux host, --mount-owner caller instead performs each one as the
guest caller's UID and GID and squashes guest root to the owner of the shared
host directory or file, so files that guest root creates are owned by that
owner rather than by root or OpenVMM; see File ownership. A
snapshot captured with a mapping also requires the same --mount-owner mode.
Capture and restore inspect and reopen the guest's open files as OpenVMM's
user, so unless OpenVMM runs as root, they fail while the guest holds a file
that only its caller can reach or reopen, such as one open for writing.
A snapshot captured without a mapping may restore with one new --mount of a
directory, but not with several or with a file; after resume, mount it
explicitly inside the guest because the initramfs hook has already completed:
mkdir -p /mnt/host
mount -t virtiofs microvm /mnt/hostThe sandbox command launches the microVM with one to three compressed
EROFS lower layers and one preformatted ext4 scratch image:
python3 scripts/nvx.py build-distro-layer \
--guest ubuntu \
--output build/ubuntu-distro.erofsRead the deterministic UUID from
build/ubuntu-distro.erofs.manifest.json, create scratch independently with
mkfs.ext4, then launch the Ubuntu workload through the existing Alpine
control initramfs:
python3 scripts/nvx.py sandbox \
--layer distro,build/ubuntu-distro.erofs,11111111-1111-1111-1111-111111111111 \
--scratch /var/lib/nvx/scratch.ext4 \
--entrypoint /bin/sh \
--workload-user 65534:65534 \
--memory-mib 256CI uses /sbin/nvx-sandbox-smoke as the entrypoint to verify the security
profile that this section describes before clean guest exit. Before any other
check, it requires:
- the fixed
65534:65534identity as the real, effective, saved, and file-system IDs, no supplementary groups, andHOME=/nonexistent; - empty inheritable, permitted, effective, bounding, and ambient capability
sets and
NoNewPrivs: 1; - mount, PID, and UTS namespaces other than the guest's initial ones, whose
inode numbers Linux 6.18 fixes in its UAPI, with the workload as PID 1 of its
PID namespace and the
--hostnamevalue as its host name; - exactly one mount at each of
/,/proc,/sys,/dev,/dev/pts, and/dev/shm, none of which propagates to another namespace: an overlay root over the EROFS layers whose upper directory is on scratch,nosuid,nodev,noexecprocfs, read-only sysfs, and a private/devtmpfs that holds onlyfd,full,null,ptmx,pts,random,shm,stderr,stdin,stdout,tty,urandom, andzero, with its own devpts instance; - the agent-owned
/containercgroup; and - Ubuntu image files that the workload cannot modify, and a scratch-backed
/tmpwrite that lands in the overlay.
A managed workload that mounted into the agent's namespace would stack its
runtime mounts on those of earlier requests, so the single-mount check also
covers repeated managed execs. With --arg limits --arg MEMORY_MAX --arg PIDS_MAX, for a sandbox started with the same --memory-max and
--pids-max, it also requires the memory controller to kill an allocation of
twice MEMORY_MAX with SIGKILL while a 1 MiB allocation succeeds, and the
pids controller to stop the workload at PIDS_MAX processes, itself included;
keep twice MEMORY_MAX within the guest's free memory. With
--arg TARGET --arg ro|rw, it also checks a live share at
TARGET as described below, including symbolic links in an rw share; the
share needs a host-created, world-writable nvx-links directory for them.
With --arg TARGET --arg file-ro|file-rw, it checks a shared file at
TARGET: a virtiofs bind with the given mode, alone in its directory,
holding host-to-guest, which the workload can neither remove nor rename. It
requires a write to a file-ro file to fail with EROFS, so the host must let
every identity write the file, and it writes guest-to-host to a file-rw
file.
Repeat the pair to check several shares; with an rw and an ro share, it
also verifies that a link in the rw share cannot write into the ro share.
With --mount-owner caller, --arg caller performs the rw checks and also
verifies that the workload owns what it creates, populates a directory it
created, and creates links in its own nvx-caller-links directory, while
--arg eperm requires reading and listing the share to fail with EPERM and
writes to fail, which is the result when OpenVMM cannot assume the workload
identity. --arg policy checks an rw share whose
access policy denies nvx-denied, allows
nvx-denied/nvx-allowed, and makes only nvx-writable writable: the workload
writes only inside nvx-writable, gets EROFS everywhere else, cannot
hard-link a read-only file into nvx-writable, and sees only nvx-allowed,
which stays readable, in nvx-denied. The share's other directories and files
must be writable by every identity, so that only OpenVMM can refuse a write.
The layer UUID is the EROFS superblock UUID, not a content digest. The command
validates the files before launch, orders roles independently of option order,
attaches layers read-only, and reserves the writable slot for scratch.
Conversion and scratch formatting stay off the start path. The Ubuntu
converter verifies immutable inputs, applies the deny-by-default metadata
policy, creates /nonexistent for UID/GID 65534, and invokes mkfs.erofs.
Systemd entrypoints are explicitly unsupported and do not relax the non-root,
drop-all-capabilities sandbox policy.
This is the cold-filesystem bootstrap described in
the sandbox design, not the final
production agent. It accepts no environment variables or secrets and does not
expose sandbox snapshot capture or restore, the configuration region, or runtime RPC.
Arguments are individual kernel-command-line tokens and therefore cannot
contain whitespace. The workload enters private mount/PID/UTS namespaces with
a private /dev, an agent-owned cgroup, no capabilities, and no_new_privs.
It always runs as the fixed non-root UID:GID selected at VM creation
(65534:65534 by default). The guest verifies that exactly one matching user,
its primary group, and its absolute home directory exist in the assembled
root; otherwise the workload is never started.
The outer agent retains the initramfs root; the capability-stripped child
enters only the assembled root with chroot, because Linux cannot
pivot_root away from an initramfs rootfs.
sandbox run and sandbox provision accept repeatable
--mount GUEST_TARGET,HOST_PATH[,ro|rw] (default ro) options, each with its
own guest target and access mode, plus repeatable --mount-deny HOST_PATH,
--mount-allow HOST_PATH, and --mount-write HOST_PATH rules (see
Access policy). OpenVMM exports one host directory through
the microVM's virtio-fs device, and several shares, or a shared file, as the
children of an aggregate on that device, and enforces each share's access mode
and policy on the host side, so edits are visible in both directions without
staging or copy-back:
python3 scripts/nvx.py sandbox \
--layer distro,build/ubuntu-distro.erofs,11111111-1111-1111-1111-111111111111 \
--scratch /var/lib/nvx/scratch.ext4 \
--mount /workspace,/srv/checkout,rw \
--mount-deny .git/credentials \
--mount /opt/hostedtoolcache,/opt/hostedtoolcache,ro \
--entrypoint /bin/shA --mount whose host path is a regular file shares only that file, as
virtio-fs host mapping describes: the workload sees
it at its target, but neither its directory nor its siblings. A shared file
takes no --mount-deny, --mount-allow, or --mount-write path, because its
mode applies to the whole file.
A relative --mount-deny, --mount-allow, or --mount-write path is
resolved inside its share's host directory. With one share, every such path
applies to it. With several, each applies to the --mount before it and must
name a path inside that share's directory. The guest targets and the host
paths of the shares must not equal or contain one another, because one
share could otherwise hide another or reach its files under a different access
mode, so a shared file may lie beside a shared directory, but not inside it.
NVX and OpenVMM compare the host paths by resolved path and by file
identity, so one directory or file reached through two paths, such as a bind
mount or a hard link, is rejected too. NVX also compares each share's identity
with those of the other share's ancestors, so a share inside a bind mount of
the other share's directory is rejected before OpenVMM starts. On Linux,
OpenVMM also
compares the filesystem sources that each directory reaches, through the
mount that contains it and every mount below it, so it also rejects a bind
mount or nested mount that exposes part of one share inside the other. That
check is Linux-only, so on Windows, don't share a directory that reaches the
other share's files through a mount.
Guest mount flags are not a security boundary: OpenVMM rejects every write to
an ro share with EROFS, whichever mount or link inside the guest reaches
it.
After it assembles the container overlay and verifies the workload identity,
the guest agent creates each target inside the container root and mounts the
share there with nosuid,nodev before the workload enters its private mount
namespace. With several shares, or a shared file, it mounts the aggregate at
/run/nvx/shares, outside the container root, and binds each child at its
target with its mode and nosuid,nodev, in order, as the
nvx_share=NAME,TARGET,MODE[,file] tokens that NVX adds to the kernel command
line describe. It binds a shared file at a regular file, which it creates when
the container root has nothing at the target. The number of shares is bounded
only by the sandbox's 1024-byte kernel command-line budget. A one-shot
workload exit, a managed stop, and any failure after a share is mounted
unmount the binds in reverse order, then the aggregate, before the overlay is
unmounted or the VM powers off. Each target must be an absolute, canonical
path; / and /etc, and the /etc/machine-id, /proc, /sys, /dev, and
/.nvx-agent trees, are reserved for the container runtime, which replaces
/etc/machine-id with the workload's machine ID.
The guest refuses a target whose path crosses a symbolic link in a container
layer, a target that a container layer holds as another kind of file than the
share's, a repeated child, and overlapping targets, and any validation or mount
failure aborts the sandbox with status 125 instead of starting the workload
without its shares.
An rw share supports the symbolic links that package managers and
language toolchains create; see
virtio-fs host mapping for their semantics. The
existing OpenVMM file-identity policy applies to each share. A managed
sandbox stores each share's absolute host path, kind, mode, denied, allowed,
and writable paths and the ownership mode in its configuration, and reattaches
every share on each start, which refuses a host path that no longer holds a
directory or regular file as recorded. A configuration with several shares
uses format 4, which earlier NVX releases reject rather than start without the
other shares.
A configuration with allowed or writable paths uses format 5, which earlier
NVX releases reject rather than start a share without its access policy.
A configuration with a shared file uses format 6, which earlier NVX releases
reject rather than start without the file.
Each share can narrow what the workload can see and modify inside it. OpenVMM enforces the policy on the host for every request, whichever guest mount, path, or link reaches the share:
--mount-deny HOST_PATHhides a path and everything below it. It may name the share's root itself when allowed paths inside it remain, so the share exposes only those paths.--mount-allow HOST_PATHexposes a path inside a denied path, and everything below it, again. The hidden directories on the way to it are traverse-only: the workload can enter and list them, but the listing shows only the entries that lead to allowed paths, any other name fails withEACCES(Permission denied), and the workload cannot modify them. A denied path may in turn lie inside an allowed path.--mount-write HOST_PATH, on anrwshare, makes a file or directory, and everything below it, one of the only writable parts of the share. Every other write fails withEROFS(Read-only file system), including creating, removing, or renaming an entry in a read-only directory, which also stops a rename into or out of a writable path. A hard link from a writable path to a read-only file fails withEXDEV(Invalid cross-device link), so the link cannot make the file writable.
Each path must exist inside the share's host directory, must not cross a
symbolic link or junction, and is attributed to a share like --mount-deny.
Its names may contain spaces, but not begin or end with one, nor contain
other whitespace, colons, or backslashes.
Allowed paths follow the share's write policy: without --mount-write, an
allowed path in an rw share is writable; with it, an allowed path is
writable only inside a writable path. For example,
the following share hides the MCP gateway's logs except the tool payloads
that it spills below them, which stay readable, and lets the workload write
only to agent and cache:
python3 scripts/nvx.py sandbox \
--layer distro,build/ubuntu-distro.erofs,11111111-1111-1111-1111-111111111111 \
--scratch /var/lib/nvx/scratch.ext4 \
--mount /tmp/mcp-runtime,/tmp/mcp-runtime,rw \
--mount-deny mcp-logs \
--mount-allow mcp-logs/mcp-payloads \
--mount-write agent \
--mount-write cache \
--entrypoint /bin/shOpenVMM rejects an allowed path outside a denied path, a writable path inside
a hidden part of the share or inside another writable path, and a writable
path on an ro share before boot. The policy applies to names inside the
share: a host hard link or bind mount that makes a read-only file reachable
inside a writable path makes it writable, and one that makes part of a denied
path reachable elsewhere exposes that part, so do not create such aliases
inside a shared directory.
--mount-owner selects the host identity that performs the share's file
operations:
vmm(the default): OpenVMM performs every operation as its own user, so the files and directories that the workload creates are owned by the OpenVMM user. Guest file permissions use the ownership and mode bits that OpenVMM reports, so grant the workload identity access to the host directory. The workload cannot create entries inside a directory it created unless the host grants write access to others.caller: OpenVMM performs each operation as the guest caller's numeric UID and GID, without supplementary groups or capabilities. Files and directories that the workload creates are owned by its--workload-useridentity on the host, and the host also enforces permissions for that identity, so the workload can populate the directories it creates. Guest UID 0 and GID 0 are squashed to the owner and group of the host directory, which therefore must not be owned by UID 0 or GID 0; the guest can create neither root-owned nor setuid-root host files. If OpenVMM cannot assume a caller's identity, that operation fails withEPERM(Operation not permitted) instead of running as OpenVMM.
python3 scripts/nvx.py sandbox \
--layer distro,/var/lib/nvx/distro.erofs,11111111-1111-1111-1111-111111111111 \
--scratch /var/lib/nvx/scratch.ext4 \
--mount /workspace,/srv/checkout,rw \
--mount-owner caller \
--workload-user 1001:1001 \
--entrypoint /bin/shAs for any sandbox, the layers must define the workload user. Choosing the owner of the host directory as the workload identity keeps every file in it owned by that user.
caller requires a Linux host. Windows has no per-request POSIX identity for
OpenVMM to switch to, so NVX and OpenVMM reject --mount-owner caller on
Windows/WHP. On Linux, assuming an identity other than OpenVMM's own, or
dropping OpenVMM's supplementary groups, needs the CAP_SETUID and
CAP_SETGID capabilities, for example as ambient capabilities of an
unprivileged NVX process. sudo replaces HOME with root's home directory,
so the example passes the user's own back:
sudo setpriv --reuid="$(id -u)" --regid="$(id -g)" --init-groups \
--inh-caps=+setuid,+setgid --ambient-caps=+setuid,+setgid -- \
env HOME="$HOME" python3 scripts/nvx.py sandbox ... --mount-owner callerA service manager can grant the same set, such as systemd's
AmbientCapabilities=CAP_SETUID CAP_SETGID. Without these capabilities, an
operation succeeds only if its caller has OpenVMM's user and primary group and
OpenVMM's user belongs to no other group; every other operation fails with
EPERM. Hosts commonly grant access to /dev/kvm or /dev/mshv through such
a group, so caller usually needs both capabilities. Grant OpenVMM no other
capabilities: CAP_SETUID lets it assume any host identity, which caller
mode confines to its export.
The guest kernel reports each caller's identity, and guest root may assume any
identity inside the guest, so guest root and a compromised guest kernel can act
as any nonzero host UID and GID inside the share, including leaving setuid
files that those identities own. Share only directories that such identities
may modify, and keep the share on a host filesystem mounted nosuid when host
users might execute files from it.
For a state-aware sandbox, provision configuration without starting a VM, start it once, run multiple workloads in the same warm guest, stop it while retaining configuration, and finally deprovision it:
python3 scripts/nvx.py sandbox provision \
--state-dir /run/user/1000/nvx-example \
--layer distro,/var/lib/nvx/distro.erofs,11111111-1111-1111-1111-111111111111 \
--scratch /var/lib/nvx/scratch.ext4
python3 scripts/nvx.py sandbox start \
--state-dir /run/user/1000/nvx-example
python3 scripts/nvx.py sandbox exec \
--state-dir /run/user/1000/nvx-example \
--entrypoint /usr/bin/python3 --arg=/work/agent.py --cwd /work \
--environment-file /run/user/1000/nvx-example-environment.json \
--outcome-report /run/user/1000/nvx-example-exec.json
python3 scripts/nvx.py sandbox exec \
--state-dir /run/user/1000/nvx-example \
--entrypoint /bin/sh --arg=-c --arg='cat /tmp/previous-result'
python3 scripts/nvx.py sandbox stop \
--state-dir /run/user/1000/nvx-example
python3 scripts/nvx.py sandbox deprovision \
--state-dir /run/user/1000/nvx-exampleLifecycle transitions fail closed: start rejects an already-running or stale
runtime record; exec and stop require a live OpenVMM process; and
deprovision refuses to remove a running sandbox or unknown files. A control
capability or control socket that remains without a runtime record, as after a
killed start, may belong to an OpenVMM process that still runs, so start and
deprovision refuse it too: end any OpenVMM process whose arguments name the
state directory, and then remove control.capability and control.sock.
provision, start, stop, and deprovision hold an exclusive lock on
lifecycle.lock in the state directory until they return, so the transitions
of one sandbox take effect one at a time: each waits up to --timeout seconds
for any other in progress, and of two overlapping starts, exactly one launches
OpenVMM while the other then fails because the sandbox is already running.
exec does not take the lock, so a long workload does not delay stop. A
provisioned state directory keeps lifecycle.lock, and deprovision removes
it with NVX's other files. On NFS, and on Windows file systems without POSIX
deletion such as ReFS, a removed lock file keeps the emptied directory until
the transitions that wait for it close it, so deprovision then releases the
lock before it removes the directory, and a provision that waits for that
removal completes it once nothing keeps the directory. A transition that
waited while deprovision removed the sandbox then fails because the sandbox
is not provisioned, except provision, which provisions the state directory
again once deprovision has removed it, unless another provision provisions
it first. Only a provision that starts just as deprovision removes the
emptied directory can provision it first, and deprovision then fails
because the sandbox was provisioned again. --timeout
must be finite and greater than 0. If the guest exits while start waits for
it to become ready, for example because it refuses
the workload identity, OpenVMM closes the control endpoint and shuts down.
start then waits up to --timeout seconds for OpenVMM to publish
outcome.json and exit, and fails with the report's category and status, such
as guest-exit status 125. It terminates OpenVMM only if OpenVMM outlives that
wait. The runtime record identifies OpenVMM by its process ID and start time, so
these checks treat OpenVMM as gone once it exits, even if no process reaps it or
another process reuses its ID. A record that an earlier NVX version wrote lacks
the start time and identifies OpenVMM by its process ID alone. Managed
workload arguments use the bounded control protocol rather than the kernel
command line and may contain whitespace. Managed execution can select an
absolute working directory and either repeated inline KEY=VALUE entries or a
UTF-8 JSON-array environment file. The two environment forms are mutually
exclusive. Omission inherits the guest bootstrap environment, not the host
environment: PATH=/usr/sbin:/usr/bin:/sbin:/bin, TERM=linux, and HOME,
USER, and LOGNAME resolved from the fixed workload identity. An empty file
array requests an empty environment. --inherit-default-environment layers
the supplied entries over that default environment instead of replacing it,
each entry replacing the default variable of the same name; without supplied
entries it has no effect. Inline values are visible in host process arguments
and should not be used for secrets. These options apply only to managed
sandbox exec; one-shot execution rejects them. The workload sees one machine
ID for the life of the VM. On stop, the guest agent unmounts the live share,
overlay, layers, and scratch in dependency order before the VM powers off, as it
does when a one-shot workload exits. The legacy operation-less sandbox
form is sandbox run; it remains one-shot and rejects --state-dir or any
request to retain VM state.
Environment files are limited to 1 MiB of UTF-8 JSON. Environments contain at
most 256 unique, nonempty names. Each KEY=VALUE entry and working-directory
path is limited to 4096 UTF-8 bytes; the combined execution request must also
fit the existing 64 KiB control-payload bound. A managed exec forwards at
most 1 MiB of combined standard output and standard error; the guest agent
kills a workload that writes more, and exec exits with status 125 and reports
the output-limit category.
The explicit test-microvm --scenario managed-exec-config --backend BACKEND
scenario is the authoritative acceptance for these public options. It invokes
scripts/nvx.py sandbox provision, start, exec, stop, and deprovision
as subprocesses with an Alpine control guest and an Ubuntu workload layer. It
checks sequential distinct CWD and exact-environment requests, an environment
layered over the defaults, and then omitted defaults. It resolves the selected
UID 65534 account from the Ubuntu workload's own passwd database through a
public managed getent execution, then requires exactly the documented PATH,
TERM, HOME, USER, and LOGNAME values, which a layered entry replaces
only in its own execution. Unrelated guest bootstrap and shell-provided entries are
permitted because omission inherits the guest bootstrap environment; prior
request entries and the internal execution-config descriptor must not leak.
It also checks public
rejection of relative CWD and out-of-range uint32
timeouts, timeout recovery, stdout/stderr forwarding, and typed exec outcome
reports. It requires build/ubuntu-distro.erofs, its manifest, and the
build/ubuntu-smoke-scratch.ext4 template produced by the guest-artifact build.
CI invokes it separately on every backend; it is not included in the default
scenario set because downloaded packages do not include the scratch template.
Empty environments are measured with /usr/bin/env, not a shell that can
synthesize its own variables. The scenario retains bounded subprocess argument
and status observations, typed exec outcomes, and OpenVMM logs. Inline
environment values are redacted from the retained command observations.
The explicit test-microvm --scenario sandbox-lifecycle --backend BACKEND
scenario is the authoritative acceptance for the managed lifecycle itself. It
also runs the public commands as subprocesses against the real kernel, Alpine
control initramfs, Ubuntu EROFS layer, and fresh copies of the same scratch
template, so it has the same artifact requirements and also runs only when
named. It checks that:
provisionrejects a root--workload-userbefore it creates any state, and the guest refuses an identity that the Ubuntu image lacks before a one-shot workload starts, and in a managedstart, which then fails withguest-exit status 125from OpenVMM's outcome report and leaves onlyconfig.json,lifecycle.lock,openvmm.log, and thatoutcome.json, without a runtime record, capability, control socket, report staging file, or OpenVMM process;- every operation fails on a state directory that was never provisioned, a
repeated
provisionleaves the configuration unchanged, andexecandstopfail beforestart; - a repeated
startand adeprovisionof a running sandbox fail without changing its runtime record, capability, or OpenVMM process, and the sandbox keeps serving requests; - managed arguments keep leading, trailing, and embedded spaces, tabs, and
newlines, a later request reads the file that an earlier one wrote, and the
file survives
stopand a newstart, because the overlay's upper directory is on scratch; /sbin/nvx-sandbox-smokepasses its security profile and resource-limit checks in a sandbox provisioned with--hostname,--memory-max, and--pids-max, after earlier requests;- a request that writes more than the 1 MiB output bound exits with status
125 and an
output-limitoutcome report after the host has received more than 1 MiB less one 32 KiB read chunk and at most 1 MiB, and a request whose--outcome-reportpath exists fails before its workload runs and leaves the file intact; stopends OpenVMM and its control socket or pipe, leaves onlyconfig.json,lifecycle.lock,openvmm.log, and a successfuloutcome.json, and leaves a cleanly unmounted scratch file system, after whichexecandstopfail;- two overlapping
startcalls then launch a single OpenVMM process: one succeeds, and the other fails withsandbox is already running or has stale runtime state; - after OpenVMM exits without a
stop,execandstopreport the stale runtime state andstartrefuses it; and deprovisionremoves only NVX's files: it fails and keeps a foreign file in the state directory, and once that file is gone, it removes the directory, which a laterexecdoes not recreate.
Throughout, no OpenVMM process whose arguments name the scenario's fixture
outlives its sandbox, and the EROFS layer keeps its digest and the scratch
image its file. On failure, the scenario stops and deprovisions what it
started, ends every OpenVMM process that names its fixture, removes any control
capability or socket that no runtime record names, and preserves the fixture
for recovery only if that cleanup fails. Its evidence, written to the
output directory with a sandbox-lifecycle- prefix, holds bounded command
observations (arguments, statuses, output sizes, and state-directory entries),
the runtime record, the OpenVMM logs, the VM-level and exec outcome reports, the
guest probe's transcript, and the console of the refused one-shot run, each
limited to its last 64 KiB. The command observations hold no workload output,
and no evidence holds the control capability.
Decoder, helper, and direct control-session tests remain useful supplemental
coverage for protocol boundaries and guest implementation details. They do not
replace or establish support through the public nvx.py sandbox interface.
run --outcome-report PATH and one-shot sandbox run --outcome-report PATH
forward OpenVMM's bounded local JSON report. Managed sandbox exec writes only
the operation, bounded result category, numeric status, and an opaque operation
ID to its requested report; stdout, stderr, arguments, environment values, and
credentials remain excluded. sandbox stop waits for OpenVMM teardown and
retains the latest VM-level report as outcome.json in the state directory.
Neither OpenVMM nor NVX uploads these files.