Skip to content

Guest command execution over virtio-vsock, and sandboxes that actually confine - #70

Open
admercs wants to merge 32 commits into
masterfrom
feat/virtio-vsock
Open

Guest command execution over virtio-vsock, and sandboxes that actually confine#70
admercs wants to merge 32 commits into
masterfrom
feat/virtio-vsock

Conversation

@admercs

@admercs admercs commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Two related pieces of work, plus the CI run that neither had.

Running a command in the guest, for real

execute_script was described in four places as running inside the guest and
always evaluated a Rhai script on the host. The engine was never the problem:
nothing in the repo could reach a guest at all. Virtio devices kept their
descriptor tables in host Vecs a test filled in, and no virtio register file
was ever mapped into guest physical address space.

  • GuestQueue — split virtqueues read out of guest memory. Every field is
    guest-written, so each is bounded: a chain that cycles is refused, an index
    past the table errors, indirect tables may not nest, a chain may not claim
    more than 64 MiB.
  • VirtioMmioTransport — virtio-mmio v2. It implements Device, so
    register_mmio_region puts it on the MMIO exit path VM::run already takes.
  • VsockDevice, hv2-guest-agent (wire protocol + in-guest binary),
    GuestAgent, AgentVM::exec_in_guest, surfaced as vm.exec and
    POST /api/v1/vms/{id}/exec.

Four ways of having no guest to run in each fail as themselves rather than as a
timeout — an agent that gets a timeout when the real problem is a missing device
retries forever. Capability::GuestExec existed and had never been consulted.

Sandboxes

Nothing in the repo confined anything. hv2-agent's Sandbox says so in its own
docs; hv2-core::container is 3,866 lines whose start() reads "For now,
simulate with a PID"
and reports Running with a fabricated 1000 + n. A
repo-wide grep for seccomp|setrlimit|unshare|prctl|CreateJobObject found prose
and struct fields — not one confinement syscall.

hv2-sandbox is built around one rule: silently dropping a control is worse
than no sandbox
, because a caller who asked for no network and got one believes
the opposite of the truth. controls() reports what this host enforces, probed
by attempting each one; a spec asking for what the backend lacks is refused,
naming the control and why; best_effort() is an explicit opt-in whose result
says what was dropped.

Two backends behind one trait: ProcessSandbox (cgroup v2 + namespaces +
rlimits + no_new_privs on Linux, job objects on Windows) and MicroVmSandbox
on the vsock machinery above. sandbox.run / sandbox.capabilities expose it to
agents.

Admin no longer implies HostExec. Every other capability is implied by
the Admin wildcard, and every other tool acts on VMs the server manages; this
one acts on the machine the server runs on. Leaving it in the wildcard would have
granted host execution to every existing admin session the moment this shipped.

What running it on a kernel found

The Linux backend was initially only type-checked. Running it on a real kernel
(WSL2 Debian 6.18, unprivileged) found two defects of the same shape — a claim
true in the mechanism and false in what the workload could observe:

  1. best_effort was broken on any host without cgroup delegation: it
    promised to run with whatever the host could enforce, then attempted a cgroup
    the probe had already reported unavailable. Fixed structurally — backends are
    now handed a spec filtered to what their own probe accepted.
  2. /proc and /sys were inherited. Neither is an ordinary directory; each
    is a view of the namespace it was mounted in. The workload was correctly
    PID 1 in its own namespace and could still enumerate 48 host processes, and
    had only loopback on netlink while /sys/class/net listed the host's
    interfaces. Both namespaces now remount the filesystem describing them, and
    both probes rehearse the remount rather than only the unshare.

The isolation tests ask the workload what it can see rather than reading
controls() back — a test doing only the latter passes on a backend that reports
everything and applies nothing.

Verification

Workspace (Windows) 4,812 tests, 0 failing
hv2-sandbox on Linux 6.18 20 tests + clippy -D warnings, on the kernel
hv2-sandbox on Windows 18 tests, incl. the kernel refusing a workload's second process
Clippy clean under CI's exact invocation
Rustdoc clean under -D warnings

What is not verified

  • No kernel has booted on the vsock device. The tests lay out rings in guest
    memory and publish descriptors exactly as a driver does, so they support "the
    device implements the protocol", not "a Linux guest connected". Same hardware
    gate as the rest of the boot path.
  • The macOS sandbox backend is type-checked only (--target aarch64-apple-darwin), never run.

Note on history

This branch carries four commits that predate it (9fb2db8..a7668f3) and had
never been through CI — the main reason for opening this as a PR. origin/master
has since gained a manifest change (5f67ef5) that this branch also contains as
an independent commit with identical content (319c2c9); they should merge
cleanly, but it is worth knowing the duplicate is there.

🤖 Generated with Claude Code

