nova-runtime: unified ingress and egress control
· Architecture team
Summary
In nova-runtime, every byte into or out of a NovaScale workload passes through one trusted supervisor per node, enforced from a single set of rules in policyDB. Workload pods run as Kata microVMs with no network device. The supervisor owns their VPC IPs, so each workload is still reachable at a unique IP in the VPC, exactly as today.
The design borrows egress mediation from NVIDIA OpenShell and identity-checked mTLS ingress from Ghostunnel.
Plan of record: nova-runtime runs on Kata. Each pod is a Kata microVM with no network device, and it reaches the supervisor only over vsock. runsc is retired once Kata + nova-runtime passes the evidence under Isolation layer: Kata; plain runc is not used for nova-runtime workloads.
Goals
One source of truth: ingress and egress rules live in policyDB; policy-controller compiles them into one signed OPA bundle per node.
Unchanged external model: workload IPs still come from the node's delegated subnet; VPC routes, security groups and flow logs see the same addresses.
No path out except the supervisor: workload pods are non-root, have no capabilities, no NIC and only loopback.
Two independent checks on every call between Nova workloads: egress on the caller's node, ingress on the callee's.
Identity, not IPs, decides: mTLS with SPIFFE IDs between workloads; keys and credentials never enter a workload.
Fits today's stack: execution-controller placement, novalet, OVS VPC and workload bridges, single-tenant nodes.
Non-goals for v1
UDP and QUIC (denied by default).
Multi-tenant nodes (nodes are single tenant).
Changes to the scheduler; IPAM changes only by a reuse cooldown in novalet.
Transparent capture for apps that ignore proxy settings (phase 2, through seccomp notify).
Why nova-runtime, and not OpenShell or Ghostunnel as they are
Neither tool covers NovaScale on its own. OpenShell confines what a sandboxed agent can reach on the way out; Ghostunnel checks who may connect on the way in. NovaScale needs both directions, for every workload, tied to its own placement, IPs, identities and network. Today it has internet egress open apart from IMDS and RFC1918, secrets travelling in specs, and the per-pod front built but not deployed.
| Capability | OpenShell | Ghostunnel | nova-runtime |
|---|---|---|---|
| Ingress control | Not its focus | Yes: mTLS, cert-field rules or OPA, once per connection | Yes: mTLS, TLS or TCP modes, per request on HTTP and gRPC |
| Egress control | Yes: per binary, host and HTTP request | Static: one fixed target per client listener | Yes: any destination policy allows, per request |
| One policy for both directions | No: egress YAML per sandbox | No: flags or OPA per process | Yes: one policyDB rule compiled into the caller's egress and the callee's ingress |
| Two independent checks per call | No | No | Yes: caller's node and callee's node decide separately |
| Workload identity | Executable path and hash inside the sandbox | Certificate of whoever runs it | SPIFFE ID per workload; keys held by the supervisor, never by the pod |
| Unique VPC IP per workload | No: its own network fence | No | Yes: supervisor owns the IP from novalet's IPAM; security groups and flow logs unchanged |
| Fits NovaScale placement and OVS | No: its own gateway and drivers | No | Yes: execution-controller, novalet, OVS bridges, VPC local DNS |
| One supervisor for many pods | No: one per sandbox | No: one process per listener | Yes: per node, with per-pod identity, credentials, policy and limits |
| Credentials kept out of workloads | Yes: placeholders at approved endpoints | No | Yes, plus no secret values in specs |
| Per-app approved syscall profiles | No: fixed process rules | No | Yes: signed per image digest, notify tier, placement gate |
| Isolation layer | Container or microVM drivers | None: it is a proxy | Kata microVM for every workload, no NIC, vsock only |
| Policy rollout across the fleet | Gateway per deployment | Files or OPA per process | Signed bundle per node from policy-controller, with a publish gate |
What nova-runtime reuses. From OpenShell: the supervisor and sandbox split, egress mediation, credential placeholders, and the rule-change gate. From Ghostunnel: mTLS termination, cert-field and OPA authorization, SPIFFE support and self-sandboxing, carried forward in the existing nova.patch fork.
Architecture

