Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,12 @@ See [docs/execution-model.md](docs/execution-model.md). We keep this model small
* `rlimit` and cgroup resource caps.
Each kernel layer is best-effort and recorded in the sandbox's confinement
report; hardened rollout mode fails closed when a required layer is missing.
* **`backend="microvm"`** - reserved; not yet implemented (fails closed).
* **`backend="microvm"`** - the reserved hardware-VM boundary. The supervisor
probes the host for a supported VMM (Firecracker, Cloud Hypervisor, QEMU) and
an accessible `/dev/kvm`, and **fails closed** with a diagnostic naming what is
missing; when the host is capable it still refuses, because the guest launcher
and vsock transport are not yet implemented. It never degrades to a weaker
boundary.
* **Broker** - sole path to privileged syscalls, sealed with AEAD (X25519 to
ChaCha20-Poly1305) and strict per-direction replay counters.
* **Fallback hardening** - even the process backend is defense-in-depth, not a
Expand Down
7 changes: 6 additions & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ rollout mode a required-but-unavailable layer fails closed. Even at full
strength this is **defense in depth, not a hardware VM boundary** — for
high-assurance multitenancy, run one sandbox per process inside a VM or microVM.

- **`backend="microvm"`** is reserved and not yet implemented; it fails closed.
- **`backend="microvm"`** is the reserved hardware-VM boundary. The supervisor
probes for a supported VMM (Firecracker, Cloud Hypervisor, QEMU) and an
accessible `/dev/kvm` and **fails closed** with a diagnostic when they are
missing; even on a capable host it refuses, because the guest launcher and
vsock transport are not yet implemented. It never downgrades to a weaker
boundary.

---

Expand Down
212 changes: 212 additions & 0 deletions pyisolate/runtime/microvm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""microVM backend scaffolding for ``backend="microvm"``.

This module is the supervisor-side groundwork for the microVM isolation mode:
the strongest boundary PyIsolate targets, where the guest runs behind a
hardware-virtualization boundary (KVM) instead of only kernel policy on a shared
kernel. It provides three things that do **not** require a running hypervisor to
be useful or testable:

* **capability detection** — is a supported VMM (Firecracker, Cloud Hypervisor,
QEMU) on ``PATH`` and is ``/dev/kvm`` usable (:func:`detect_microvm_support`);
* **machine-config generation** — turn a sandbox's limits into the JSON a VMM
consumes to boot a guest (:class:`MicroVMConfig`);
* **fail-closed admission** — a precise, actionable error when the host cannot
provide the boundary (:func:`require_microvm_support`).

What it deliberately does **not** yet do is boot a guest or carry the cell
protocol over vsock; that launcher is the next increment. Until then the
supervisor routes ``backend="microvm"`` here and fails closed rather than
silently downgrading to a weaker boundary — consistent with the threat model,
which treats microVM as reserved and fail-closed.
"""

from __future__ import annotations

import math
import os
import shutil
from dataclasses import dataclass, field
from typing import Any, Optional

from ..errors import SandboxError

# VMMs PyIsolate knows how to target, in preference order. The value is the
# executable name looked up on PATH.
_KNOWN_VMMS: tuple[tuple[str, str], ...] = (
("firecracker", "firecracker"),
("cloud-hypervisor", "cloud-hypervisor"),
("qemu", "qemu-system-x86_64"),
)

_KVM_DEVICE = "/dev/kvm"

# Default guest kernel command line for a Firecracker-style minimal boot.
_DEFAULT_BOOT_ARGS = "console=ttyS0 reboot=k panic=1 pci=off"


class MicroVMUnavailable(SandboxError):
"""Raised when a microVM boundary cannot be established on this host."""


@dataclass(frozen=True)
class MicroVMSupport:
"""Result of probing the host for microVM prerequisites."""

vmm_kind: Optional[str]
vmm_path: Optional[str]
kvm: bool
reasons: tuple[str, ...] = ()

@property
def ready(self) -> bool:
"""Whether a hardware-VM boundary can be launched here."""
return self.vmm_path is not None and self.kvm

def as_dict(self) -> dict[str, Any]:
return {
"ready": self.ready,
"vmm_kind": self.vmm_kind,
"vmm_path": self.vmm_path,
"kvm": self.kvm,
"reasons": list(self.reasons),
}


def detect_vmm() -> tuple[Optional[str], Optional[str]]:
"""Return the first supported VMM found on ``PATH`` as ``(kind, path)``."""
for kind, executable in _KNOWN_VMMS:
path = shutil.which(executable)
if path is not None:
return kind, path
return None, None


def kvm_available() -> bool:
"""Return whether ``/dev/kvm`` exists and is readable+writable to us.

KVM requires read/write access to the device node; a present-but-inaccessible
``/dev/kvm`` (missing group membership, container without the device) cannot
launch a VM, so it is reported as unavailable.
"""
return os.path.exists(_KVM_DEVICE) and os.access(_KVM_DEVICE, os.R_OK | os.W_OK)


def detect_microvm_support() -> MicroVMSupport:
"""Probe the host for a usable VMM and KVM, collecting the blocking reasons."""
vmm_kind, vmm_path = detect_vmm()
kvm = kvm_available()
reasons: list[str] = []
if vmm_path is None:
names = ", ".join(executable for _, executable in _KNOWN_VMMS)
reasons.append(f"no supported VMM on PATH (looked for: {names})")
if not kvm:
if not os.path.exists(_KVM_DEVICE):
reasons.append(f"{_KVM_DEVICE} is not present (no hardware virtualization)")
else:
reasons.append(f"{_KVM_DEVICE} exists but is not readable+writable")
return MicroVMSupport(
vmm_kind=vmm_kind,
vmm_path=vmm_path,
kvm=kvm,
reasons=tuple(reasons),
)


def require_microvm_support(
support: Optional[MicroVMSupport] = None,
) -> MicroVMSupport:
"""Return *support* if a microVM boundary is possible, else fail closed.

The microVM backend has no weaker mode to degrade to: either a hardware-VM
boundary can be launched or the request is refused. The raised error names
every missing prerequisite so an operator can fix the host.
"""
support = support or detect_microvm_support()
if not support.ready:
detail = "; ".join(support.reasons) or "prerequisites unavailable"
raise MicroVMUnavailable(
"backend='microvm' requires a hardware-VM boundary that this host "
f"cannot provide: {detail}. Run on a host with a supported VMM and "
"an accessible /dev/kvm, or choose backend='process' for kernel-level "
"confinement without a VM."
)
return support


@dataclass
class MicroVMConfig:
"""Inputs for a guest microVM, renderable to a VMM machine configuration.

The fields map onto a Firecracker-style boot: a guest kernel, a root
filesystem image, CPU/memory sizing, and a vsock device used to carry the
cell protocol between supervisor and guest.
"""

kernel_image: str
rootfs_image: str
vsock_uds_path: str
vcpus: int = 1
mem_size_mib: int = 128
guest_cid: int = 3
boot_args: str = _DEFAULT_BOOT_ARGS
rootfs_read_only: bool = False
extra_drives: list[dict[str, Any]] = field(default_factory=list)

def __post_init__(self) -> None:
if self.vcpus < 1:
raise ValueError("vcpus must be >= 1")
if self.mem_size_mib < 1:
raise ValueError("mem_size_mib must be >= 1")
# Firecracker reserves CIDs 0-2 (hypervisor/local/host); guests start at 3.
if self.guest_cid < 3:
raise ValueError("guest_cid must be >= 3 (0-2 are reserved)")

@classmethod
def from_limits(
cls,
*,
kernel_image: str,
rootfs_image: str,
vsock_uds_path: str,
mem_bytes: Optional[int] = None,
vcpus: int = 1,
guest_cid: int = 3,
) -> "MicroVMConfig":
"""Build a config from a sandbox's byte-denominated memory limit."""
mem_mib = 128 if mem_bytes is None else max(1, math.ceil(mem_bytes / (1 << 20)))
return cls(
kernel_image=kernel_image,
rootfs_image=rootfs_image,
vsock_uds_path=vsock_uds_path,
vcpus=vcpus,
mem_size_mib=mem_mib,
guest_cid=guest_cid,
)

def to_firecracker_json(self) -> dict[str, Any]:
"""Render the full-VM JSON Firecracker accepts via ``--config-file``."""
drives = [
{
"drive_id": "rootfs",
"path_on_host": self.rootfs_image,
"is_root_device": True,
"is_read_only": self.rootfs_read_only,
}
]
drives.extend(self.extra_drives)
return {
"boot-source": {
"kernel_image_path": self.kernel_image,
"boot_args": self.boot_args,
},
"drives": drives,
"machine-config": {
"vcpu_count": self.vcpus,
"mem_size_mib": self.mem_size_mib,
"smt": False,
},
"vsock": {
"guest_cid": self.guest_cid,
"uds_path": self.vsock_uds_path,
},
}
29 changes: 28 additions & 1 deletion pyisolate/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .observability.alerts import AlertManager
from .observability.trace import Tracer
from .policy import resolve_policy
from .runtime import microvm as _microvm
from .runtime.process_backend import ProcessSandbox
from .runtime.protocol import CapabilityHandle, ControlRequest
from .runtime.thread import SandboxThread
Expand Down Expand Up @@ -313,6 +314,11 @@ def spawn(
"""
global NAME_PATTERN
backend = _normalize_backend(backend)
if backend == "microvm":
# Dedicated fail-closed path: probe for a real VMM/KVM boundary and
# refuse with an actionable diagnostic rather than the generic
# not-implemented error. The launcher itself is still pending.
return self._spawn_microvm(name)
_require_implemented_backend(backend)
if not isinstance(name, str) or not name:
raise ValueError("Sandbox name must be non-empty string")
Expand Down Expand Up @@ -479,6 +485,24 @@ def _apply_kernel_policy(self, cg_path: Any, policy: Any) -> None:
raise
logger.debug("sandbox_policy map update skipped for %s", cg_path)

def _spawn_microvm(self, name: str) -> Sandbox:
"""Admit a microVM sandbox, failing closed until the launcher lands.

Probes the host for a usable VMM and KVM. When the boundary cannot be
established the caller gets a precise :class:`MicroVMUnavailable` naming
every missing prerequisite. When the host *is* capable, we still refuse
with :class:`NotImplementedError`, because the guest boot and vsock
transport are not yet wired -- but the refusal is honest about why,
instead of degrading to a weaker boundary.
"""
support = _microvm.require_microvm_support()
raise NotImplementedError(
f"backend='microvm' prerequisites are present (VMM: "
f"{support.vmm_kind} at {support.vmm_path}, /dev/kvm accessible), but "
"the guest launcher and vsock transport are not yet implemented. "
"Use backend='process' for kernel-level confinement in the meantime."
)

def _spawn_process(
self,
name: str,
Expand Down Expand Up @@ -709,7 +733,10 @@ def _get_supervisor() -> Supervisor:
def spawn(*args, **kwargs):
if "backend" in kwargs:
backend = _normalize_backend(kwargs["backend"])
_require_implemented_backend(backend)
# microVM has a dedicated fail-closed path in Supervisor.spawn that
# reports *why* it is unavailable; let it through the generic guard.
if backend != "microvm":
_require_implemented_backend(backend)
kwargs["backend"] = backend
return _get_supervisor().spawn(*args, **kwargs)

Expand Down
Loading
Loading