Wrapper Security & Threat Model
This document describes the layered defenses around mvmforge’s function-entrypoint surface: where each defense lives, what threat it addresses, and the known limits of v0.2. The architectural commitments come from ADR-0009; this is the operator-facing companion.
Trust boundaries
┌─────────────────────────────────────────────────────────────────────┐│ User process (your Python or Node script) ││ import mvm as mv ││ add.remote(2, 3) ─────► Host SDK transport │└──────────────────────────────────┬──────────────────────────────────┘ │ [boundary 1] │ encoded [args, kwargs] over stdin ▼┌─────────────────────────────────────────────────────────────────────┐│ mvmctl (subprocess on the host machine) ││ relays stdin → vsock; stdout from VM → SDK │└──────────────────────────────────┬──────────────────────────────────┘ │ [boundary 2] │ vsock — VM ↔ host ▼┌─────────────────────────────────────────────────────────────────────┐│ guest agent (mvm side) ││ per-call hygiene: TMPDIR, FD reset, env baseline; spawns wrapper │└──────────────────────────────────┬──────────────────────────────────┘ │ [boundary 3] │ fresh process, stdin pipe ▼┌─────────────────────────────────────────────────────────────────────┐│ wrapper (nix/wrappers/{python,node}/{oneshot,longrunning}.{py,mjs}) ││ reads stdin, decodes payload, dispatches user fn, writes stdout │└──────────────────────────────────┬──────────────────────────────────┘ │ [boundary 4] ▼ user codeBoundary 1 is adversarial in the operability sense — the SDK
treats mvmctl as a subprocess that may hang, OOM, or otherwise
misbehave. Boundaries 2 and 3 are adversarial in the security
sense — that’s where dm-verity, seccomp, and the deny-default
network sit (mvm side). Boundary 4 is trust-boundary-shaped only
if the wrapper trusts the substrate: today the wrapper does, but
the wrapper’s decoder hardening still applies as defense-in-depth.
Layered defenses
Boundary 1: Host SDK transport (plan-0005)
| Threat | Defense |
|---|---|
| Hung substrate hangs the SDK forever | Per-call wall-clock timeout (MVMFORGE_INVOKE_TIMEOUT_SEC, default 60s). Kill via process group on expiry; raises MvmTransportError. |
| Runaway substrate output OOMs the host | Streaming reader threads with hard cap (MVMFORGE_MAX_OUTPUT_BYTES, default 16 MiB). Kill on overflow. |
| Workload id misparsed by mvmctl as a flag | IR validation rejects non-conforming ids with E_INVALID_ID (^[a-z][a-z0-9-]{0,62}$). SDK transport re-validates and uses -- argv separator before positional ids — defense-in-depth even if the IR validator was bypassed. |
| Hostile / buggy substrate returns malicious decoded payload | Host SDK applies decoder hardening on the return value: max nesting depth 64, non-finite numbers rejected, JSON parse errors surface as MvmTransportError rather than silent garbage. |
| Stderr envelope spoofing / log noise breaks parser | Wrapper emits envelope with a marker prefix (MVMFORGE_ENVELOPE: {…}). Host parser scans stderr for the marker; falls back to “last JSON-shaped line” for one release of compat with pre-marker wrappers. |
| Secrets accidentally passed as function args | Heuristic warning when a kwarg name matches (token|password|secret|api_key|credential|bearer|private_key|auth_token). Hard-error under MVMFORGE_STRICT_SECRETS=1. |
Boundary 4: Wrapper (plans 0003, 0005, 0006)
| Threat | Defense |
|---|---|
| Inbound payload pathologically large | Hard cap (default 16 MiB, configurable per-image at build time). Reads in chunks; aborts before buffering past the cap. |
| Inbound payload pathologically nested | Max nesting depth 64. Rejects deeper. |
| Inbound payload contains duplicate keys | JSON object_pairs_hook rejects duplicates (Python). |
| Inbound payload contains non-finite floats | parse_constant rejects NaN / Infinity (Python). |
| Inbound payload uses code-executing serializer format | IR Format enum is closed: Json | Msgpack. Code-executing formats forbidden by ADR-0009. CI lane (just wrapper-forbidden-check) greps the wrapper templates for the forbidden import set. |
| User code raises and the substrate’s logs leak file paths / vars / payload | Prod-mode wrapper emits a sanitized envelope ({kind, error_id, scrubbed-message}). Path-shaped tokens stripped; message capped at 200 chars. Full traceback flows through a separate operator-log channel (vsock secondary stream → host stderr; mvm-side, not yet wired). Dev mode echoes the traceback for ergonomics — never ship in prod. |
| Crash exposes process memory via core dump | prctl(PR_SET_DUMPABLE, 0) at startup (Linux). Belt-and-suspenders with the agent’s RLIMIT_CORE per ADR-0009. |
| Wrapper repurposed for warm-process reuse leaks state | Single-shot invariant explicit in wrapper docstrings. Runtime guard hard-errors on second main() invocation unless MVMFORGE_WRAPPER_ALLOW_REENTRY=1 is set. |
Substrate (mvm side — referenced for completeness)
| Defense | Lives in |
|---|---|
| dm-verity rootfs (read-only, integrity-verified) | mvm guest-lib |
| Per-service uid + seccomp tier | mvm guest-lib (standard-python, standard-node — pending) |
RLIMIT_CORE = 0 on the wrapper service | mvm agent |
| Per-call hygiene (fresh TMPDIR, FD reset, env baseline) | mvm agent (ADR-007 §6) |
| Stdin hard cap (M1) | mvm agent |
Network deny-default (TAP not allocated when mode=none) | mvm agent |
| Snapshot HMAC (warm-VM session reuse) | mvm agent |
Build-time (some pending; tracked in plans)
| Defense | Status | Plan |
|---|---|---|
Hash-pinned dependencies (uv.lock / pnpm-lock.yaml / etc.) | declaration shape pending | plan-0008 |
pip install --only-binary=:all: for runtime deps | upstream mvm | plan-0003 phase 4 |
npm install --ignore-scripts for runtime deps | upstream mvm | plan-0003 phase 4 |
| Vendor advisory checks (pip-audit, npm audit) | upstream mvm | plan-0003 phase 6 |
| Hash-pin reproducibility check | upstream mvm | plan-0003 phase 6 |
| Forbidden-import grep on wrappers | shipped | plan-0007 phase 1 |
| Schema-bound payload validation | pending | plan-0009 |
| Granular network grants (egress / peers / ingress / dns) | pending | plan-0004 phase 5 |
Known limits
-
The threat model assumes a trusted wrapper-templates + factory build. An attacker who can modify
nix/wrappers/python/oneshot.py(or any of the other wrapper templates undernix/wrappers/) or the Nix factory that bakes them in can bypass every defense above. That’s why the templates ship undernix/wrappers/(reviewable in mvmforge), why the factories will be reviewed in mvm, and why the rootfs uses dm-verity (so a runtime attacker can’t modify what was built). -
No streaming returns yet. v1 buffers the full return value before decode. A function returning gigabytes is rejected by the host SDK’s cap rather than streamed. Streaming lands when mvm’s chunked-events wire ships (mvm plan 41 v2).
-
No payload-schema validation in the wrapper yet. The wrapper enforces caps + format, but argument shape validation against a declared JSON Schema is plan-0009 work. Until then, type confusion between caller and callee surfaces as a Python
TypeError/TypeError-equivalent inside the wrapper, which the envelope scrubber handles. -
Duplicate-key rejection is wrapper-side only. V8’s
JSON.parsesilently merges duplicate keys before reviver functions run, so the TS host SDK can’t enforce dup-key rejection without a streaming JSON parser dep. The wrapper rejects on inbound (the dangerous direction); outbound from a trusted wrapper is correct by construction. -
MVMFORGE_MVM_BINis env-trusted. Standard subprocess trust model. The SDK runs in the user’s Python / Node process where they canimport osand run anything; constrainingmvmctldiscovery beyond env + PATH would be theater. -
Operator log channel for full tracebacks not yet wired. The wrapper today emits sanitized envelopes (no traceback) on prod-mode stderr. The full traceback channel (vsock secondary stream → host’s
mvmctl logs <vm>) is mvm-side work, tracked under plan-0003 §Phase 5.
Configuration knobs
All knobs are environment variables on the host SDK process:
| Variable | Default | Effect |
|---|---|---|
MVMFORGE_INVOKE_TIMEOUT_SEC | 60 | Per-f.remote(...) wall-clock timeout. |
MVMFORGE_SESSION_START_TIMEOUT_SEC | 60 | mv.session(...) start timeout. |
MVMFORGE_SESSION_STOP_TIMEOUT_SEC | 30 | Session teardown timeout. |
MVMFORGE_MAX_OUTPUT_BYTES | 16777216 | Hard cap on mvmctl stdout / stderr per call. |
MVMFORGE_STRICT_SECRETS | unset | When 1, secret-shaped kwarg names raise SecretInArgError instead of warning. |
MVMFORGE_MVM_BIN | (PATH) | Override mvmctl discovery. |
Wrapper-side knobs (set at image build time via wrapper.json):
| Field | Default | Effect |
|---|---|---|
mode | prod | dev enables tracebacks alongside envelopes. |
max_input_bytes | 16777216 | Wrapper-side stdin cap. Belt-and-suspenders with the substrate’s M1 cap. |
working_dir | /app | Where the wrapper chdir’s before importing the user module. |
Reporting a vulnerability
Use the security advisories tab on the GitHub repo. Do not file a public issue for an exploitable bug. The build-time / wrapper / substrate split documented above is meant to give you a starting point for “where does this live?” — if you’re not sure, file privately and we’ll route.