nervosys and others added 30 commits August 25, 2026 10:15
The Hypervisor trait identifies a vCPU by a bare id, so WHPX and KVM
keyed `vcpu_map` by that id alone and `load_boot` resolved the partition
with `vms.last()`. Two VMs on one backend therefore shared a vCPU 0, and
a boot image loaded into whichever partition happened to be newest. The
lookups read as "the right one" but meant "the most recent one".

`VM::new` already builds a backend per VM, so the invariant held in
practice and nothing was observed to be broken — but `new_with_backend`
is public and hands a caller the aliasing path with no warning.

Make the invariant explicit instead of implicit: `vms: Vec<_>` becomes
`vm: Option<_>`, so the type says there is at most one, and `create_vm`
refuses a second with an error that explains why rather than silently
returning a handle onto the first. `shutdown` releases the slot, so a
backend stays reusable.

HVF has the same defect in a different shape: Hypervisor.framework
allows one VM per process and every field is backend-global, so a second
`create_vm` aliased the first outright. Guard it the same way, and split
`set_up_vm` out so a failure part-way through releases the claim rather
than bricking the backend.

Tests: two per backend, covering the refusal and reuse-after-shutdown.
Both skip on hosts without a usable hypervisor, which includes this one
and CI — the wiring is verified, the runtime assertion is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs `dtolnay/rust-toolchain@stable` unpinned, so it picks up
1.98.0 (2026-08-20) and its new lints on the next run. Four sites fail
`-D warnings` today, independently of any branch.

Three are mechanical: `Histogram::reset` and the TAP registry decode
become `fill` and `as_chunks`, and `RateLimiter::check` returns
`Option<u64>` — `Some(remaining)` allowed, `None` refused — instead of
`Result<u64, ()>`, whose error type carried no information anyway.

The fourth is not mechanical. `manual_slice_fill` fired on the two Drop
impls in crypto::fips that erase key material, and `fill(0)` would have
silenced it while leaving the real problem: both the loop and `fill`
write to memory that is never read again, which the optimizer is free to
delete entirely. Use `zeroize`, which is already in the lock file and
uses volatile writes, so the erasure survives optimization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every virtio device in this crate modelled a device that no driver could
find. The queues kept their descriptor tables in host Vecs a test filled
in, and nothing mapped a virtio register file into guest physical address
space, so the devices were reachable from tests and from nowhere else.

Three pieces, in dependency order:

GuestQueue reads the descriptor table, available ring and used ring out
of guest memory, so a driver in a booted guest and the device are talking
about the same bytes. Every field it reads is guest-written, so each is
bounded rather than trusted: a chain that cycles is refused instead of
walked forever, a descriptor index past the table is an error, an
indirect table may not nest, and a chain may not claim more than 64 MiB.

VirtioMmioTransport is the register file, virtio-mmio version 2. It
implements Device, so registering it with register_mmio_region puts it on
the MMIO exit path VM::run already takes. A notify arriving before
DRIVER_OK is ignored, because a queue mid-publication is not a queue.

VsockDevice is the first device on that transport: the channel a guest
agent will speak over, chosen because it works before the guest has
networking and needs no guest address. Credit is enforced in both
directions rather than assumed, and both buffers are bounded — the same
shape as the serial console overflow, and the case where an agent
streaming guest output would otherwise leak host memory. peek() reads
without consuming, for the same reason peek_output() does.

43 tests drive these against real guest memory: the vsock tests lay out
rings and publish descriptors exactly as a driver does, so the packet
encoding, connection state machine and credit arithmetic are covered end
to end. No test boots a kernel, so what they support is "the device
implements the protocol", not "a Linux guest connected".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
execute_script evaluates a Rhai script on the host against four read-only
scalars. Four places described it as running inside the guest, and its
documented example was a shell command Rhai cannot parse. The engine was
never the problem: there was no way to reach into a guest at all.

There is now. VM::attach_vsock maps a virtio-vsock register window into
guest physical address space and keeps the device, so a host process can
open a connection to a program inside the guest. hv2-guest-agent holds
the wire protocol -- length-prefixed JSON, one request at a time -- and
the in-guest binary that answers it. GuestAgent is the host client, and
AgentVM::exec_in_guest is the operation an agent calls.

Four things have to be true before a command runs, and each fails as
itself rather than as a generic timeout: no vsock device attached, a
guest that was never told where to look, a guest that is not running, and
a guest with no agent in it. "The host never attached a device" and "the
guest never answered" send an operator to different places, so they do
not share an error -- the same reasoning that made the console report
attached separately from its contents.

Running a command in a guest is gated on Capability::GuestExec, which
existed and had never been consulted. Reading a VM and running arbitrary
programs inside it are different powers, and VmRead should not imply the
second.

