Skip to content

Casey-allard/mvm

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2,537 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mvm

mvm is a Rust CLI (mvmctl) and a set of language SDKs for running workloads in fast, hardware-isolated microVMs — from OCI images, Nix flakes, or decorated functions — on macOS and Linux, with a security posture that is enforced by CI, not by documentation.

Every machine boots its own Linux kernel under a real hypervisor. There is no Docker on the runtime path, no SSH in any guest, and (on the in-house macOS backend) no guest network device at all: guest I/O crosses vsock, where the host can audit flows, substitute secrets so the workload never sees raw credentials, detect-and-replace secrets and structured PII on owned cleartext egress paths, and enforce default-deny egress from a signed execution plan.

macOS 26+ (Apple Silicon)  →  in-house HVF VMM (Hypervisor.framework, zero extra deps)
macOS 13–25                →  libkrun (Homebrew)
Linux + /dev/kvm           →  Firecracker

Highlights

  • One command from image to isolated VMmvmctl machine run --image alpine -- uname -a
  • Three ways to define a workload — an OCI image, a Nix flake (mkGuest), or a decorated function (@mvm.app) — all compile to the same signed, auditable microVM
  • SDKs for Python, TypeScript, and Rust — a decorator SDK for authoring workloads and a runtime SDK for driving them, both thin wrappers over one conformance-pinned surface
  • Security claims, CI-enforced — 15+ numbered claims (signed execution plans, chain-signed audit log, dm-verity boot, default-deny egress, sealed prod images with no interactive access, secret substitution over vsock)

Install

# Pre-built release (macOS Apple Silicon, Linux x86_64/aarch64)
curl -fsSL https://raw.githubusercontent.com/tinylabscom/mvm/main/install.sh | sh

# From source
git clone https://github.com/tinylabscom/mvm.git && cd mvm
cargo build --release && cp target/release/mvmctl ~/.local/bin/

# Language SDKs
pip install mvm                 # Python  (or: pip install ./crates/mvm-sdk/sdks/python)
npm install @runmvm/mvm         # TypeScript

Host prerequisites: macOS 26+ Apple Silicon needs nothing (the in-house HVF backend and builder are dependency-free); macOS 13–25 needs the libkrun runtime (brew install slp/krun/libkrun slp/krun/libkrunfw); Linux needs /dev/kvm (Firecracker is managed for you). mvmctl doctor diagnoses your host and prints exact install hints for anything missing.

Quick start

Transient machines

A transient machine boots, runs one command, and is torn down on exit — nothing is registered, nothing persists. This is the default shape of machine run (no --name):

# Boot an OCI image, run a command, tear the VM down.
# Networking is OFF by default (default-deny egress).
mvmctl machine run --image alpine -- sh -c "echo hello from a microVM && uname -a"

# Multiple args after `--` are the argv; the VM lives only for this command.
mvmctl machine run --image python:3.12 -- python -c "print(2 + 2)"

# Interactive dev shell (dev-tier images) — still transient
mvmctl machine run --image alpine -it -- /bin/sh

# Give it resources; admit specific egress only (audited; TCP/22 always refused)
mvmctl machine run --image alpine --cpus 2 --memory 512M \
  --allow-host api.example.com:443 -- ./fetch

# Build a Nix flake and run it transiently in one step
mvmctl machine run --flake . -- ./app

# Share a host directory read-only; use :rw only with --profile dev/permissive
mvmctl machine run --image alpine --mount .:/work -- ls /work

# Increase logging globally; RUST_LOG still overrides the generated filter
mvmctl machine run --image alpine -vvv --allow-host api.example.com -- ps aux

Persistent machines

A persistent machine has a name and an on-disk spec: create once, start/stop/exec against it, reconfigure it, remove it when done.

mvmctl machine create web --image nginx --cpus 2 --memory 512M
mvmctl machine start web
mvmctl machine exec  web -- nginx -v
mvmctl machine logs  web
mvmctl machine reconfigure web --memory 1G     # patch + relaunch
mvmctl machine stop  web && mvmctl machine rm web

mvmctl machine ls                              # list (alias: ps)
mvmctl machine inspect web

The builder VM

Nix builds run inside a headless builder VM that mvm manages for you — there is no interactive shell into it. It exists only to run nix build, and you debug it through its logs. It auto-bootstraps on the first machine build / machine run; to set up host tooling and pre-acquire its image ahead of time:

