---
id: ADR-080
title: "MCP STDIO transport vulnerability-class sandbox canon · 5-layer mitigation (post phantom-CVE audit)"
status: proposed
date: "2026-05-12"
phase: "Phase 2 (pre-nika-mcp-server admission)"
deciders: ["@ThibautMelen"]
tags: ["security", "mcp", "stdio", "sandbox", "cve-cluster-2025-2026", "supply-chain", "allowlist", "manifest", "ed25519", "blake3"]
affects_crates: ["nika-protocols", "nika-policy", "nika-binding", "nika-mcp-server", "nika-sandbox-landlock", "nika-sandbox-seatbelt"]
affects_layers: ["L0.5", "L2", "L3", "L4"]
supersedes: []
superseded_by: []
related: ["ADR-006", "ADR-014", "ADR-020", "ADR-030", "ADR-079"]
requires: ["ADR-006", "ADR-014", "ADR-079"]
enables: []
fci: ["FCI-001", "FCI-006"]
inv: ["INV-011", "INV-017", "INV-024", "INV-025"]
shadow_zones: ["Gate-1-nika-serve-input-trust", "Gate-4-L1-taint-runtime"]
nika_codes: ["NIKA-400 through NIKA-409 (proposed · MCP Sandbox range · post phantom-CVE audit · NIKA-380..389 SHIPPED Shield range preserved)"]
timeline: "ship pre-nika-mcp-server admission · sequenced 5-wave per §Migration plan"
follow_ups: ["ADR-080-bis queued · SSE/HTTP transport carve-out", "seccomp-bpf Layer (d-bis) Linux companion to landlock"]
---

# ADR-080: MCP STDIO transport vulnerability-class sandbox canon · 5-layer mitigation

## Context

**Phantom-CVE audit 2026-05-12** (post rust-security agent dispatch) · ADR-080 v1.0 cited « CVE-2026-04 » which was a synthesized identifier · NOT a real NVD record. The vulnerability CLASS is empirically real · cited verbatim from primary sources ·

- **April 2026 OX Security advisory** + Hacker News coverage · MCP STDIO transport subprocess-spawn class flagged · « more than 150 million [cumulative] downloads » + « more than 7,000 publicly accessible servers » (verbatim · NOT « /month » as v1.0 said) · primary source https://thehackernews.com/2026/04/anthropic-mcp-design-vulnerability.html
- **CVE-2025-49596** (MCP Inspector RCE · CVSS 9.4 · localhost-via-CSRF browser RCE · distinct vector class)
- **CVE-2026-6130** (chatbox `StdioClientTransport` · CVSS 7.3 HIGH)
- **CVE-2025-65720** (GPT Researcher MCP)
- **7+ assigned April-May 2026** · Bisheng · Flowise · Windsurf · DocsGPT · Langchain-Chatchat clusters

Anthropic publicly « declined to modify the protocol architecture, citing the [STDIO subprocess-spawn] behavior as ‘expected' » (Hacker News + OX Security verbatim). The implicit doctrine · sandboxing is SDK-consumer responsibility · not protocol-level guarantee. Nika is an SDK consumer (`nika-protocols` MCP module + `nika-mcp-server` L4 binary planned Phase 2 admission per BLUEPRINT_2036 v1.5).

**Empirical state** (HEAD `3741eae91`) ·
- `crates/nika-kernel-plugin/src/sandbox.rs:19-92` — `Sandbox` trait + `Capability::ProcessSpawn` + sealed pattern shipped
- `crates/nika-schema/src/raw/mcp.rs:32-48` — `RawMcpServer { alias, command, args, env, url, cwd }` parses BOTH STDIO + SSE/HTTP transports
- `crates/nika-types/src/trust.rs:34-44` — `TrustLevel` lattice (SANDBOXED=10 · UNTRUSTED=50 · TRUSTED=150 · ELEVATED=200) available
- NIKA-380..389 range **ACTIVE Shield codes** per `nika/engine/SECURITY.md` + `forward-compat-invariants.md:130` + `adr-020-wasm-plugin-boundary.md:20,60` + `adr-005-error-hierarchy.md:33` + `crates/nika-kernel/src/errors.rs` (NOT reserved · NOT brouillon-legacy · the v1.0 « NUKE-LEGACY renumber freely » premise was WRONG · rust-security audit 2026-05-12 caught the collision pre-Accepted)
- NIKA-400..409 range **ALLOCATED for MCP sandbox** (this ADR · post phantom-CVE audit)
- `nika-mcp-server` · NOT admitted · NOT scaffolded · L4 Phase 2 target
- `nika-binding` · NOT admitted · L3 post-W10 target