Deliberate limits, stated rather than implied: no streaming, output
capped at 1 MiB per stream with a truncated flag, and an exit code kept
distinct from a terminating signal, because a program killed by SIGKILL
did not exit 0. The guest is given a shorter deadline than the host
waits, so an overrun comes back as a reported timeout with whatever the
program printed rather than as host-side silence.

docs/GUEST_AGENT.md carries the build story: cross-compiling the binary,
getting it into an image, the kernel config the vsock transport needs,
and the trust model -- the channel is the boundary, and the agent runs
what the host asks as whoever started it.

The guest binary is Linux-only and so is not built by a Windows run;
it was type-checked with --target x86_64-unknown-linux-gnu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mechanism landed in the previous commit had no way in from outside
the crate. This adds vm.exec to the MCP tool surface and
POST /api/v1/vms/{id}/exec beside it, so an agent and an operator with
curl reach the same guest -- the same reason the console is shared
between them.

The tool description says outright how it differs from vm.execute_script,
and a test pins that: the reason this tool exists is that another one was
described as doing what it does, and an agent picks the wrong one for the
same reason a reader did.

Every way of having no guest to run in is reported as itself. No host
installed, VM never started, no channel attached, nothing answering
inside -- four different messages, because an agent that receives a
timeout when the real problem was a missing device will retry forever,
and one that receives an empty success believes a command ran that never
did. VmHost::exec therefore defaults to refusing rather than to an empty
result: a host that tracks VMs without running them has no guest, and
fabricating one is the shape of defect execute_plan had.

vm.exec carries its own PolicyAction and its own ToolCategory rather than
borrowing ResourceRead and System. Running a program inside a guest is
not reading a resource, and it is not administering the host the VM runs
on; folding it into either would let a policy that meant to allow one
allow the other.

LocalVmHost now builds its AgentVMs holding GuestExec. Authorization for
that path lives in the MCP layer, which checks the capability and VM
ownership before dispatch -- without this the inner gate refuses what the
outer one already authorized, and the two disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IronStack renamed the layer to match the plural naming its siblings use -- Communications, Languages, Interfaces, and now Interactions. No capability, dependency or surface changed; only the layer id, which the registry and this manifest must agree on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things here were named like sandboxes and confined nothing. hv2-agent's
Sandbox says so in its own docs: a policy object whose limits bind only
where a caller consults them. hv2-core's container module is 3,866 lines
of namespace, cgroup and seccomp structures whose start() reads "For now,
simulate with a PID" and reports Running with a fabricated 1000+n. A
repo-wide search for seccomp|setrlimit|unshare|prctl|CreateJobObject found
prose and struct fields, and not one confinement syscall.

This crate makes some, and is built around one rule: a sandbox that
silently drops a control is worse than no sandbox, because a caller who
asked for no network and got one believes the opposite of the truth.
So controls() reports what this host enforces, determined by probing
rather than by assuming; a spec asking for something the backend lacks is
refused, naming the control and why it is unavailable; and best_effort()
is an explicit opt-in whose result says what was dropped.

Two backends behind one trait. ProcessSandbox uses cgroup v2, namespaces,
rlimits and no_new_privs on Linux, and a job object on Windows.
MicroVmSandbox runs the workload in a guest over the vsock agent, which is
where the controls no host kernel gives an unprivileged process come from.
A caller picks isolation strength without changing how it asks.

Ordering is load-bearing in both, and both say why. On Linux the cgroup is
joined before CLONE_NEWUSER, because afterwards the file is unwritable,
and a second fork follows unshare(CLONE_NEWPID) because that flag puts the
*next* child in the namespace -- without it the workload would run in the
host's PID namespace while the code claimed otherwise. On Windows the
child is created suspended and assigned to the job before it executes an
instruction; assigning after spawn leaves a window to allocate past the
cap or spawn a process that escapes the set.

Deliberate refusals, rather than weaker things wearing the same name:
Linux filesystem isolation is unimplemented and reported as such, because
a chroot a retained descriptor walks out of is not isolation and
pivot_root needs a prepared root. macOS reports resource limits and
nothing else rather than building on a sandbox_init deprecated since 10.8.
SandboxCommand starts with an empty environment, since inheriting would
hand a sandboxed workload every credential the parent holds.

Verification, unevenly: Windows is verified on this host, including a test
that gives a workload a one-process job and confirms the kernel refuses
the process it tries to spawn. The Linux backend is type-checked for
x86_64-unknown-linux-gnu under clippy -D warnings and has never run on a
kernel; the probe design means it reports what fails rather than claiming
what it cannot do, but that is not the same as the isolation holding.
docs/SANDBOXES.md carries the table of what is verified where.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent that needs to run something on the host -- a build, a linter, a
conversion -- has had no way to ask, and the honest reason was that there
was nothing to run it under. A "run this command" tool without
confinement would have handed every agent the account the server runs as.
hv2-sandbox made confinement real, so the tool can exist.

