What it is
AF_VSOCK is a Linux socket address family built for one purpose: letting a hypervisor host and one of its guest VMs talk to each other without a network. A normal socket between two machines needs an IP address, a route, and something listening on a port that’s reachable over that route. A vsock socket needs none of that. The hypervisor itself (Firecracker, in this case) wires up the channel directly between a host-side Unix domain socket and a port number inside the guest kernel, so there’s no IP stack involved on either side of that specific channel, no ARP, no interface to bring up.
Firecracker exposes vsock as a single virtio-vsock device per VM, backed on the host by one Unix domain socket path. A process on the host connects to that path and sends a short handshake line naming which guest port it wants; Firecracker then bridges the rest of that TCP-like connection straight to a listener inside the guest on that port.
Why sandkiln uses it here
The guest agent (sandkiln-guest-agent, a small binary baked into every sandbox’s rootfs) needs to receive commands from the daemon and send back output: exec a process, read or write a file, open a PTY. Doing that over the sandbox’s own network interface would mean either exposing an extra listening port on the same interface a caller’s own outbound traffic uses, or building a second, isolated guest network just for control traffic. vsock sidesteps the whole problem. It’s a channel the guest agent listens on that has nothing to do with the sandbox’s network configuration at all, doesn’t need enp0s...-style interface setup inside the guest, and isn’t reachable from anything the sandboxed workload itself can touch, since it was never IP traffic to begin with. A deny_all egress policy (see iptables and per-sandbox egress policy) has no bearing on it for the same reason.
Key terms
- AF_VSOCK. The Linux socket address family for hypervisor-guest communication, distinct from
AF_INET/AF_INET6. - CID (context ID). vsock’s equivalent of an IP address: an integer identifying one side of the channel. Firecracker manages this internally; sandkiln’s own code never has to construct or parse a CID directly, only the Unix domain socket path and the port.
- Guest port. A plain integer the guest-side listener binds, analogous to a TCP port but scoped only to vsock traffic for that one VM.
- Framing. The scheme that tells a reader where one message ends and the next begins, on a byte stream that has no built-in message boundaries of its own.
How it works in sandkiln
sandkiln-protocol (a small crate depended on by both the host-side sandkiln-vmm and the guest-side sandkiln-guest-agent, so the two sides can never define the message shapes differently) defines the actual framing: every message is a 4-byte little-endian length prefix, followed by exactly that many bytes of JSON. It’s deliberately not newline-delimited, since exec output can contain arbitrary bytes, including embedded newlines and even embedded null bytes, and a length prefix is the one framing scheme a payload like that can’t corrupt by accident. JSON itself is a deliberate, boring choice on top of that: no schema compiler needed for a protocol this small, readable directly in a packet capture while debugging, and a tagged cmd/status field on the Request/Response enums (#[serde(tag = "cmd")] on the Rust side) keeps the handful of message shapes self-describing.
There are four separate vsock ports, not one, because the different kinds of traffic have genuinely different shapes:
AGENT_PORT(5000). One request in, one response out, then the connection can close. This is whereexec,read_file,write_file,chmod, and the rest of the file-operation calls all go, each as oneRequestvariant and one matchingResponse.PTY_PORT(5001). A long-lived connection that sends exactly one framed handshake (PtyHandshake { cols, rows }), then drops into a raw, unframed byte passthrough for the rest of its life. An interactive shell’s input and output aren’t discrete messages, and framing them would just add overhead to a channel that’s supposed to feel like a real terminal.EXEC_STREAM_PORT(5002). Also long-lived, but unlikePTY_PORTit never stops being framed: after oneExecStreamHandshake, every subsequent message is a framedExecStreamEvent(Stdout,Stderr, or one finalExitcarrying the process’s exit code). A caller watching a detached background command needs to know which stream a chunk came from and when the process actually exited, not just an undifferentiated blob of bytes.TUNNEL_PORT(5003). The one port where the direction reverses — the guest dials out instead of the host dialing in (see Local tunnel: the mechanism). The host pre-binds a plain Unix socket at<uds_path>_<port>; each connection the guest’s local tunnel listener accepts dials this port, sends one framedTunnelOpen { tunnel_id, conn_id }handshake, then becomes a raw passthrough, the same handshake-then-passthrough shapePTY_PORTuses.
sandkiln-vmm hand-rolls the roughly ninety lines a minimal client actually needs: write the request line and body, read the status line, read headers looking only for Content-Length, then read exactly that many bytes back.
See it in action
A realexec call, captured live against a running daemon (curl -i, full headers included):
AGENT_PORT, in the exact wire format sandkiln-protocol defines, for that same call:
env field is always present, even empty, because the daemon resolves a sandbox’s create-time environment-variable defaults and any per-call override into one final map before it ever builds this message; the guest agent has no notion of the two layers, only the one map it’s handed.