The supervisor holds every workload IP, key and credential on the node. Each pod is a Kata microVM with no network device that reaches the supervisor only over vsock, and OVS sends each workload /32 to the supervisor's single port.
Components
The supervisor is the only trusted, network-facing part of a workload's path; everything inside the workload pod is assumed hostile. The supervisor grows out of today's per-pod Ghostunnel identity front (novalet/internal/front plus ghostunnel/nova.patch).
| Component | Runs where | Trusted | Job | Built from |
|---|---|---|---|---|
| Supervisor pod | One per node (two instances for HA), own host netns, one port on the workload bridge | Yes | Owns every workload IP on the node; mTLS ingress; egress mediation; OPA decisions; holds SVIDs and credentials | Ghostunnel fork: today's nova.patch plus HTTP mode, connect mode, multi-endpoint, vsock |
| Workload pod | Kata microVM per placement: no virtio-net, guest netns with lo only | No | Runs the app as non-root with no capabilities | Kata, with pod networking turned off |
| VMM | Host process per pod (QEMU or Cloud Hypervisor), own netns with only lo | Yes | Runs the microVM; seccomp-filtered | Kata's VMM |
| nova-relay | Inside each guest, unprivileged | No | Bridges 127.0.0.1 ports to vsock; serves the HTTP proxy for egress | New, small |
| vsock channel | vhost-vsock between guest and host | n/a | Egress: the guest dials the supervisor's vsock port. Ingress: the supervisor dials the guest's ports. The host-set peer CID is the pod's identity | Kata already uses vsock for kata-agent |
| OVS VPC bridge | Node | Yes | Uplink; delegated subnet to and from the patch port | Existing |
| OVS workload bridge | Node | Yes | Per-workload /32 to the supervisor port; anti-spoofing on that port | Existing bridge, new flows |
| OPA agent | Node, local socket | Yes | Evaluates ingress and egress decisions from the signed bundle | Existing (long-poll bundle) |
| Syscall supervisor | Node, host side | Yes | Decides notified syscalls from each app's approved profile | New |
| policy-controller | Control plane | Yes | Compiles policyDB rules into one signed bundle per node; signs syscall profiles | Existing, new compiler stage |
| SPIRE agent | Node | Yes | Issues per-pod SVIDs to the supervisor through the Delegated Identity API | Existing SPIFFE IDs, new delegation |
SPIFFE IDs keep today's shape, spiffe://…/t/<tenant>/w/<workload>.
Addressing and data path
IPAM and VPC routing do not change; only the last hop on the node does. A workload's IP still comes from the node's delegated subnet, but OVS sends it to the supervisor port instead of a pod port, and the supervisor owns the address.
VPC bridge. The delegated subnet goes in through the patch port; only the subnet comes back out. Internet SNAT, security groups, IMDS and RFC1918 blocking stay where they are today.
priority=100,in_port=uplink,ip,nw_dst=10.64.3.0/26 actions=output:patch-wl
priority=100,in_port=patch-wl,ip,nw_src=10.64.3.0/26 actions=output:uplink
priority=0,in_port=patch-wl actions=drop
Workload bridge. novalet adds two flows per endpoint at READY and removes them at drain. Anti-spoofing moves from "each pod port sources its own IP" to "the supervisor port sources only IPs it has registered".
priority=200,in_port=patch-vpc,ip,nw_dst=10.64.3.21 actions=mod_dl_dst:<sup-mac>,output:sup
priority=200,in_port=sup,ip,nw_src=10.64.3.21 actions=output:patch-vpc
priority=10,in_port=sup actions=drop
Per-/32 flows are kept even though one supervisor fronts the whole subnet: traffic to an IP that is not READY dies in OVS, and each workload gets its own packet and byte counters. An ARP responder answers the gateway IP that the supervisor netns routes through, as for pods today.
Supervisor netns. A local route makes the workload IP bindable without per-pod interface config (the AnyIP trick). It is added at READY and removed at drain, so the kernel never answers for a workload that is not running.
ip route add local 10.64.3.21/32 dev lo table local
Guest netns. Inside the Kata VM, the same IP goes on the guest's lo before the app starts, so apps that bind to or advertise their own IP (Kafka advertised.listeners, Cassandra broadcast_address) keep working. It is also passed as NOVA_WORKLOAD_IP. No NIC sits behind it: the VM has no virtio-net device.
ip addr add 10.64.3.21/32 dev lo # inside the guest, before the app starts
Ingress listeners. One listener per exposed (IP, port), not a wildcard. The kernel resets ports that policy does not expose, and the local address of an accepted connection selects the endpoint.
ln, err := net.Listen("tcp", net.JoinHostPort(ep.WorkloadIP, strconv.Itoa(p.Port)))
// accept: conn.LocalAddr() == 10.64.3.21:443 → endpoint → guest vsock port 8080
Egress source address. The supervisor dials from the caller's workload IP, so security groups, firewall allowlists, NAT and flow logs see what they see today. Return traffic is addressed to that IP and comes back to the supervisor.
d := net.Dialer{LocalAddr: &net.TCPAddr{IP: net.ParseIP(ep.WorkloadIP)}}
conn, err := d.DialContext(ctx, "tcp", dest) // only after the egress decision allows it
Calls inside one node. If the destination IP belongs to a workload on the same node, it is local in the supervisor netns: the dial loops back to the supervisor's own listener, both checks still run, and OVS is not involved.
If the bridges run on the OVS-DPDK datapath, the supervisor's kernel-facing port should be a virtio-user or AF_XDP port; tap and internal ports are a slow path there.
Policy model
A rule is written once in policyDB and compiled twice: into egress for the caller and ingress for the callee. The two sides of a call cannot drift apart because they come from the same row.
Rules in policyDB. Today's label-selector policies gain direction-free from / to, an optional L7 block, and an optional credential grant.
- id: x-to-a
tenant: t
from: { workload: x } # or a label selector
to: { workload: service-a, port: https }
http: { methods: [GET, POST], paths: ["/v1/orders/**"] }
- id: a-to-stripe
tenant: t
from: { workload: service-a }
to: { external: api.stripe.com, port: 443 }
http: { methods: [POST], paths: ["/v1/charges"] }
credential: stripe-live
- id: partner-to-a
tenant: t
from: { external_ca: partner-ca, uri: "spiffe://partner/*" }
to: { workload: service-a, port: https }
- id: vpc-lb-to-a
tenant: t
from: { cidr: 10.64.0.0/16 }
to: { workload: service-a, port: health }
Ports belong to the workload. Each exposed port declares how the supervisor terminates it.
| Mode | Termination | Decided on | Typical callers |
|---|---|---|---|
mtls | mTLS, client cert required | Peer SPIFFE ID or cert SANs, plus L7 | Nova workloads, partners with certs |
tls | Server cert only | Source CIDR or JWT, plus L7 | VPC services, load balancers |
tcp | None, byte copy | Source CIDR and port | Databases, legacy protocols |
A separate protocol: http | grpc | tcp field turns L7 checks on. gRPC gets per-RPC rules for free, because the method is the :path.
What each rule compiles to.
| Rule shape | Egress entry | Ingress entry |
|---|---|---|
| workload → workload | Caller's node bundle | Callee's node bundle |
| workload → external host | Caller's node bundle | None |
| external CA, CIDR or JWT → workload | None | Callee's node bundle |
The bundle is keyed by workload. Today's allowed_callers[callee] = [caller] in authz.rego grows into rule objects {peer, methods, path_prefixes}, plus a new egress[caller] set.
OPA input. One Rego package, two input shapes:
{"direction":"ingress","target":"service-a","port":"https","peer":"spiffe://…/t/t/w/x","src_ip":"10.64.3.21","method":"GET","path":"/v1/orders/42"}
{"direction":"egress","source":"x","dest_host":"service-a.t.nova","dest_port":443,"method":"GET","path":"/v1/orders/42"}
When decisions run.
tcpports: once per connection. As today,--max-conn-lifetime(60 s) bounds how long a revoked caller keeps an open connection.httpandgrpcports: at the handshake (rejects callers with no grant before any parsing), then once per request.Default: deny. A per-rule
auditmode logs would-be denials without blocking, for rollout.
Change gate. Before publishing, policy-controller compares the new bundle with the live one. Any of these holds the publish for human approval:
a new external destination that carries a credential;
a wildcard host or path;
a new write method (POST, PUT, PATCH, DELETE) for an existing pair;
any new peer on a
tcpport.
This is a simple diff, not formal proof; OpenShell's prover is the model if it needs to go further.
Identity and credentials
Keys and secrets live only in the supervisor. A compromised workload has no key to steal and no socket except its own to ask through.
SVIDs. Today novalet waits for each pod's SVID (awaitSVID) so the per-pod front can present it. In the new model the supervisor fetches an SVID per pod through SPIRE's Delegated Identity API, using the selectors novalet registers for that pod. The same SVID is:
the server certificate on that workload's
mtlsandtlsports, so callers can pin it withverify-uri;the client certificate when that workload calls another Nova workload.
The workload pod receives no SVID and no key material.
Credentials. Secrets stop travelling as plaintext env values in container_specs.spec over PodAssignments. A spec names a credential (stripe-live); the supervisor fetches it from the secret store, scoped to that workload, and injects it only on egress rules that carry credential:. The app holds a placeholder.
Injection needs the supervisor to see the HTTP request. Two ways, per destination:
Plaintext to the supervisor (default). The app calls
http://api.stripe.comthrough nova-relay; the supervisor originates TLS. No CA in the pod.Interception. For apps that insist on
https://, the supervisor terminates TLS with a per-pod CA trusted inside the pod. More compatible, but the supervisor then holds a CA key per pod.
Isolation between pods sharing a supervisor. Nodes are single tenant, so one supervisor per node is within the tenant boundary. Pods still get least privilege:
Identity: taken from the vsock peer CID, which the host sets, never from anything the pod sends.
Credentials: loaded only for workloads placed on this node; injected only for the owning pod's connections.
Policy: the bundle is keyed by workload, so pods of one tenant do not inherit each other's rules.
Fairness: per-endpoint caps on connections, bandwidth and concurrent TLS handshakes.
Dedicated supervisors. A workload with isolation: dedicated in its spec gets its own supervisor instance. Use it for untrusted agent code, where hostile traffic from inside the pod targets the supervisor's parsers, and a shared supervisor would expose other pods' credentials.
Syscall control and per-app approval
Every app runs under its own approved syscall profile. seccomp-BPF allows common syscalls in the kernel, sends a small sensitive set to a per-node syscall supervisor through seccomp user notification, and denies the rest. A profile is bound to a workload and its image digest, and execution-controller will not place an app without an approved one.