sandbox.run and sandbox.capabilities dispatch against a SandboxHost, the
way vm.* dispatches against a VmHost. LocalSandboxHost runs the workload
under a ProcessSandbox; a deployment wanting a VM boundary instead passes
a MicroVmSandbox to with_sandbox and the tools do not change.

Three deliberate properties, each of which is a way this could have gone
quietly wrong:

With no host installed the tools refuse. The alternative to confinement
is not running the program unconfined, it is not running it.

The defaults are the strict ones -- 512 MiB, 30 seconds, no network, no
new privileges, processes isolated -- built by relaxing SandboxSpec's
untrusted spec rather than by assembling one field at a time, so a field
nobody set can never mean "unconfined". Unknown fields are rejected
rather than ignored: a misspelled allow_network should not silently
become the default in either direction.

Admin no longer implies HostExec. Every other capability is implied by
the Admin wildcard, and every other tool acts on VMs the server manages;
this one acts on the machine the server runs on. Leaving it in the
wildcard would have granted host execution to every session already
holding Admin the moment this shipped, which is a privilege expansion
nobody would have written down. HostExec and PolicyAction::HostExec are
separate from their GuestExec counterparts for the same reason: a program
in a guest cannot reach the host and one on the host can, however well
confined.

The repo's own ontology invariant caught two parameters I had left
without descriptions, which is what that test is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t found

The Linux backend had only ever been type-checked. This host turns out to
have a real kernel available -- WSL2 Debian, 6.18, unprivileged -- so it
has now been run, and running it found two defects of the same shape:
a claim that was true in the mechanism and false in what the workload
could actually observe.

best_effort was broken on any host without cgroup delegation. It promised
to run with whatever the host could enforce, then had the backend attempt
to create a cgroup the probe had already reported unavailable, and failed
with ConfinementFailed. The fix is structural rather than local: Sandbox
::run now filters the spec through SandboxSpec::without_controls before
handing it down, so a backend can never attempt a control its own probe
rejected.

/proc and /sys were inherited. Neither is an ordinary directory -- each is
a view of the namespace it was mounted in -- so the workload was correctly
PID 1 in its own namespace and could still enumerate 48 host processes,
and had only loopback on netlink while /sys/class/net listed the host
interfaces. "Cannot signal" was true; "cannot see" was not. Both
namespaces now bring CLONE_NEWNS and remount the filesystem that describes
them, and both probes rehearse the remount rather than only the unshare,
so a kernel where it fails reports the control as unavailable.

The new tests ask the workload what it can see rather than reading
controls() back. That distinction is the point: a test that only checked
the reported set would pass on a backend that reported everything and
applied none of it. On this kernel the workload is PID 1, sees 3
processes, and has one interface.

An example, `probe`, prints what any given machine can enforce and asks a
confined workload what it can see. The answer differs per kernel, per
container and per cgroup delegation, which is why it is a program rather
than a paragraph.

Linux: 20 tests, clippy -D warnings, on the kernel. Windows: 18 tests
unchanged, workspace 4812 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI on real macOS found it: the sandbox host test asserting that a non-zero
exit is a result rather than an error spawned /bin/false, which does not
exist there. Every other path these tests use -- /bin/echo, /bin/sh,
/bin/sleep -- is in the same place on both, so this was the only one.

/bin/sh -c "exit 3" instead, which is portable and lets the assertion name
a specific status rather than only "not zero".

The failure was in the test, not the backend; the macOS sandbox backend
itself built and its other tests passed. That job is also the first
evidence the unix fallback compiles and runs on real macOS rather than
only type-checking for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous page described a boot path that had reached "able to execute
a guest" and a CI run that had just gone green. Both statements have moved:
there is now a channel into the guest and a sandbox that confines, and the
CI picture is a pull request away from landing rather than a merged fact.

Its organising device is a verification ledger -- every claim paired with
the evidence behind it and where that evidence came from. That column is
the point. This session started by inheriting the assumption that no Linux
kernel was available here, repeated it twice, and was wrong: WSL2 Debian is
one command away, and running the sandbox on it found two defects that
cross-compiling could not. "Compiles" and "was run" needed to stop looking
alike on the page.

The previous handoff is recoverable at a7668f3 if its boot-path archaeology
is wanted -- the WHPX 0x80370302 diagnosis and the CI recovery history are
not repeated here.

Also published as an artifact for reading outside a checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This module models the OCI runtime spec: 3,866 lines of namespace, cgroup
and seccomp types plus a lifecycle state machine, with nothing behind them
and, as it turns out, no callers anywhere in the workspace. That would be
fine. What was not fine is that it reported success.