mvmctl bootstrap      # host setup + pre-fetch the builder VM image (fast first build)
mvmctl doctor         # diagnose host deps + the resolved builder/runtime backend

For an interactive shell you want a workload microVM, not the builder — use a transient run against a dev-tier image: mvmctl machine run --image alpine -it -- /bin/sh.

Examples

Working example workloads live in examples/ — build any of them with mvmctl machine run --flake examples/<name> (Nix) or mvmctl build compile (SDK):

Example Kind What it shows
examples/python/hello-app Python decorator Minimal @mvm.app function-entrypoint workload
examples/python/hello-app-with-deps Python decorator @mvm.app with a locked python_deps (uv) dependency → sealed deps volume
examples/python/secret-egress Python decorator Secret substitution over egress — the workload sees a placeholder, never the raw secret
examples/typescript/hello-app TS decorator Minimal mvm.app({...})(fn) workload
examples/typescript/hello-app-with-deps TS decorator mvm.app with locked node_deps
examples/exit_code Nix flake One-shot sealed workload (exits a chosen code)
examples/sleeper Nix flake Long-lived sealed workload fixture
examples/egress-probe Nix flake One-shot workload that TCP-probes targets and exits a verdict — exercises egress policy
examples/audit-probe Nix flake In-guest host.audit.v1 round-trip fixture

Defining a workload

A workload can be defined three ways. All three compile to the same artifact — a signed image plus a launch plan — and boot identically on every backend.

1. From an OCI image

The fastest path — no flake, no host Nix:

mvmctl machine run --image python:3.12 -- python -c "print(2 + 2)"

Provenance (registry, repo, resolved digest, layer list, cosign verdict) is recorded in the chain-signed audit log; --prod refuses mutable tags before any network fetch. In a source checkout, OCI boots also need a cached dm-verity-capable workload kernel; build it once with mvmctl kernel build --which workload (or just kernel-workload) before the first machine run --image ....

2. From a Nix flake (mkGuest)

Reproducible, minimal guests built from a flake — the guest carries only what you declare. mkGuest has three entrypoint forms; the form decides the security posture:

{
  inputs = {
    mvm.url     = "github:tinylabscom/mvm?dir=nix";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
  };

  outputs = { mvm, nixpkgs, ... }:
    let
      system = "aarch64-linux";
      pkgs   = import nixpkgs { inherit system; };
    in {
      packages.${system}.default = mvm.lib.${system}.mkGuest {
        inherit pkgs;
        name = "my-app";

        # Form 1 — command entrypoint  (SEALED, one-shot)
        entrypoint.command = [ "${pkgs.python3}/bin/python3" "-m" "http.server" "8080" ];

        # Form 2 — services  (SEALED, supervised long-running)
        # entrypoint.services.web.exec = "${pkgs.caddy}/bin/caddy run";

        # Form 3 — shell  (DEV, console drops to a shell)
        # entrypoint.shell = "bash";
      };
    };
}
mvmctl machine build --flake .          # build the image inside the builder VM
mvmctl machine run   --flake . -- ./app # build + boot + run

Builds run nix build inside a builder VM — host Nix is never used or required, so the same mvmctl produces byte-identical artifacts on every host. Sealed images are dm-verity verified and have no interactive access; the dev form keeps a console. See the mkGuest guide and nix/lib/default.nix for the full API.

3. From a decorated function (SDK)

Write an ordinary function; the decorator declares the image, resources, deps, and env around it. mvmctl build compile reads the file statically (it is never executed on the host) and emits the flake + launch plan:

# app.py
import mvm

@mvm.app(
    image=mvm.python_image(python="3.12"),
    resources=mvm.resources(cpu=1, memory_mb=256),
    dependencies=mvm.python_deps(lockfile="uv.lock", tool="uv"),
    env={"BANNER": mvm.literal("hi")},
    before_start="export FOO=1",
)
def greet(name: str) -> str:
    return f"hello {name}"
mvmctl build compile app.py --out ./out   # static parse (no execution) → ./out (flake.nix + launch plan)
mvmctl machine build --flake ./out        # build the image inside the builder VM

# Dispatch greet(name="ari"): the entrypoint payload is [args, kwargs] JSON on stdin
# (empty stdin ⇒ the default no-arg payload `[[], {}]`).
echo '[[], {"name": "ari"}]' | mvmctl machine run --entrypoint --flake ./out   # → "hello ari"

At build time the @mvm.app decorator and the mvm import are stripped from the bundled source, so the guest runs your plain function with no SDK dependency inside the microVM.