Three tiers per profile
| Tier | Decided by | Examples | Change without restart |
|---|---|---|---|
| Allow | seccomp-BPF in the kernel | read, write, futex, epoll_*, socket for AF_UNIX / AF_INET / AF_INET6 | No: a filter cannot be loosened once installed |
| Notify | Syscall supervisor, through user notification | execve / execveat checked against the app's binary list and hashes; rules expected to change | Yes |
| Deny | seccomp-BPF returns EPERM and logs | mount, bpf, ptrace, keyctl, kexec_load, init_module, io_uring_setup, userfaultfd, perf_event_open; clone with namespace flags; clone3 returns ENOSYS so libc falls back to clone | No |
What notify can and cannot prove. A notify reply of "continue" lets the kernel re-read pointer arguments, so a decision based on a path or buffer can race with another thread. Hard limits therefore stay where no race exists: register arguments in BPF, paths in Landlock (including execute rights), and the network fence. Notify adds per-app exec checks with hashes, audit, and rules that can change without a restart.
How the notify channel is wired. In a Kata guest, kata-agent installs the app's seccomp filter in the guest kernel. A small guest shim holds the notify listener and forwards each notification over vsock to the host syscall supervisor, which decides from the app's signed profile and returns allow or EPERM. The shim reads arguments from the guest's /proc/<pid>/mem and checks SECCOMP_IOCTL_NOTIF_ID_VALID before replying. If kata-agent does not accept an OCI listenerPath, the shim installs the notify filter itself, as the app's parent, before exec.
Where it runs. Decisions run on the host, outside the VM, so a compromised guest cannot change the rules; only the forwarding shim runs in the guest. If the shim or the channel fails, notified syscalls fail with ENOSYS, which is fail-closed. A guest-kernel exploit defeats in-guest seccomp, but the VM boundary still holds.
Approval workflow
Record: run the app in staging with every non-baseline syscall sent to notify, allowed and logged (or use the Kubernetes Security Profiles Operator recorder).
Propose: the recorder emits a profile: the allow set, notify rules (binaries and hashes), deny by default.
Review: policy-controller diffs it against the baseline and the last approved profile. Syscalls on the risk list need a security approver; everything else needs the app owner.
Sign: the approved profile is stored in policyDB, keyed by workload and image digest, and signed by policy-controller.
Enforce: execution-controller refuses placement without an approved profile for that digest; novalet verifies the signature before building the OCI spec.
Learn from denials: each denial is logged with the syscall and arguments and can become a proposed change that goes back to step 3. Production never holds a syscall waiting for a human; dev environments may, with a timeout.
Changes this adds
| Component | Change |
|---|---|
| policyDB | Syscall profiles keyed by workload and image digest, with approval state, approver and risk flags |
| policy-controller | Profile diff and risk flags, approval API, signing |
| execution-controller | Placement requires an approved profile for the image digest; ToProto carries the profile reference |
| novalet | Fetches and verifies the profile; writes linux.seccomp with listenerPath into the OCI spec; runs the syscall supervisor and holds listener fds |
| Syscall supervisor (new) | Per node; answers notifications from each app's profile; audit events and metrics |
Request flows
Each call between Nova workloads is decided twice, by two supervisors that do not trust each other. Y → B follows the same path with its own vsock CID, IPs and SVIDs, so the two flows never share a decision.