ContainerRuntime::start invented a PID -- 1000 + n -- and marked the
container Running. A caller had no way to tell that from a working
runtime: the state was right, the PID was plausible, and nothing was
confined or even executing. It refuses now.

Writing the test for that turned up the same defect in a second place.
kill() returned Ok(()) for a container in any state other than Running,
reporting a signal delivered to a process that did not exist. It refuses
now too.

Container::start is untouched and stays honest: it takes a PID from its
caller, so whoever supplies one is the party that knows it is real. The
tests that drove the fabricated path have been rewritten to asserts what
is true -- that start refuses, that a refused start moves nothing and
invents no PID, that stats count three Created containers rather than one
imaginary running one -- and one now exercises the state machine directly,
which is the half that works.

The module header says plainly what this is and points at hv2-sandbox for
confinement an operating system actually enforces.

Workspace: 4,812 tests passing, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…repairs

The repo keeps an Unreleased section per crate and the previous session
maintained it; this session's work was missing from it entirely.

Added covers the two tracks: a guest that can finally be reached, and
confinement an operating system enforces. Changed carries the one entry
that alters existing behaviour -- Admin no longer implying HostExec --
because that is the item most likely to surprise someone who upgrades
without reading further. Fixed records the container runtime that reported
success for things that never happened, and the two sandbox defects that
only turned up once the code ran on a real kernel instead of
cross-compiling for one. CI records three workflow steps that had never
run once, and says plainly that Benchmarks stays red for a reason a
one-line fix does not address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st double

The in-guest binary had only ever been type-checked for Linux. This repo
already knows that cargo check does not link -- it is why the HVF backend
reached CI broken -- so "it compiles for the target" was the weakest claim
in the stack.

Built and run on Linux 6.18 it links to a 497 KB ELF, binds AF_VSOCK port
1024 (ss --vsock confirms the listener), accepts a connection, answers a
ping, runs /bin/sh -c and returns its stdout with the program's real exit
code, and refuses a mismatched protocol version with a message naming both
sides. That is the framing, the read loop and the exec path exercised
through the kernel rather than through the fake channel the unit tests use.

The probe that did it ships as tools/vsock_probe.py, because the three
things it tells apart -- no agent, wrong version, not answering -- are
exactly what someone bringing up a guest image needs when vm.exec times
out and the image looks fine.

The docs now separate the two halves rather than calling the whole stack
unverified. The agent half works. The device half still has no kernel
booted against it, and the halves have never met: the agent was proven
over the kernel's own vsock stack, the device against tests. Joining them
is the first real boot.

A local run needs vsock_loopback, which was loaded for the test and
unloaded afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… was set

Control::Memory was reported as enforced on Windows on the strength of
SetInformationJobObject returning success. That is configuration, not
enforcement -- the same gap the process-count test already closed for the
other half of the job object.

A workload under a 256 MiB job now asks for 1 GiB and gets
System.OutOfMemoryException back from the kernel.

The first version of this test asserted the workload failed, and it did
not: PowerShell catches the allocation failure, reports it, and still
exits 0. The exit code was never the evidence. The refused allocation is,
so that is what the test reads, and the comment says why -- a later reader
would otherwise "fix" it back.

Windows 19 tests, Linux 20 unaffected (the test is cfg(windows)).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    platform      : Kvm
    provision     : OK -- backend VM and vCPUs exist
    launch        : OK -- the guest is executing
    console       : "Hello, World!\r\n"

The handoff called this the gate on every boot-path claim, and said the
KVM path had never run at all -- type-checked only. It needed a machine
with an accessible /dev/kvm, which this one turns out to have: WSL2 has
nested virtualisation on, kvm_amd loaded, svm in cpuinfo. The blocker was
an inherited assumption, not hardware.

VM::provision now demonstrably creates a real KVM VM and its vCPUs,
load_boot writes a real image into guest physical memory, and launch runs
it. The 512-byte real-mode image in examples/guest_code writes to COM1 and
halts, so the evidence is the guest's own output rather than an exit code
-- and it arrived through SerialDevice, DeviceManager and
VM::console_output, which means the console plumbing from the previous
session was right.

Two probes ship with it, because the answer differs per machine and is
worth being able to ask: kvm_probe reports platform, VM::new and
provision separately, since a host can pass one and fail the next --
Windows does, when Hyper-V owns VT-x. boot_probe runs the whole path and
prints what the guest said.

Writing them turned up a detection bug. is_kvm_available checked
Path::new("/dev/kvm").exists(), which is true on any host with the module
loaded, including the very common case where the calling user is not in
the kvm group. detect() then reported Kvm and every later call failed with
a permission error naming none of this. It opens the device now, so an
unprivileged process falls back to Tcg -- the honest answer to the
question detect is actually asking. Verified both ways: as root, Kvm; as
an unprivileged user on the same machine, Tcg.

hv2-core: 2,084 tests on Linux with KVM live, 2,137 on Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…startup

The workload has to fit under the cap it is testing. PowerShell does not:
it spends most of a 256 MiB budget on its own startup, and on a cold CI
runner it spent the entire wall-clock deadline doing that, so the test
failed with killed_by: WallClock and empty output -- a timeout that said
nothing either way about whether the job limit binds. It passed here only
because this machine is warm.

The workload is now this test binary re-executed with a variable that
makes it ask Windows to commit a fixed number of bytes and print which
answer came back, via try_reserve_exact so a refusal is a value rather
than an abort. It runs in under a second.

Both directions are asserted now: refused under a cap below the request,
granted under a cap above it. One-sided, the test also passes on a host
that simply had no memory to spare, which is no evidence about the cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ledger's one genuinely unverified row said "no kernel has booted on
it" and put the cause down to hardware. Half of that is now false and the
other half was never true: a guest executes on KVM here, and what the
vsock halves still need to meet is a guest kernel built with
CONFIG_VIRTIO_VSOCKETS, not a different machine.

Adds the boot and the detection fix to the changelog, adds the row to the
handoff table, and rewrites the GUEST_AGENT paragraph that sent the
reader looking for hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running a real bzImage against BootSource::Linux found two required
pieces of the boot protocol missing.

boot_params carried no e820 map. The entry count at 0x1e8 was zero and
the table at 0x2d0 was empty, and a guest booted this way runs no BIOS,
so there is no INT 15h to fall back on: the kernel finds no RAM at all
and stops before it has a console to say so on. The map is built from the
guest's memory size now. That arrives via LoadedBoot::set_memory_size
rather than load(), because an API server validating an image has no VM
and no size to give; asking for the memory regions without one is refused
instead of answered with an empty map.

No KVM vCPU had ever been given a CPUID configuration. set_cpuid and
get_supported_cpuid were both written and neither was called by anything,
so the guest saw a CPU reporting no vendor, no features and a maximum
leaf of zero. A Linux kernel asks within its first few dozen
instructions. create_vcpu applies the host's supported set now, which is
what a VMM with no CPU model configured is expected to do.

The visible effect is a guest that executes rather than spinning: it
reaches the kernel and triple-faults, which is a failure with a next
step. The kernel does not boot yet and this claims otherwise nowhere.
examples/linux_boot_probe.rs reports how far it gets; the 32-bit entry
state was checked separately with a hand-assembled image that executes at
1 MB and drives COM1, so what remains is specific to the Linux path.

Also drops a register dump on KVM_EXIT_SHUTDOWN that looked useful and
was not: on SVM, KVM resets the vCPU before returning that exit, so the
registers always read as the reset vector. The comment says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent's history is normally kept by putting it back in the prompt.
That forces the decision about what matters to be made at write time:
when a tool returns 40 MB, something chooses what to keep before anyone
knows what the next question will be, and whatever it discards is gone.

The new crate implements the alternative from Scroll (arXiv:2608.21690).
The history lives outside the context as something the agent queries, and
everything rests on one invariant: eviction changes the view, never the
record. EventLog is append-only and offers no operation that edits or
removes an event -- not as discipline, as absent API -- and every event
has a Seq that never changes and never repeats. Payloads over 8 KiB move
to a store behind a handle and are still indexed by their whole content,
because indexing the preview would make a large result findable only by
its first 240 bytes, which is where nothing interesting ever is.

WorkingView::evict persists before it selects, so nothing can leave the
view before it is addressable; then it protects the active turn and the
recent tail, folds unprotected tool payloads down to their addresses, and
only then evicts, leaving a Headline in a tiered index. A headline is a
pointer and not a summary: a summary replaces what it describes and a
headline sits beside it carrying its address, so thin or wrong is
survivable. The index keeps recent history detailed and coarsens distant
history to O(k log_k n) blocks, and every entry at every tier still
carries its span.

Seven MCP tools dispatch against a ContextHost the way sandbox.* does
against a SandboxHost. With none installed they refuse: a record that
accepts every write and loses it is worse than none, because the agent is
told it succeeded. context.exec needs HostExec as well as the new
ContextMemory capability, since being able to read the record is not a
reason to run code on the machine holding it.

The runtime is a confined process with a durable workspace, not a
resident namespace: files persist between calls and variables do not.
docs/CONTEXT_AS_ENVIRONMENT.md says so, along with the log being flushed
rather than fsynced and the log being unreachable from inside the
sandbox by absence rather than by policy.