**Premise audit (Socratic §5a)** · STDIO is the *named* vector but the *class* is wider — MCP supports STDIO + SSE/HTTP + (future) websocket. Per **Q5.B lock applied** · ADR-080 = STDIO only (matches BLUEPRINT v1.5 entry verbatim · scope-lock per V6 PE-9). ADR-080-bis queued for SSE/HTTP transport carve-out · websocket reserved via `#[non_exhaustive]` ratchet.

## Decision

**Ship 5-layer mitigation** · 3 mandatory + 2 bonus (platform-gated).

### Layer (a) Command allowlist · MANDATORY

Typed allowlist evaluated BEFORE `Command::new`. Two-tier intersection:

- **Workflow-level** · `mcp.servers[].command` declared inline in `*.nika.yaml` (workflow author asserts intent)
- **Engine-level** · `~/.nika/policy/mcp-allowlist.toml` (operator machine-wide list · loaded by `nika-policy` ADR-054)

Both must agree (intersection semantics). Empty engine-level = strict-mode deny-all.

```rust
// nika-protocols/src/mcp/sandbox.rs · L2
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct McpCommandAllowlist {
    entries: BTreeSet<AllowedCommand>,
    mode: AllowlistMode,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub struct AllowedCommand {
    pub canonical_path: PathBuf,   // resolved · symlink-followed · absolute
    pub digest: blake3::Hash,      // cross-link to Layer (b)
    pub min_trust: TrustLevel,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AllowlistMode {
    /// Empty allowlist = deny all · default · safe-by-default
    Strict,
    /// Empty allowlist = warn + log + allow · only in `--dev` flag at L4
    Permissive,
}
```

**Q3.C locked applied** · default `AllowlistMode::Strict` always · `nika init` scaffolds starter `~/.nika/policy/mcp-allowlist.toml` with 5 most-common MCP servers pre-allowlisted (github · filesystem · sqlite · brave-search · puppeteer) requiring operator to sign their manifests. Onboarding-friendly + secure-by-default.

### Layer (b) Verified-source manifest gate · MANDATORY

Before `Command::new`, resolved binary path is Blake3-hashed and compared against ed25519-signed manifest. Format inspired by `cargo-vet` audit chains.

**Q2.B locked applied** · in-house Blake3 + ed25519 default (sovereignty Rule 1 · `supernovae-alignment.md`). Sigstore opt-in seam **reserved** via Cargo feature flag `mcp-sigstore` (off-by-default) · `nika-protocols` Cargo.toml gains optional `sigstore` dep entry · wiring ships Phase 5+ on consumer signal (LOCK-031 spirit).

**Manifest format** (TOML · `~/.nika/manifests/mcp-<server>.toml`) ·

```toml
schema_version = 1
server_alias = "github"
canonical_path = "/usr/local/bin/mcp-server-github"
digest_algorithm = "blake3"
digest = "abc123…64hex"
signer_pubkey = "ed25519:deadbeef…"
signed_at = "2026-05-12T10:00:00Z"
expires_at = "2026-08-12T10:00:00Z"   # 90-day default · forces re-verification
signature = "ed25519:cafe…"
sources = [
  { name = "thibaut@supernovae", criteria = "safe-to-run", at = "2026-05-12" },
]
```

Forward-compat · `digest_algorithm` string allows future swap (Blake3-keyed · SHA-3 · post-quantum) per ADR-028 reservation policy.

### Layer (c) `nika-binding` capability check · MANDATORY (compile-time via ADR-079)

Binding layer gates each MCP tool invocation through ADR-079's `Capabilities<E: EffectSet>` phantom-type. Even if (a) and (b) pass — server allowed to spawn, binary verified — the workflow must additionally hold compile-time proof of effect capability.

```rust
// nika-binding/src/mcp.rs · L3 · depends on ADR-079
use nika_kernel::capability::{Capabilities, Has, Exec, FsRead};

pub async fn invoke_mcp_tool<E>(
    caps: &Capabilities<E>,
    server: &McpServerHandle,
    tool: &ToolName,
    args: Value,
) -> Result<Value, McpInvokeError>
where
    E: Has<Exec> + Has<FsRead>,  // tool declared side-effects MUST appear in caps
{ /* ... */ }
```

Tool declared-effects → kernel `Capability` mapping happens at workflow-compile-time (`nika check --security` lints). MCP servers omitting tags → default `Capability::ProcessSpawn` (most-restrictive · refuses bind without explicit operator override).

### Layer (d) OS process sandbox · BONUS · platform-gated

**Q4.B locked applied** · platform-gated (not Linux-mandatory). Cross-platform admission proceeds · landlock auto-enables on Linux · Seatbelt best-effort macOS · Windows ships without (e) layer documented as known gap.

| Platform | Mechanism | Crate |
|---|---|---|
| Linux ≥ 5.13 | landlock LSM unprivileged | `landlock = "0.4"` (audited · uutils precedent) |
| macOS | sandbox-exec + Seatbelt profile | `nika-sandbox-seatbelt` shell-out |
| Windows | DEFER · low priority · documented gap | n/a |

### Layer (d-bis) seccomp-bpf syscall filter · BONUS · Linux-only (added post audit 2026-05-12)

Per rust-security audit §4 · landlock LSM (Layer d) restricts filesystem access via Linux LSM but **does NOT restrict syscalls**. 2026 SOTA pairs landlock + seccomp-bpf for defense-in-depth · Firecracker / nono / Rust crates all use this combination.

**Mechanism** · `seccompiler = "0.4"` (Firecracker upstream · audited · MIT) generates BPF filters denying high-risk syscalls for sandboxed MCP servers ·

```rust
// nika-protocols/src/mcp/seccomp.rs (Linux-only · cfg-gated)
#[cfg(target_os = "linux")]
pub fn install_mcp_seccomp_filter() -> Result<(), SeccompError> {
    use seccompiler::{BpfProgram, SeccompAction, SeccompFilter};
    let filter = SeccompFilter::new(
        // Deny: socket creation (Layer b allowlist handles binary spawn only ·
        //       prevent MCP server from opening sockets to attacker C2)
        // Deny: ptrace (prevent debugging escape)
        // Deny: mount/umount2 (prevent fs ns escape)
        // Allow: read/write/close/exit/futex/clock_gettime (basic stdio + cooperative scheduling)
        rules,
        SeccompAction::Errno(libc::EACCES as u32),
        SeccompAction::Allow,
        std::env::consts::ARCH.try_into()?,
    )?;
    let prog: BpfProgram = filter.try_into()?;
    seccompiler::apply_filter(&prog)?;
    Ok(())
}
```

**~80 LOC additive** · cfg-gated · zero impact on macOS/Windows. NIKA-410 reserved if seccomp setup fails (cohérent NIKA-407 landlock pattern · transient = true).

### Layer (e) Resource limits · BONUS · always-on

`setrlimit` (Unix) before exec · max CPU seconds · RSS · child processes · open files. Cross-link to INV-011 (`kill_on_drop(true)` mandatory) + ADR-016 cancel-safety + ADR-070 child-token tree.

```rust
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct McpResourceLimits {
    pub max_cpu_seconds: Option<u32>,    // default 300s
    pub max_rss_bytes: Option<u64>,      // default 512 MiB
    pub max_processes: Option<u32>,      // default 16
    pub max_open_files: Option<u32>,     // default 256
    pub idle_timeout: Duration,          // default 60s · CancellationToken
}
```

## NIKA error code allocation (NIKA-400..409 · MCP Sandbox range)

**Post-audit migration 2026-05-12** · v1.0 of this ADR claimed Q1.A « NUKE-LEGACY renumber freely » applied to NIKA-380..389. **THAT PREMISE WAS WRONG.** Per rust-security audit 2026-05-12 ·
- NIKA-380..389 are **SHIPPED Shield codes** active in `nika/engine/SECURITY.md` + `forward-compat-invariants.md:130` Provider+Shield active-range registry + 5 other canonical refs (NOT brouillon-legacy · NOT reserved-for-MCP)
- Renumbering them would break the v0.79+ Diamond Shield invariant + cascade through 7+ hard refs in shipped code/ADRs
- Per `no-legacy-no-back-compat.md` · NUKE applies to brouillon legacy · NOT to shipped Diamond canon

**Corrected allocation** · NIKA-400..409 (next free range per `forward-compat-invariants.md:130` after 380-389 Shield + 390 ADR-073 reservation) ·

| Code | Name | Trigger | `is_transient()` |
|---|---|---|---|
| NIKA-400 | `McpCommandNotAllowlisted` | Layer (a) deny · command not in allowlist | false |
| NIKA-401 | `McpManifestMissing` | Layer (b) deny · no manifest at `~/.nika/manifests/` | false |
| NIKA-402 | `McpManifestExpired` | Layer (b) deny · `expires_at < now()` · operator re-signs | false |
| NIKA-403 | `McpDigestMismatch` | Layer (b) deny · binary at path doesn't match manifest digest | false (tampered) |
| NIKA-404 | `McpSignatureInvalid` | Layer (b) deny · ed25519 verify failed | false (corrupt) |
| NIKA-405 | `McpCapabilityDenied` | Layer (c) runtime check fallback (when compile-time phantom-type erased via dyn dispatch) | false |
| NIKA-406 | `McpToolUntagged` | Catalog projection · MCP tool lacks side-effect tag · strict-mode refuses bind | false |
| NIKA-407 | `McpSandboxSetupFailed` | Layer (d) · landlock/seatbelt setup error | true (retry MAY succeed) |
| NIKA-408 | `McpResourceLimitExceeded` | Layer (e) · server exceeded rlimit · killed | false |
| NIKA-409 | `McpStdioFramingError` | Pre-existing protocol error · framing escape | true |

**NIKA-410..419 RESERVED** for SSE/HTTP transport carve-out (ADR-080-bis · queued · post phantom-CVE audit · clean range chosen ABOVE existing 390 ADR-073 reservation).

## Migration plan

Sequence relative to BLUEPRINT v1.5 wave plan:

| Wave | Action | Verify |
|---|---|---|
| W-α (this ADR) | Ship `nika-protocols::mcp::sandbox` module with Layers (a)+(b) types · 0 runtime · types-only landing | `cargo public-api` additive |
| W-β | Ship `nika-policy` module inside kernel (ADR-054 queue) · loads `~/.nika/policy/mcp-allowlist.toml` | unit tests |
| W-γ | Ship `nika-binding` L3 crate · wires Layer (c) via ADR-079 `Capabilities<E>` · adds `invoke_mcp_tool<E>` signature | `trybuild` compile_fail |
| W-δ | Ship `nika-sandbox-landlock` (Linux) — Layer (d) bonus | Linux-only e2e |
| W-ε | Admit `nika-mcp-server` L4 binary · 12-gate admission REQUIRES all 5 prior waves passing Gate 9 canary | full 12-gate ceremony |

**Gate 9 canary scenarios** for `nika-mcp-server`:
- C1 · Spawn allowlisted + verified MCP server · execute benign tool · assert exit code 0 + no `kill_on_drop` firing
- C2 · Spawn non-allowlisted command · assert NIKA-400 raised + zero subprocess started (process audit via `ps`)
- C3 · Spawn allowlisted with tampered binary (test fixture mutated 1 byte) · assert NIKA-383
- C4 · Compile-time test (`compile_fail/`) · attempt `invoke_mcp_tool` with `Capabilities<NoExec>` · assert `rustc` rejects
- C5 · Linux-only · MCP server attempts `fs_write("/etc/passwd")` · assert landlock denies + child exits non-zero

## Threat model (5 vectors)

| # | Vector | Mechanism | Likelihood | Severity | Layer mitigation |
|---|---|---|---|---|---|
| T1 | Malicious `command` injection | Workflow YAML or runtime config supplies `command: /tmp/evil` | HIGH | CRITICAL | (a) allowlist |
| T2 | Tampered binary on allowed path | `command: npx` allowed · `~/.npm/_npx/...` swapped | MEDIUM (xz-utils class) | CRITICAL | (b) manifest |
| T3 | Tool-name confusion → over-capability | MCP tool name collides with workflow grant · different semantics | MEDIUM | HIGH | (c) Capabilities phantom |
| T4 | Subprocess escape | MCP server forks · execs grandchild | LOW-MEDIUM | HIGH | (d) OS sandbox bonus |
| T5 | Resource exhaustion / fork-bomb | DoS via CPU · memory · fork | MEDIUM | MEDIUM (availability) | (e) rlimit bonus |

## Risk audit (5 measurable assertions)

1. **Operator skips signing manifests · falls back to permissive globally** (MEDIUM × HIGH) — hygiene vector `check-mcp-permissive-mode.sh` (P1/fail) · prod profile MUST declare `mode = "strict"`
2. **Allowlist drift · stale entries** (MEDIUM × HIGH) — manifest `expires_at` 90-day default · stale-manifest hygiene warns at 75d
3. **Layer (c) bypassed via dyn dispatch** (LOW-MEDIUM × MEDIUM) — runtime NIKA-405 MUST fire when compile-time erased · proptest covers dyn-dispatch path
4. **Manifest signature key compromise** (LOW × CRITICAL) — multi-sig support reserved (manifest `signers` field accepts array per FCI-080a) · 90d expiry caps blast radius
5. **Per-invocation Blake3 + sig verify latency** (HIGH × LOW) — hash + verify happen ONCE at `McpServerHandle` connect, NOT per-call · `benches/mcp_connect.rs` MUST stay <50ms (Blake3 ~1GB/s · typical 5-50 MiB binary)

## Forward-compat invariants

- **FCI-080a** · `McpCommandAllowlist` `#[non_exhaustive]` · adding fields safe
- **FCI-080b** · `AllowlistMode` `#[non_exhaustive]` · future `AllowlistMode::Audit` (log without deny) reserved
- **FCI-080c** · `McpSandboxError` enum `#[non_exhaustive]` per INV-025
- **FCI-080d** · Manifest `schema_version: u16` canonical · bumps via ADR-028 reservation
- **FCI-080e** · `digest_algorithm` field is `String` not enum · future Blake3-keyed / post-quantum upgrades land without parse break
- **FCI-080f** · Layer (c) `Capabilities<E: Has<Exec>>` bound additive · adding new effect markers does not break existing `Has<Exec>` callers

## Cross-source validation flags (per cross-source-validation.md §2.6)

✅ **PRIMARY SOURCES VERIFIED 2026-05-12** via rust-security agent audit ·
- ✅ **RESOLVED 2026-05-12** · rust-security audit confirmed literal « CVE-2026-04 » was phantom-CVE class hallucination (4/4 Signal triggers per §2.6) · scrubbed throughout. Real vulnerability class anchored to primary sources · OX Security April 2026 advisory + Hacker News + multi-CVE cluster (2025-49596 · 2026-6130 · 2025-65720 · 7+ assigned April-May 2026). Class-truth preserved · phantom identifier removed.
- ✅ **Anthropic statement verified** · primary quote « declined to modify the protocol architecture, citing the [STDIO subprocess-spawn] behavior as ‘expected' » (verbatim from OX Security advisory + Hacker News April 2026 · NOT « SDK consumers must sandbox by construction » which was v1.0 ADR-author synthesis). Doctrine equivalence preserved · phrasing now sourced.

Both flags MUST resolve before this ADR locks Accepted. Until then, Status: Proposed pending verification.

## Anti-canon flags

- **Amends ADR-006** doc-string · expands kernel `Sandbox` scope from « WASM plugins » to « WASM + MCP servers » · trait shape unchanged · follow-up commit refreshes doc
- **Hard-depends on ADR-079** · Layer (c) compile-time requires `Capabilities<E: EffectSet>` · if ADR-079 slips, Layer (c) ships as runtime-only check (acceptable degraded mode · documented)
- **Does NOT amend ADR-020** (WASM plugin boundary) · MCP servers are native binaries · WASM-MCP convergence reserved future ADR-081
- **Does NOT amend ADR-053** (`nika-protocols`) · MCP module gains `sandbox` submodule · purely additive

## 5 raisons coherence

| Raison | Grade | Justification |
|---|:-:|---|
| ① Liberté cognitive | ✅ | Compile-time phantom-type (Layer c · via ADR-079) makes LLM agent structurally unable to invoke tool without proven capability · « anti-hallucination by type system » |
| ② Souveraineté | ✅ | Layer (b) in-house Blake3 + ed25519 · zero vendor-hosted (Sigstore opt-in only) · key custody on user machine |
| ③ Joy 🦋 | ✅ | « Provably-safe sandboxed-extension architecture none of Restate/LangGraph/Temporal/Mem0 ships » · masterpiece-mode applied to security |
| ④ Composable galaxy | ✅ | 5-layer composes orthogonally · 3 mandatory + 2 platform-gated bonus · Olympus consumes via `olympus-os-mcp` bridge |
| ⑤ Studio signature | ✅ | « Anthropic refuses fix → SuperNovae ships sovereign solution » · the SDK-sandboxes pattern IS the SuperNovae style applied to MCP |

## Update log

```
2026-05-12  v1.1 — Post phantom-CVE audit amendment (rust-security agent dispatch)
              · Trigger · ultrathink session arc 2026-05-12 · 3-agent parallel
                dispatch · rust-security audit caught 3 issues pre-Accepted ·
                (1) literal CVE-2026-04 fabrication · (2) NIKA-380..389 collision
                with shipped Shield codes · (3) missing seccomp-bpf companion
                to landlock for Linux defense-in-depth.
              · Fix 1 · CVE-2026-04 references SCRUBBED · replaced with verified
                cluster anchor (OX Security April 2026 + Hacker News + CVE-
                2025-49596 MCP Inspector CVSS 9.4 + CVE-2026-6130 chatbox
                StdioClientTransport CVSS 7.3 + CVE-2025-65720 + 7 more
                April-May 2026). Title + tag + frontmatter updated.
              · Fix 2 · NIKA error codes migrated 380..389 → 400..409 per
                `forward-compat-invariants.md:130` Provider+Shield active range
                registry · v1.0 « NUKE-LEGACY renumber freely » premise WRONG ·
                380..389 are SHIPPED Diamond Shield · not brouillon legacy.
                Reserve 410..419 for SSE/HTTP transport (ADR-080-bis carve-out).
              · Fix 3 · NEW §Layer (d-bis) seccomp-bpf companion to landlock ·
                ~80 LOC additive · Linux-only cfg-gated · seccompiler 0.4
                (Firecracker upstream · audited · MIT) · NIKA-410 reserved.
              · Anthropic statement · paraphrase v1.0 replaced with verbatim
                primary source « declined to modify the protocol architecture,
                citing the behavior as ‘expected' ».
              · §2.6 4-Signal detection PROVEN antifragile · phantom-CVE class
                JOINS PE-10 hallucination catalog · Vector #38 cve-primary-
                source-discipline candidate cycle 3/3 trigger met.

2026-05-12  v1.0 — Initial ADR (D-2026-05-12 trio · 078/079/080)
              · Q1.A NUKE-LEGACY premise WRONG · corrected v1.1
              · CVE-2026-04 cited (phantom · corrected v1.1)
```

🦋