On node 2, the local IP the connection arrived on selects the backend, and the peer SVID selects the rule.
Variants
X tries B, with no rule: node 1's egress check returns 403. If node 1's supervisor were compromised and dialed anyway, node 2's ingress check would reject peer X.
A on the same node as X: A's IP is local in the supervisor netns, so the dial loops back to the supervisor's own listener. Both checks run; OVS is not involved.
Caller outside Nova: it connects straight to
10.64.3.70:443. The port runs intlsortcpmode, and the ingress check uses the source CIDR or a JWT instead of an SVID.
How an app reaches its egress socket
Proxy mode:
HTTP_PROXY/HTTPS_PROXYpoint at nova-relay. Any allowed destination works; the supervisor resolves names through VPC local DNS.Fixed-dependency mode, for apps that ignore proxy settings: novalet writes
/etc/hostsentries from the pod's egress rules (127.0.1.1 service-a,127.0.1.2 service-b), and nova-relay listens on each address and forwards over vsock to the supervisor. Works for any TCP protocol; dependencies can share a port number.
In both modes the app speaks plaintext to nova-relay and the supervisor adds mTLS.
Lifecycle
novalet is the only writer of the supervisor's endpoint table; the supervisor never learns anything from a pod. A pod counts as running only after the supervisor has proven it can reach it and holds its SVID.
Placement to READY
execution-controller sends the
PodAssignmentwith the workload IP, ports, port modes, policy reference and syscall profile reference.novalet assigns the VM a vsock CID and creates the VMM's netns with only
lo.novalet upserts the endpoint as
PENDINGwith the CID and ports. The supervisor requests the delegated SVID, opens listeners on the workload IP, and starts accepting egress from that CID.novalet starts the Kata VM with no virtio-net and the nova-runtime guest image. The workload IP goes on the guest's
lo; nova-relay listens on the guest's vsock ingress ports and forwards to the app's127.0.0.1port.The supervisor health-checks the app over vsock.
novalet adds the supervisor-netns local route and the two OVS flows, then marks the endpoint
READY.novalet reports the pod running; execution-controller moves the placement to RUNNING.
Drain and delete
novalet marks the endpoint
DRAININGand removes the OVS ingress flow, so no new connections arrive.The supervisor stops accepting, lets open connections finish up to a drain timeout, then closes them.
novalet removes the local route, the anti-spoofing flow, the endpoint, and stops the VM.
The IP returns to the pool only after a cooldown, so retries meant for the old workload cannot land on the next one.
Generations. Each endpoint carries a generation that bumps on every pod restart. The supervisor rejects upserts with an older generation and closes connections tied to one.
Endpoint stream. Same pattern as the node/v1 Sync stream: a full snapshot on connect, then deltas. A restarted supervisor rebuilds everything from novalet and the on-disk bundle; it keeps no state of its own.
service SupervisorControl {
rpc SyncEndpoints(stream SupervisorAck) returns (stream EndpointUpdate);
}
message EndpointUpdate {
oneof op { Snapshot snapshot = 1; Endpoint upsert = 2; EndpointKey remove = 3; }
}
message Endpoint {
string workload_id = 1; // policyDB subject
string spiffe_id = 2;
string pod_uid = 3;
uint64 generation = 4;
string workload_ip = 5; // from the node's delegated subnet
uint32 vsock_cid = 6; // guest CID, set by novalet
repeated Port ports = 7;
string isolation = 8; // shared | dedicated
State state = 9; // PENDING | READY | DRAINING
}
message Port {
string name = 1; uint32 port = 2; uint32 target_port = 3;
enum Mode { MTLS = 0; TLS = 1; TCP = 2; } Mode mode = 4;
enum Proto { RAW = 0; HTTP = 1; GRPC = 2; } Proto protocol = 5;
}
Supervisor availability. Two instances bind the same addresses with SO_REUSEPORT; the kernel spreads new connections between them. A crash drops only the failed instance's connections, and upgrades start the new version, drain the old one, then stop it. Each instance runs non-root with only CAP_NET_BIND_SERVICE, under Landlock and seccomp.
execution-controller changes
execution-controller keeps owning placement; it learns to describe a supervised workload and refuses to send one to a node that cannot enforce it. File and function names come from an earlier read of nvscale and should be checked against the current tree.
| Where | Change | Why |
|---|---|---|
internal/placement · ContainerSpec | Accept runtime_class: nova-runtime (Kata underneath); add ports[] (name, port, target_port, mode, protocol), isolation, credentials[] (names only) and a syscall profile reference to the jsonb spec | Describes what the supervisor exposes and how; existing specs parse as before |
internal/placement · ParseSpec | Validate ports (unique, target_port set, valid mode); refuse secret values in env; refuse identity_front with nova-runtime | Kata is the isolation layer of record; the front is superseded |
internal/placement · ToProto | Emit a NovaRuntime block on the PodAssignment (ports, isolation, credential names, profile reference) as new core.proto fields | Ports and modes are structured; a label like IdentityFrontLabel is fine only for a prototype |
internal/server · Sync | Add CapNovaRuntime = "nova.io/cap.nova-runtime". sendAssignments fails closed: a nova-runtime placement for a node without the cap goes FAILED | An older novalet would start the pod with a normal uplink and no policy |
| Placement filter | Only consider nodes advertising CapNovaRuntime; count each pod's VM memory overhead when fitting | Every Kata pod carries a guest kernel and VMM |
| Status mapping | Endpoint PENDING → CREATING, READY → RUNNING, DRAINING → TERMINATING; failure reasons: VM boot failed, SVID timeout, health check failed, supervisor unavailable | A pod that is up but unreachable must not count as running |
| Service discovery | No change: supervisors resolve destinations through VPC local DNS, so workload names must keep resolving to workload IPs | The pod has no DNS of its own |
pgstore | No schema change: the new fields live in the container_specs jsonb | Keeps the migration small |
Syscall profiles add one more check here: placement requires an approved profile for the image digest (see Syscall control and per-app approval).
novalet changes
novalet does most of the new work: it runs the node supervisor, keeps its endpoint table, and builds supervised pods without an uplink. Most of it follows patterns novalet already has for the identity front.
| Where | Change | Reuses |
|---|---|---|
source/podcontroller | Advertise CapNovaRuntime in the NodeRegister labels only when Kata, the supervisor and the syscall supervisor are installed and healthy | The two existing caps |
New internal/supervisor | Runs the node supervisor (two instances, plus one per isolation: dedicated pod) and the syscall supervisor; serves SupervisorControl.SyncEndpoints on a Unix socket; adopts running instances after a novalet restart | internal/front: units that survive novalet restarts, stopOrphanFronts / Adopt |
| Workload IPAM | Return a released IP to the delegated-subnet pool only after drain completes and a cooldown passes | novalet's existing allocator |
manager.startPodInner | For nova-runtime pods: assign a vsock CID; create the VMM's netns with only lo (skip the novacni Add); upsert PENDING; start the Kata sandbox; wait for the supervisor's health check; then program flows and mark READY | The awaitSVID gate, now waiting on the supervisor |
manager.teardownPod | Drain sequence: DRAINING, remove the ingress flow, wait, remove route, anti-spoof flow and endpoint, stop the VM, then release the IP | Existing teardown ordering |
runtime.RunSandbox | Kata config per pod: pod networking off (no virtio-net), vsock on, the nova-runtime guest image (nova-relay, syscall shim), a guest kernel with Landlock; pass the signed seccomp profile to kata-agent; set HTTP_PROXY, HTTPS_PROXY, NOVA_WORKLOAD_IP; write /etc/hosts entries for fixed dependencies | novalet's existing microVM launch path |
network | Per-/32 ingress and anti-spoof flows on the supervisor port; local routes in the supervisor netns; no pod port | Existing OVS flow programming |
| Supervisor pod network | Its netns gets one port on the workload bridge (virtio-user or AF_XDP on OVS-DPDK), a default route via the bridge gateway, an ARP responder, and VPC local DNS as its resolver | Today's pod port setup, once per node |
| SPIRE registration | Keep registering per-pod entries; authorize the supervisor as a Delegated Identity API caller | Existing SVID issuance |
| Front retirement | Stop creating per-pod nova-front units for nova-runtime pods; keep the code path for other pods during migration | internal/front stays until the last workload moves |
The per-pod front's job (mTLS ingress plus the OPA check at the handshake) moves into the supervisor unchanged, so nothing is lost while the egress side is built.
Syscall profiles add the guest seccomp wiring through kata-agent, the guest shim, and the per-node syscall supervisor (see Syscall control and per-app approval).
Other changes
Seven more components change; the Ghostunnel fork is the largest.
| Component | Change |
|---|---|
| policyDB | Rule schema gains from / to with workload, label selector, external host, cidr, external_ca; optional http block; credential; audit flag. Syscall profiles keyed by workload and image digest |
| policy-controller | New compiler stage emits ingress and egress entries per rule; bundle keyed by workload; authz.rego rewritten for both directions and L7; change gate before publish; profile review and signing; opa-system-authz.rego still limits the OPA API to the decision queries |
Ghostunnel fork (nova.patch) | Many endpoints in one process from the SyncEndpoints stream; listeners per workload IP and port; HTTP mode with a per-request OPA call (httputil.ReverseProxy); connect mode (HTTP CONNECT and SOCKS5) on each pod's vsock port; vsock dialer for ingress; egress dialer with the caller's source IP and SVID; SVIDs from the Delegated Identity API; caller identity to the app via PROXY v2 TLVs or headers; per-endpoint limits; one audit line per decision |
| novacni / vpg-controller | nova-runtime mode: no pod port, since the VM has no NIC; security groups move from per-pod-port matches to matches on the workload IP at the supervisor port, so OVS stays the L3/L4 backstop |
| Kata | Pod networking off (no virtio-net); vsock for nova-relay and the syscall shim; guest kernel with Landlock and seccomp; VMM seccomp-filtered in an lo-only netns |
| SPIRE | Enable the Delegated Identity API and authorize the supervisor's identity |
| Secret store | Credentials scoped per workload and readable only by the node supervisor |
The Envoy + ADS design in ewcarch.html stays the alternative if retries, header routing or rate limiting become requirements; the Ghostunnel fork is chosen for footprint and because Nova already runs it.
Security analysis
The workload is treated as hostile, and so is every other node's supervisor. The largest remaining risk is a compromise of the shared supervisor itself, which exposes the credentials of every pod on that node (one tenant, one node).
| Threat | Mitigation | Left over |
|---|---|---|
| Workload connects out directly | No virtio-net; only vsock to the supervisor | None while the VM has no NIC |
| Workload impersonates another pod | Identity is the vsock peer CID, which the host sets | None |
| Workload spoofs a source IP | No NIC; OVS anti-spoofing on the supervisor port | None |
| Guest-kernel exploit | Contained by the VM; the VMM is seccomp-filtered in an lo-only netns | A VMM or KVM bug |
| Unapproved syscalls | Per-app signed profile, deny by default | Notify decisions on pointer arguments are advisory |
| Workload attacks the supervisor's TLS or HTTP parsing | Supervisor is non-root with only CAP_NET_BIND_SERVICE, under Landlock and seccomp; isolation: dedicated for untrusted code | A shared-supervisor compromise exposes that node's credentials |
| Caller's node supervisor is compromised | Callee's supervisor verifies the peer SVID and runs its own ingress check | Calls the callee already allows that caller |
| SVID or credential theft from a pod | Keys and secrets never enter pods; SVIDs are short-lived | None |
| Exfiltration over DNS | Pods have no DNS; the supervisor resolves only for allowed destinations | Data in allowed requests |
| Tampered policy | Bundles and profiles are signed and verified; changes go through the publish gate | A bad rule approved by a human |
| OPA unavailable | Fail closed; the agent serves the last good bundle from disk | L7 ports deny new requests until OPA recovers |
| Stale IP reuse | Release cooldown and generation checks | None |
| Cloud metadata (IMDS) access | Still blocked in OVS; policy never allows link-local destinations | None |
| Secrets in specs | Specs carry credential names; values stay in the secret store | None |
Isolation layer: Kata (plan of record)
Kata is the isolation layer of record. Each pod gets its own guest kernel behind the hypervisor, so a kernel exploit in the app stays inside its VM, and nova-runtime adds the network, identity, credential and syscall controls on top. runsc is retired after the evidence below; runc is not used for nova-runtime workloads.
| Threat | runc + nova-runtime | runsc today | Kata today | runsc + nova-runtime | Kata + nova-runtime (plan of record) |
|---|---|---|---|---|---|
| Kernel exploit, escape to host | Partial: syscalls reach the host kernel | Strong: user-space kernel, few host syscalls | Strongest: own guest kernel behind the hypervisor | Strong | Strongest |
| Data leaves over the network | Strong: no NIC, policy only | Weak: internet egress open apart from IMDS and RFC1918 | Weak | Strong | Strong |
| Lateral movement | Strong: two checks per call, L7 | Partial: front + OPA at the handshake (built, not deployed) | Partial | Strong | Strong |
| Credential theft | Strong: never in the pod | Weak: env values in plaintext specs | Weak | Strong | Strong |
| Per-app syscall approval | Strong | Partial | Weak | Partial | Strong inside the guest, VM boundary behind it |
| New code to trust | Supervisors | None added | None added | Supervisors | Supervisors and the syscall shim |
| Cost | Native | Syscall overhead | VM memory and boot time per pod | Syscall overhead | VM memory and boot time per pod |
Kata + nova-runtime is the only column with no Weak or Partial rating on a security threat; its price is the VM cost per pod and the new supervisor code.
Hardening around the VM boundary
No virtio-net device; vsock is the only channel to the host besides block and console.
The VMM (QEMU or Cloud Hypervisor) is seccomp-filtered, runs without root where the hypervisor supports it, and sits in a netns with only
lo.A minimal guest kernel: Landlock and seccomp on, unneeded drivers and modules off.
Inside the guest: per-app approved seccomp profiles, Landlock, a non-root app, and a read-only root filesystem.
Evidence to collect
VM boundary: run public guest-to-host escape proofs of concept for the chosen hypervisor, and confirm the VMM's seccomp filter blocks host syscalls it does not need.
Kernel exploit containment: run public container-escape proofs of concept (Dirty Pipe, CVE-2022-0847, as one example) inside the guest. They may take over the guest but must not reach the host or other pods.
Exfiltration from a hostile pod: raw sockets, DNS, ICMP, IPv6, link-local and IMDS, and smuggling through allowed hosts.
Lateral movement: try every other workload without a rule; both checks must deny.
Secret exposure: search pod env, files and guest memory for credentials and keys.
Supervisor and shim attack: fuzzing and a red-team pass on the TLS, HTTP, CONNECT and vsock paths.
Cost: boot time and memory per pod, and throughput over vsock compared with today's pod ports, for today's workloads.
Retiring runsc. runsc is retired when Kata + nova-runtime contains every exploit in test 2, passes tests 3 to 5 with no findings, has no critical finding in tests 1 and 6, and its cost in test 7 is accepted. Until then, existing runsc workloads stay on runsc.
Compatibility: storage and operations
From the app's side, almost everything keeps working at the same paths. Only two things break outright: UDP, which is denied in v1, and apps that need raw sockets or network privileges in the pod (multicast, ICMP beyond ping, `NET_ADMIN`). In-pod network mounts, iptables-rewriting mesh sidecars and ping keep working once moved: the mount to the host, the sidecar's job and ping to the supervisor. GPUs need guest-image work. This describes Kata's general behavior; novalet's volume code still has to be checked against it.
| Area | Thing | On Kata + nova-runtime | Status |
|---|---|---|---|
| Storage | ConfigMaps, Secrets, emptyDir | Shared into the guest over virtio-fs at the same paths; check live ConfigMap updates on our Kata version | As-is |
| Storage | Block volumes, local disks | Attached to the VM as virtio-blk and mounted in the guest at the same path | As-is |
| Storage | Network storage mounted on the host (CSI or novalet) | Shared into the guest over virtio-fs; storage traffic leaves from the host, outside the supervisor | As-is |
| Storage | Network storage mounted inside the pod today (FUSE such as JuiceFS, NFS client) | Works once the mount moves to the host (CSI or novalet): the app keeps the same path, and the pod no longer needs /dev/fuse or CAP_SYS_ADMIN | Changes |
| Storage | hostPath | Shared over virtio-fs; refused for untrusted apps | Restrict |
| Operations | Logs | kata-agent streams stdout and stderr to the runtime shim; same log files as today | As-is |
| Operations | Exec, attach, exec probes | kata-agent runs them inside the guest | As-is |
| Operations | Sidecars in the same pod | Same VM, shared loopback | As-is |
| Operations | CPU and memory metrics | VM cgroup on the host plus per-container stats from kata-agent; VM overhead becomes visible | As-is |
| Operations | HTTP and TCP probes | The pod IP belongs to the supervisor, so novalet probes through it | Changes |
| Operations | Port-forward and network debugging | No NIC in the VM; debug through the supervisor or vsock | Changes |
| Operations | App-side DNS lookups | No resolver in the pod; proxy mode resolves in the supervisor, raw-TCP apps use the generated /etc/hosts entries | Changes |
| Networking | Service-mesh sidecars that rewrite iptables | Not needed: the supervisor already does mTLS, identity and L7 policy, so the sidecar is dropped | Changes |
| Networking | ICMP echo (ping) | Sent by the supervisor on the pod's behalf, with an ICMP egress rule; see ICMP through the supervisor | Changes |
| Devices | GPUs (Dynamo, vLLM) | VFIO passthrough into the VM and an NVIDIA driver in the guest image | Needs work |
| Protocols | UDP | Denied in v1 | Breaks |
| Networking | Raw sockets, multicast, other ICMP types, NET_ADMIN | No NIC and no network capabilities in the pod | Breaks |
How a host-side mount reaches the container

Same view for the app: the same path and plain file I/O, with no FUSE device or privileges in the pod.
I/O path: each read and write goes from the guest kernel over virtio-fs to the pod's
virtiofsdon the host, which uses the host mount. Only the host talks to the storage service.Isolation: each pod has its own sandboxed
virtiofsdthat exports only that pod's directory, so a compromised guest cannot reach other pods' volumes or the rest of the host.Credentials: storage credentials (for example the JuiceFS metadata URL and access keys) stay on the host side.
Block volumes skip the file layer: they are attached as disks and mounted directly in the guest.
What to test with our storage
Speed: small, metadata-heavy I/O over virtio-fs is slower than a native mount; Kata's caching and DAX options close much of the gap.
Change notifications: inotify in the guest does not see writes made by other pods or nodes on shared storage; data is still correct when read.
Locks and ownership: file locks behave as apps expect, and UIDs and GIDs map correctly with user namespaces.
Policy gap. Host-side storage traffic never passes the supervisor, so OVS security groups are its only control unless policyDB gains storage rules.
ICMP through the supervisor
Ping works without giving the pod a NIC: the supervisor sends echo requests on the pod's behalf, the same way it handles TCP egress.

The trusted init sets this up in the guest before the app starts; the app itself stays unprivileged.
sysctl -w net.ipv4.ping_group_range="0 2147483647" # ping without root
ip tuntap add tun0 mode tun user nova-relay && ip link set tun0 up
ip rule add ipproto icmp lookup 100 && ip route add default dev tun0 table 100
Only ICMP gets a route, to
tun0; TCP and UDP to remote addresses still have none.nova-relay reads echo requests from
tun0and sends only the destination, id and sequence over vsock.The supervisor checks an ICMP egress rule and rate limit, pings from the workload IP with its own fixed payload, and writes the reply back with the app's original bytes, so the app sees a normal reply.
The fence holds even if the app gets root in the guest: the relay and the supervisor carry only ICMP echo. Payload exfiltration is closed; a slow timing channel remains, covered by the rate limit and the audit log.
Inbound pings to the workload IP are answered by the supervisor's kernel while the endpoint is READY. A reply shows the endpoint is registered, not that the app is healthy.
Not supported: traceroute, ICMP errors delivered to the app, and other ICMP types. Path MTU is handled by the supervisor, because the app's TCP connections end there.
Needs: a guest kernel with
ipprotoin routing rules, which Kata's recent guest kernels have.
The rule is a new protocol: icmp shape in policyDB, compiled to egress only:
- id: x-ping-gateways
from: { workload: x }
to: { cidr: 10.64.0.0/16, protocol: icmp }
icmp: { types: [echo], rate_per_s: 5 }
Known limitations
Three limitations matter most. UDP is the largest functional gap, because it rules out QUIC / HTTP/3 and app-side DNS over UDP. The shared supervisor is the largest security residual: one compromise exposes every pod's credentials on that node. The unverified assumptions are the largest schedule risk, because any of them can change the design.
| Kind | Shortcoming | What it hits | Mitigation or next step |
|---|---|---|---|
| Still breaks | UDP denied in v1 | QUIC / HTTP/3, app-side DNS over UDP, statsd and syslog over UDP, media | A per-flow UDP relay in the supervisor, like the ICMP path |
| Still breaks | Raw sockets, multicast, traceroute, NET_ADMIN | tcpdump in the pod, discovery protocols, network tools | Capture and tracing from the supervisor side instead |
| Still breaks | Undeclared inbound ports | Apps that open listeners at runtime | Every port declared in the spec; dynamic ports stay unsupported |
| Still breaks | Apps doing their own TLS to external hosts | No L7 rules or credential injection on those calls, only host and SNI | Plaintext to nova-relay, or per-pod CA interception |
| Cost | A VM per pod | Memory and boot time per pod; lower density | Measure in phase 0; size the placement filter for VM overhead |
| Cost | Extra hops | vsock plus TLS termination on both nodes add latency and supervisor CPU | Budget from phase 0 numbers; kTLS on the plaintext leg |
| Cost | virtio-fs storage | Slower small-file and metadata-heavy I/O | Kata caching and DAX; block volumes for heavy I/O |
| Cost | GPUs | VFIO passthrough and an NVIDIA driver in the guest image | Dedicated GPU node pools and a GPU guest image |
| Security residual | Shared supervisor per node | One compromise exposes every pod's credentials on that node | Sandboxing and fuzzing; isolation: dedicated for untrusted code |
| Security residual | Host-side storage traffic | Bypasses supervisor policy; only OVS security groups apply | Storage rules in policyDB (open question) |
| Security residual | Data inside allowed calls | Exfiltration to allowed hosts; slow timing channels | Narrow L7 rules, rate limits, audit logs |
| Security residual | Notify decisions on pointer arguments | Path or buffer checks can race | Hard limits stay in BPF, Landlock and the network fence |
| Operational | New code to own | Ghostunnel fork, nova-relay, syscall shim, endpoint table | Upstream the generic parts; fuzz the parsers |
| Operational | Harder debugging | No port-forward or tcpdump inside the pod | Supervisor-side debug and replay tooling |
| Operational | Migration and approval friction | Every workload needs declared ports, credential names and a profile approved per image digest | Record mode in staging; auto-approve profile changes that add no risk-list syscalls |
| Operational | Unverified assumptions | kata-agent notify hook, networking-off launch path, JuiceFS host mounts, current nvscale code | Spike these before phase 0 |
Operations
Every decision is logged with who, what and why, so a denied call can be explained from one log line. Performance targets are set from phase 0 measurements, not guessed here.
Observability
Audit log: one line per decision with direction, workload, peer SPIFFE ID or source CIDR, destination, method and path, rule id, verdict, bytes and duration.
Metrics: Prometheus per endpoint and direction: connections, requests, denials by rule, handshake failures, OPA latency, TLS handshakes in flight.
OVS counters: per-/32 packet and byte counts from the supervisor-port flows, for cross-checking.
Debug: a supervisor command that prints the endpoint table and replays a decision for a given input ("why was X denied to A?").
Changes in production
Policy: the bundle hot-reloads. HTTP and gRPC ports apply it on the next request; TCP ports on the next connection, with
--max-conn-lifetimebounding old ones.Supervisor upgrade: start the new instance on the same addresses with
SO_REUSEPORT, stop the old one accepting, drain, exit.Certificates: SVIDs rotate in place, as Ghostunnel reloads certificates today.
Performance to measure in phase 0
Added latency per call: one Unix-socket hop per side plus TLS termination on both nodes.
Supervisor CPU per 1,000 new TLS connections per second, which sizes the instance.
OPA round trip per request on L7 ports.
Throughput through the supervisor port on OVS-DPDK, compared with today's pod ports.
Rollout
Phase 0 moves today's ingress protection into the supervisor without changing what it allows, so the riskiest part (IP ownership and OVS flows) ships before any new policy does.

Every phase sits behind CapNovaRuntime and runtime_class: nova-runtime, so workloads move one at a time and roll back by returning to their previous runtime class. runsc is retired in phase 3, once Kata + nova-runtime passes the evidence above.
Open questions
Which hypervisor: QEMU with vhost-vsock (identity = peer CID) or Cloud Hypervisor with hybrid vsock (identity = host socket path)?
Does kata-agent accept an OCI seccomp
listenerPath, or does the guest shim install the notify filter itself?Can our novalet launch path run Kata with pod networking fully off (no virtio-net)?
Are boot time and memory per pod acceptable for our densest workloads?
Who mounts JuiceFS on the host for nova-runtime pods, novalet or a CSI plugin, and with whose credentials?
Should host-side storage traffic get its own policyDB rules, or stay under OVS security groups only?
GPU pods: VFIO passthrough per pod or per node pool, and which guest image carries the NVIDIA driver?
Which workloads use in-pod FUSE or NFS mounts, UDP, or
NET_ADMINtoday, and need to change before they move?Who signs off on the evidence, and which hypervisor and kernel versions does the escape replay run against?
Which syscalls join
execve/execveatin the notify tier for v1?Who approves risk-list syscalls: a named security group, or the platform team?
For external HTTPS destinations with credentials: plaintext-to-supervisor by default, or per-pod CA interception? Who owns the CA keys?
Which certificate do
tls-mode ports present to VPC callers: the workload SVID, or a certificate from an internal CA those callers already trust?Does any workload need UDP in v1?
Do we offer the connect and HTTP modes to the open-source Ghostunnel project, or keep them only in
nova.patch?
Settled: Kata is the isolation layer of record; novalet owns workload IPAM, so the reuse cooldown lives there; supervisors resolve destinations through VPC local DNS.