Two rules the integration tests found rather than confirmed: the newest
tool result can only be protected while it is still recent, or one old
result pins the whole view and eviction silently stops working; and
previews have to be bounded by the index, not only by the payload store,
or a small inline payload comes back whole from every search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the four are what they are: the entry state was checked and
works, the context crate was checked end to end, and the resident
namespace was not built. The fourth says the Linux boot protocol is still
not verified, because two required pieces being present is not the same
as a kernel booting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/bin/grep does not exist on macOS. The sandbox runs a program directly
rather than through a shell, so nothing searches a PATH on our behalf and
the test failed on the macOS runner with a spawn error while passing
everywhere else. Same shape as the /bin/false assumption this repo hit
before: ask the filesystem which of the two usual paths is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    [    0.020] Early memory node ranges
    [    0.021]   node   0: [mem 0x0000000000001000-0x000000000009efff]
    [    0.022]   node   0: [mem 0x0000000000100000-0x000000007fffffff]

Those are the two e820 entries this loader writes, read back out of the
guest. The kernel decompresses itself, enters the kernel proper, sets up
its zones and prints through SerialDevice into VM::console_output. It
does not reach userspace: it currently stops polling the DMA controller,
which is the next legacy device to bring up, and the ledger says so.

Four defects, each found by the one before it.

Single-step tracing came first, because nothing else could see the
problem. A triple fault arrives as VmExit::Shutdown and KVM resets the
vCPU on AMD before returning it, so the registers afterwards describe the
reset vector rather than the fault; a guest spinning in a tight loop
never exits at all. VM::single_step_trace records the address before each
step for exactly that reason, and keeps a bounded tail, since a guest can
run millions of instructions before it fails and the interesting part is
always the end. It located the fault in one run: 18,602 instructions in,
at a push instruction, with a stack pointer computed from boot_params.

That pointed straight at the setup header. create_boot_params copied a
fixed 0x1f1..0x250, which was the whole header under boot protocol 2.09
and has not been since; the image says where its header ends, at 0x202
plus the byte at 0x201. Everything past 0x250 reached the guest as zero,
including init_size at 0x260, which is what the kernel computes its stack
pointer from. Zero put %rsp somewhere unmapped.

With that fixed the kernel ran and immediately met three devices that
could not work behind the device manager. All three share a shape:
correct when a unit test calls them directly, broken the moment a guest
does. SerialDevice::read refused anything but one byte and the refusal
reached handle_exit as a device error that stopped the VM, so a kernel
probing the port with a word read killed its own guest; its write dropped
every byte after the first. RtcDevice and KeyboardDevice decoded absolute
ports while DeviceManager passes port - base_port, so every real access
fell through to an error arm. A 16550 and an i8042 are byte-wide register
files: a wide access walks consecutive registers now, and a port the
device does not implement reads as absent rather than as a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    [    0.850258] Run /init as init process
    [    0.851140] Kernel panic - not syncing: Attempted to kill init!
                   exitcode=0x00002a00

0x2a is 42, which is what the init in the initramfs returns. That line
only appears if the kernel unpacked the archive and executed the binary,
so it is the proof, and it needs no device and no privilege in the guest.

Four defects between the last commit and that line.

The initrd was placed at a fixed 32 MB. A compressed kernel unpacks into
init_size bytes from where it will run -- 62 MB from 16 MB for the kernel
tested here -- so 32 MB is inside that region for any kernel of ordinary
size, and decompression wrote over the initrd. The kernel then reported
invalid magic about bytes it had destroyed itself. It now goes as high as
it fits, under the header's initrd_addr_max, clamped to guest memory, and
is refused outright if it cannot clear the unpack region.

A FIFO reset erased the console transcript. Linux resets both FIFOs when
its 8250 driver takes over from earlyprintk, and this cleared the
transmit buffer -- so every guest that got far enough to initialise its
serial driver wiped its own boot log on the way past. On hardware that
reset discards bytes not yet sent; here a THR write is the transmission,
so there is nothing unsent and the buffer is the transcript.

Every device access was four bytes wide whatever the guest asked for: the
dispatch layer built a [u8; 4] and discarded the size the exit reported.
Register files are byte-wide and their reads have side effects, so a
one-byte outb of a character also cleared interrupt-enable, FIFO-control
and line-control. It looked like it worked, which is why it survived a
boot. The width travels with the access now.

An unhandled exception livelocked the VM. Only a double fault stopped it;
everything else was injected via a path that defaults to inject_interrupt
-- a hardware interrupt carrying the exception's vector, which does not
re-run the faulting instruction. So the guest re-took the same exception
forever: ~60,000 exits a second, state still Running, nothing saying why.
Unhandled exceptions stop the VM and name the vector.

An audit prompted by the earlier device bugs found the same shape in the
PIT, which is registered and would stop the VM on any wide read; fixed.
IdeController and VgaDevice have it too, are registered nowhere, and now
say so in their module docs rather than being changed untested.

Userspace output does not come back yet. printk polls the line-status
register, but a userspace write goes through the tty layer, which waits
for a transmit interrupt -- and with an in-kernel irqchip that has to be
injected with KVM_IRQ_LINE. The changelog and the ledger say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Control::FilesystemIsolation was probed as unavailable and refused, which
was honest and still a hole: an agent asking for a confined filesystem
got an error. It is enforced now with CLONE_NEWNS and a two-step
pivot_root -- chroot was rejected for the reason the module docs already
gave, that it leaves the old root mounted and a retained directory fd
walks out.

Ordering matters and is not visible to a compiler: the pivot has to come
after the cgroup join and after uid_map is written, because those are host
paths that stop existing at the pivot, and /proc and /sys have to be
mounted after it or they land in the root being discarded.

Two things the kernel forced. A non-recursive bind of a directory with
submounts fails with EINVAL inside a user namespace, so the bind is
MS_REC; but then MS_REMOUNT|MS_RDONLY covers only the top mount, leaving
a submount writable inside a mount the caller was told is read-only.
mount_setattr(AT_RECURSIVE) closes that, and its failure is fatal rather
than ignored. Kernels before 5.12 have no mount_setattr, so the probe
reports the control unavailable there rather than quietly giving them the
weaker single-mount remount.

Seven tests, each asking the workload what it can see rather than reading
controls() back. One of them initially passed for the wrong reason: it
redirected stderr to /dev/null, and an isolated root has no /dev, so both
redirections failed and the refusal it asserted said nothing about the
mount. The writable-direction half of the assertion caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t installed

Two gaps closed. ResidentRuntime keeps an interpreter alive across calls
behind the existing ContextRuntime trait, so a result computed once stays
an object a later call can use -- the shape the paper describes, and the
thing SandboxRuntime cannot do because every call is a fresh process. The
trade is stated rather than hidden: a running process cannot be
re-confined, so it is confined once at spawn, where SandboxRuntime
confines every call and lets the spec change between them.

The other gap was that nothing ever called set_context_host, so all seven
context.* tools refused in any real deployment while every unit test
passed -- the tools were correct and the product was useless. The server
installs one now, with configuration for the record's root and the
runtime's workspace, mirroring how the sandbox and VM hosts are already
installed rather than inventing a second mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release workflow has run once and all four builds failed in about
seven seconds. It was not a workflow defect: every job reports steps: []
and no runner, and the check annotation says the job was not started
because account payments failed or the spending limit needs raising. No
amount of YAML fixes that.

The defects behind it are real and would have failed the first genuine
run, so they are fixed now: protoc was never installed although hv2-cli
pulls in hv2-api, whose build script needs it, and the aarch64 Linux
target was built through a container the host's protoc could not reach.
That target is cross-compiled directly now with an explicit linker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The resident runtime and the sandbox runtime do not supersede each other:
per-call confinement or a namespace that survives, not both. The doc says
which is which rather than leaving a reader to infer it, and the lockfile
catches up with the crates added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nervosys and others added 2 commits August 26, 2026 19:58
rustdoc runs with -D warnings in CI, and an intra-doc link to a private
item fails the Documentation job. The note is about a public module and
the handler it names is not public, so it says so in prose instead of
linking.

Reproduced with RUSTDOCFLAGS="-D warnings" cargo doc --no-deps, which is
what CI runs and what a plain cargo doc does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
set_irq_line asserts and releases a line through the interrupt
controller, which for KVM means KVM_IRQ_LINE on a KvmVm::irq_line that
had been written and never called -- the third method in this backend
found that way, after set_cpuid and set_guest_debug.

Deliberately not inject_interrupt. That hands a vector straight to the
vCPU and bypasses the controller's masking and priority, which is wrong
whenever an in-kernel irqchip exists, and is the mistake the exception
path was already making.

Device::pending_interrupt lets a device report the line it is asserting,
polled after every access because that is when the condition changes: a
UART becomes ready to send the moment the guest writes a byte. The
16550's two sources are implemented and unit-tested.

What this does NOT do is fix userspace output, and the changelog says so.
What is now established: a guest reaches userspace, its init opens
/dev/console, and the write returns the full byte count -- so the tty
layer takes the data and never hands it to the device, and not after
eight seconds either. The guest writes IER=0 every time and never sets
MCR OUT2, the gate that puts a UART's interrupt on the line at all, so it
is running the port without interrupts and this path is never exercised.
The plumbing is a prerequisite that was missing, not a fix that was
verified, and the ledger records it as delivery-unproven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant