Skip to content

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 code

Boundary 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)

ThreatDefense
Hung substrate hangs the SDK foreverPer-call wall-clock timeout (MVMFORGE_INVOKE_TIMEOUT_SEC, default 60s). Kill via process group on expiry; raises MvmTransportError.
Runaway substrate output OOMs the hostStreaming reader threads with hard cap (MVMFORGE_MAX_OUTPUT_BYTES, default 16 MiB). Kill on overflow.
Workload id misparsed by mvmctl as a flagIR 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 payloadHost 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 parserWrapper 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 argsHeuristic 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)

ThreatDefense
Inbound payload pathologically largeHard cap (default 16 MiB, configurable per-image at build time). Reads in chunks; aborts before buffering past the cap.
Inbound payload pathologically nestedMax nesting depth 64. Rejects deeper.
Inbound payload contains duplicate keysJSON object_pairs_hook rejects duplicates (Python).
Inbound payload contains non-finite floatsparse_constant rejects NaN / Infinity (Python).
Inbound payload uses code-executing serializer formatIR 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 / payloadProd-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 dumpprctl(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 stateSingle-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)

DefenseLives in
dm-verity rootfs (read-only, integrity-verified)mvm guest-lib
Per-service uid + seccomp tiermvm guest-lib (standard-python, standard-node — pending)
RLIMIT_CORE = 0 on the wrapper servicemvm 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)

DefenseStatusPlan
Hash-pinned dependencies (uv.lock / pnpm-lock.yaml / etc.)declaration shape pendingplan-0008
pip install --only-binary=:all: for runtime depsupstream mvmplan-0003 phase 4
npm install --ignore-scripts for runtime depsupstream mvmplan-0003 phase 4
Vendor advisory checks (pip-audit, npm audit)upstream mvmplan-0003 phase 6
Hash-pin reproducibility checkupstream mvmplan-0003 phase 6
Forbidden-import grep on wrappersshippedplan-0007 phase 1
Schema-bound payload validationpendingplan-0009
Granular network grants (egress / peers / ingress / dns)pendingplan-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 under nix/wrappers/) or the Nix factory that bakes them in can bypass every defense above. That’s why the templates ship under nix/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.parse silently 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_BIN is env-trusted. Standard subprocess trust model. The SDK runs in the user’s Python / Node process where they can import os and run anything; constraining mvmctl discovery 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:

VariableDefaultEffect
MVMFORGE_INVOKE_TIMEOUT_SEC60Per-f.remote(...) wall-clock timeout.
MVMFORGE_SESSION_START_TIMEOUT_SEC60mv.session(...) start timeout.
MVMFORGE_SESSION_STOP_TIMEOUT_SEC30Session teardown timeout.
MVMFORGE_MAX_OUTPUT_BYTES16777216Hard cap on mvmctl stdout / stderr per call.
MVMFORGE_STRICT_SECRETSunsetWhen 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):

FieldDefaultEffect
modeproddev enables tracebacks alongside envelopes.
max_input_bytes16777216Wrapper-side stdin cap. Belt-and-suspenders with the substrate’s M1 cap.
working_dir/appWhere 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.