SDKs

Two SDK families, three languages. Both are deliberately thin: they drive the exact surface the CLI does — decorators emit the canonical Workload IR; runtime calls go through the client facade — pinned by shared conformance fixtures so no SDK can drift from mvmctl.

Decorator SDK — authoring

Declare a workload where it lives. @mvm.app(...) (Python) / mvm.app({...}) (TypeScript) is higher-order: it records the declaration and returns your function unchanged, so the same file still runs normally under python / tsx and is also read statically by mvmctl build compile.

PythonTypeScript
import mvm

@mvm.app(
    image=mvm.node_image(node="22"),
    resources=mvm.resources(cpu=1, memory_mb=256),
)
def greet(name: str) -> str:
    return f"hello {name}"
import * as mvm from "@runmvm/mvm";

mvm.workload({ id: "hello" });

export const greet = mvm.app({
  image: mvm.node_image({ node: "22" }),
  resources: mvm.resources({ cpu: 1, memory_mb: 256 }),
})((name: string): string => `hello ${name}`);

Shared builder vocabulary across both languages: image builders (python_image, node_image, nix_packages), resources, dependency locks (python_deps, node_deps), env values (literal, secret), network / egress policy, and lifecycle hooks (before_build, before_start, …). Emit the IR directly for inspection or tests with mvm.emit_json() / mvm.emitJson().

Rust is the engine behind this path: mvm-sdk parses the decorators, holds the canonical ir::Workload, and renders the flake — so adding a language means emitting that IR, not writing a compiler.

Runtime SDK — control plane

Drive machines imperatively: create, exec, move files, run processes, forward ports, tear down. The Python/TypeScript Sandbox object model and the Rust MvmClient facade are the same operations over different transports.

PythonTypeScript
import mvm

with mvm.Sandbox.create(image="python-3.12") as sb:
    sb.files.write("/app/main.py", "print('hi from mvm')")
    sb.commands.start(["python", "/app/main.py"])
    print(sb.exec("uname", "-sr").stdout)
import * as mvm from "@runmvm/mvm";

const sb = await mvm.Sandbox.create({ image: "python-3.12" });
sb.files.write("/app/main.py", "print('hi from mvm')");
sb.commands.start(["python", "/app/main.py"]);
await sb.kill();

Run a Sandbox script as an admission-only plan check or against a real VM:

mvmctl run --mode plan ./script.py     # synthesize + sign + admit, no boot
mvmctl run --mode live ./script.py     # boot a real microVM and execute

Interactive surfaces (exec, commands.start, console) are dev-tier only; against a sealed prod image they refuse with SandboxDevOnly — no silent fallback (claim 4). Machine is the persistent-handle variant; Session drives function-entrypoint invoke.

Rust — the runtime SDK is the MvmClient facade (crates/mvm-client), an async trait with a LocalBackend (in-process, drives the host directly) and a GatewayBackend (REST, for remote/hosted control — behind the remote feature). Everything is one import; embed it to run machines from your own Rust service:

use mvm_client::{MvmClient, MachineSpec, LocalBackend};

let client = LocalBackend::new();

// Fluent builder — name + image are required; cpus/memory/env default + override.
let spec = MachineSpec::builder("web", "alpine")
    .cpus(2)
    .memory_mib(512)
    .env("PORT", "8080")
    .build();

let machine = client.run_machine(spec).await?;
let out = client.exec_machine(&machine.id, vec!["uname".into(), "-sr".into()]).await?;
println!("{}", String::from_utf8_lossy(&out.stdout));

The same facade is what the CLI, the desktop studio GUI, and the fleet orchestrator (mvmd) all consume — one surface, every frontend.

Embedding mvm — studio, mvmd, and custom frontends

There are two integration seams, depending on what you're building.

Driving machines from a frontend (the desktop studio, a custom GUI/CLI, a web service): link mvm-client and go through the MvmClient trait. connect(Target) picks the transport; the returned Box<dyn MvmClient> behaves identically either way, so the same UI code drives a local host or a remote fleet:

use mvm_client::{connect, MvmClient, Target};

// In-process — this host's microVMs, auto-selected VMM. No daemon required.
let local = connect(Target::Local)?;              // == mvm_client_local::LocalBackend::new()

// Remote — a hosted fleet or a local sidecar, over REST (needs feature `remote`).
let remote = connect(Target::Gateway {
    base_url: "https://fleet.example.com".into(),
    token: std::env::var("MVM_TOKEN")?,
})?;

// Identical methods on both: create / run / start / stop / remove, exec, logs, reconfigure.
for m in remote.list_machines(Default::default()).await? {
    println!("{}", m.id);
}

The studio desktop app is exactly this pattern — the in-process LocalBackend (built into mvm-client) or the remote GatewayBackend (the remote feature), selected at runtime via MVM_STUDIO_BACKEND, one dyn MvmClient behind its Tauri commands. Its Cargo.toml:

# LocalBackend ships by default; the `remote` feature adds the REST GatewayBackend.
mvm-client = { path = "../mvm/crates/mvm-client", features = ["remote"] }

Embedding the runtime in a host-side daemon (the mvmd fleet orchestrator, or your own controller that manages instances directly): link the mvmctl library facade for the runtime types, the host shell seam, and the gated host↔guest IPC transport. Keep default-features = false so no async runtime is pulled in unless you opt into the transport:

mvmctl = { path = "../mvm", default-features = false, features = ["hostd-transport"] }
use mvmctl::core::{instance::InstanceStatus, pool::Role, protocol};
use mvmctl::runtime::shell;   // host command-execution seam

mvmd reconciles pools/instances and reaches each guest agent over the hostd-transport protocol through this seam, while workload-driving frontends stay on the MvmClient facade above. Rule of thumb: drive sandboxes → MvmClient; run the host that hosts them → the mvmctl facade.


How it works

Host (macOS / Linux)
  mvmctl / SDK ──► signed ExecutionPlan ──► admission (validity window, nonce, audit)
                                              │
                                  VM backend (auto-selected)
                     Firecracker (KVM) · in-house HVF · libkrun · QEMU (dev/test)
                                              │
Guest (its own Linux kernel)
  /init ──► mvm-guest-agent on vsock :5252  — exec, files, processes, code-run
  no sshd · no SSH keys · setpriv + seccomp service isolation
  rootfs: ext4 (dm-verity sealed in prod) or read-only virtio-fs

Backend selection is automatic per host (--hypervisor overrides); all backends consume the same image artifacts. Egress is default-deny — where policy admits flows they are enforced and audited host-side, and on the in-house backend all guest I/O rides vsock through a per-VM gating endpoint (there is no guest NIC).

Security model

mvm makes fifteen numbered, CI-enforced security claims (plus preview claims), each backed by a named test or workflow gate. In summary:

  1. No host-fs access from a guest beyond explicit shares — per-service uid, seccomp, setpriv --no-new-privs bounding set.
  2. No guest binary can elevate to uid 0 — read-only /etc/{passwd,group}, no-new-privs in the launch path.
  3. A tampered rootfs ext4 fails to boot — dm-verity + kernel-cmdline roothash
    • verity initramfs; live-KVM tamper regression panics before userspace. Scoped to the block+ext4 backends (Firecracker + Option B); the virtiofs-root dev-tier path carries a weaker contract — see the claim catalog.
  4. The guest agent has no do_exec in production builds — symbol-absence CI gate on the sealed agent.
  5. Vsock framing + supervisor config are fuzzedcargo-fuzz targets; #[serde(deny_unknown_fields)] fails closed on every host↔guest type.
  6. Pre-built dev image is hash-verified — SHA-256 manifest checked, rejected on mismatch.
  7. Cargo deps are audited on every PRdeny.toml + reproducibility double-build.
  8. Every workload runs from a signed, audited ExecutionPlan — Ed25519 host signature, validity window, nonce replay-store; chain-signed plan.admitted/launched/failed audit entries.
  9. Every published bundle is content-addressed and re-verified at fetch and admit time (unknown-key, tampered-manifest, pin-drift ladders).
  10. No untrusted workload reaches the network unless admitted by policy — default-deny; unrestricted requires an explicit opt-in.
  11. Every app-dependency volume is hash-locked, attestation-checked, CVE-scanned, SBOM-enumerated, and bound to the workload's audit chain.
  12. Every host-side broker service is bound to a signed ExecutionPlan.services binding, enforced before dispatch, audited.
  13. No raw secret value crosses to the guest — destination-bound, time-bound signed credentials only; real bytes never leave the supervisor. For owned cleartext outbound flows, the host can also detect and replace matched secrets and structured PII with request-scoped opaque tokens, then restore the original bytes only when the exact token returns on an owned, authorized cleartext path.
  14. Every run --image admission records OCI image provenance in the chain-signed audit log.
  15. No interactive access to a sealed production microVM — the console is interactive-feature-gated, the prod rootfs is verity-sealed, console capture is write-only, and the host gate refuses console on a sealed VM.

The guest agent runs as an unprivileged uid under setpriv; ~/.mvm and ~/.mvm/cache are mode 0700. Out of scope (named in ADR-001): a malicious host (mvmctl trusts the host with the hypervisor and private keys), multi-tenant guests (one guest = one workload), and hardware-backed key attestation.

  • The claim ledger (claim → witness, machine-checked): the conformance claim catalog embedded in ADR-001
  • The source of truth (threat model, tier matrix): ADR-001
  • Live posture on your host: mvmctl doctor
  • Audit chain verification: mvmctl trust audit verify (exits nonzero on drift)

Documentation

Contributing

Contributions are welcome. The short version:

Setup

git clone https://github.com/tinylabscom/mvm.git && cd mvm
just install-hooks        # pre-commit hook: auto-runs cargo fmt --all

# Source-checkout builds cross-compile embedded guest binaries; you need:
brew install zig          # or your distro's zig
cargo install cargo-zigbuild cargo-nextest

End users of released binaries need none of that — the guest binaries ship embedded. You do not need host Nix: every Nix evaluation runs inside the builder VM. After building, run mvmctl doctor — it reports the resolved builder backend and emits install hints for anything missing.

Build, test, lint

just build           # cargo build
just test            # cargo nextest run --workspace   (the named test gate)
just test-fast       # skips the embedded-binary cross-compile (fast inner loop)
just lint            # cargo fmt --all -- --check  +  clippy -D warnings
just ci              # lint + tests + doctests — run this before every PR

Ground rules (enforced by CI — see AGENTS.md for the full set):

  • Zero clippy warnings. #[allow(clippy::too_many_arguments)] is banned in hand-written code — introduce a builder struct instead.
  • Always cargo fmt --all — without --all, other workspace members are silently skipped and CI will fail.
  • No task is done without tests. Types get serde round-trips; wire/protocol code gets tampered-input rejection tests; security paths get positive and negative cases. SDK changes must keep the shared conformance fixtures (tests/machine-fixtures/) green — that is what keeps the wrappers thin.
  • Reuse first. Search the workspace before adding a helper — duplicated logic is this repo's most common bug source. All ~/.mvm paths go through mvm-core::config helpers, never inline $HOME joins.
  • Specs discipline. Design docs live in specs/ (ADRs in specs/adrs/, plans in specs/plans/). If your change lands a plan workstream, tick the matching boxes in the plan and refresh the refactor status dashboard in the same PR. If it touches a security claim, keep the conformance claim catalog in ADR-001 in sync — the claim→witness mapping is machine-checked.

Keep PRs focused (one concern each) and write commit messages that explain why. PRs merge through the GitHub merge queue once CI is green. The full live suite (workspace clippy on x86_64-linux, seccomp probes, longer fuzz runs, live-KVM smokes) needs real /dev/kvm; cloud-init scaffolding for a throwaway KVM box lives in nix/ops/hetzner/, and the contributor guide has the details.

Repository layout

14-crate Cargo workspace. The dependency spine runs low → high: mvm-protocol (no_std + alloc: wire types / Workload IR / policy / audit-log verify — wasm-capable) → mvm-core (std: config / paths / crypto / signed execution plans — no async by default) → { mvm-fs (ext4 / OCI / overlay), mvm-net (vsock + egress tunnel), mvm-build (Nix builder pipeline) } → mvm-runtime (the VmBackend trait plus the libkrun / HVF / Firecracker / QEMU impls and VM lifecycle) → mvm-client (the local/remote client facade the SDKs and frontends share) → mvm-cli (the mvmctl surface). Alongside the spine: mvm-hostd (host daemons — broker, signers, per-VM supervisor binaries), mvm-agentd (in-guest vsock protocol + agent), mvm-sdk (decorator parser → Workload IR → Nix template, plus the runtime SDK), and deps/libkrun-sys (the libkrun FFI + safe wrapper). xtask holds the CI lint gates and mvm-conformance runs the BDD security-claim suite. The full module map is in CLAUDE.md. Language SDK surfaces live under crates/mvm-sdk/.

License

Apache 2.0 — see LICENSE.

About

Manage secure microVMs — one command from zero to secure sessions, locally and remotely.

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages