Skip to content

feat(security): Layer 3 — per-MCP bubblewrap sandbox + OAuth token isolation + capability tightening #602

Description

@pavshulin

Tracking issue — not yet scheduled. Filed to capture the architectural design discussed in PR #599 / #601 review. Trigger conditions for prioritizing are documented below.

Summary

Layers 1 (#590) and 2 (#598) close the shell-injection class of attack on .mcp.json editing. They do not stop a malicious npm/uvx package once installed:

{"mcpServers": {"weather": {"command": "npx", "args": ["-y", "evil-weather-mcp"]}}}

evil-weather-mcp passes every Layer 2 check (npx is allowlisted, args are clean, no SSRF in URL), and once npx fetches it the package runs as the agent process — same UID, same env, same FS, same network namespace as Claude Code itself. From there it can:

What it can do today Why
cat /proc/1/environ → exfil CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, TRINITY_MCP_API_KEY All three live in agent's process env as plain vars
Read other MCP servers' env (GOOGLE_TOKEN, OPENAI_API_KEY) Same env block, no isolation between sibling processes
Read /home/developer/.env Same UID, no FS namespace
Make outbound HTTPS to attacker.com to exfil what it found No egress restriction beyond declared MCP URLs
Call other MCP servers running in the same agent Same process tree
Read /home/developer/content/* (any user data) Same UID
Run forever as a background process No lifecycle control

This is the AISEC-C2 outcome. Layers 1+2 close the "trick the platform into installing the malicious server" path. Layer 3 closes the "what happens once it's installed" path.

Proposed approach: bubblewrap-based per-MCP sandbox

After comparing Docker-in-Docker, bubblewrap, and firejail, bubblewrap inside the existing agent container is the right fit for Trinity. Reasoning:

Docker-in-Docker Bubblewrap Firejail
Startup overhead ~500ms ~10ms ~10ms
Per-MCP isolation Yes Yes Yes
Need a daemon Yes (Docker) No No
Cross-platform dev macOS works Linux only Linux only
Existing pattern in Trinity Already used at agent layer None None
Operational complexity High (image lifecycle) Low Medium
Granularity Container-level Syscall-level Syscall-level
Battle-tested by Cloud Run, Lambda Flatpak (~100M+ installs), Steam Less mainstream
Modern default ✅ (Flatpak migrated from firejail to bwrap)

Trinity already uses Docker for the agent boundary; adding more Docker for sub-MCPs is operational overhead with diminishing returns. Per-process startup matters when an agent has 5+ MCP servers — Docker-in-Docker would add ~3 seconds to agent startup. bwrap adds ~10ms each.

Concrete shape

agent container (one Docker container, as today)
├── claude-code (PID 1, holds OAuth token in env or socket)
└── For each MCP server defined in .mcp.json:
    bwrap \
      --unshare-pid \
      --unshare-net (egress allowlist via slirp4netns or namespace policy) \
      --tmpfs /tmp \
      --ro-bind /usr /usr \
      --ro-bind /lib /lib \
      --ro-bind /home/developer/.npm /home/developer/.npm \
      --setenv ${this MCP's declared env only} \
      --cap-drop ALL \
      --new-session \
      -- npx mcp-server-X

Each MCP gets:

  • Own PID namespace → cannot read /proc/1/environ (sees only its own PID)
  • Own network namespace with egress restricted to declared URL (or localhost for stdio handshake)
  • Own mount namespace → cannot read /home/developer/.env or other MCPs' files
  • Own env block → only the env values its .mcp.json entry declared (no leak of other servers' creds)
  • Capabilities dropped → can't ptrace, can't override DAC, can't bind privileged ports
  • No new privileges flag → suid binaries don't elevate

Three independent sub-fixes (can ship separately)

3a. OAuth token isolation

Move CLAUDE_CODE_OAUTH_TOKEN and TRINITY_MCP_API_KEY out of /proc/1/environ:

  • Option A: file at /run/secrets/anthropic-token readable only by Claude Code's UID with restrictive permissions
  • Option B: local Unix socket where Claude Code requests the token at startup; child MCPs spawn without it in env

Once these are out of process env, an MCP that does cat /proc/1/environ gets nothing useful. Smallest blast-radius reduction even without sandboxing.

3b. Per-MCP bubblewrap sandbox (the main work)

  • Add bubblewrap to trinity-agent-base Dockerfile (apt-get install bubblewrap)
  • Wrap each command in the rendered .mcp.json — the validator (services.mcp_validator) becomes the place that emits the bwrap wrapper around each entry's command/args before writing to disk
  • Build per-runtime profiles (npx-profile, uvx-profile, python-profile, etc.) — each declares the FS bind mounts and seccomp filter for that runtime's known needs
  • Spike: how does Claude Code's MCP stdio handshake survive the PID namespace? May need --share-pid for stdio transport (still gets net + mount + env isolation)

3c. Container capability tightening

The current agent container has cap_dac_override, cap_sys_ptrace, etc. (flagged in #590 tech notes). Independent of MCP sandboxing, dropping these narrows what any RCE inside the container can do — cap_sys_ptrace for example lets a process read another process's memory including Claude Code's own.

Trigger conditions (when to prioritize)

Today the actual risk is "owner installs malicious npm package, exfils their own credentials." That's a Bad Day but not a platform compromise — blast radius = one tenant's own data + their own token. Layer 3 becomes urgent when one of these becomes true:

  1. Non-owners can add MCP servers (today, owner-only). Owner-only means the threat model is "owner runs malicious package by mistake or deliberately on their own agent"
  2. Trinity supports third-party MCP marketplaces (npm @some-vendor/mcp-server from anyone) — currently you're trusting the publisher of every npm/uvx package an owner installs
  3. Non-owner data lives in agent containers (e.g., shared agents where the workspace contains files belonging to a different user)
  4. A pentest finds a way to escalate from MCP RCE to host RCE via the cap_dac_override / cap_sys_ptrace surface
  5. Trinity moves to a multi-tenant hosted offering where tenants share infrastructure

Acceptance Criteria (when this is scheduled)

Phase 1 — OAuth token isolation (3a)

  • CLAUDE_CODE_OAUTH_TOKEN no longer present in /proc/1/environ
  • TRINITY_MCP_API_KEY likewise
  • Existing Claude Code + Trinity MCP flows still work (test against running agent)
  • Test: malicious MCP attempts cat /proc/1/environ → token absent

Phase 2 — Bubblewrap sandbox (3b)

  • bubblewrap in trinity-agent-base image
  • Per-runtime sandbox profiles for npx, uvx, python/python3, node, bun, deno, docker
  • Validator-emitted wrapper in .mcp.json rendering pipeline
  • Each MCP isolated: own PID namespace, own net namespace with egress allowlist, own mount namespace, own env block, dropped caps
  • Common MCP servers tested (context7, playwright, google-workspace, github, slack)
  • "Sandbox disabled" escape hatch for power users (per-server flag, audit-logged)
  • Documentation: how to debug a sandbox-broken MCP, how to extend a profile

Phase 3 — Capability tightening (3c)

  • cap_dac_override dropped from agent container baseline
  • cap_sys_ptrace dropped from agent container baseline
  • Other unnecessary caps audited and dropped (cap_sys_admin if present, etc.)
  • Existing functionality verified (file ops, MCP servers, terminal)

Effort estimate

  • 3a (OAuth token isolation): ~3-5 days. Depends on whether Claude Code allows reading the token from a file rather than env (may need vendor cooperation)
  • 3b (bubblewrap sandbox): ~2-3 weeks. Most of the time is profile tuning and breakage debugging across the common MCP server zoo
  • 3c (cap tightening): ~2-3 days. Mechanical change with regression testing

Total: ~3-4 weeks for the full Layer 3 rollout.

What this PR does NOT propose

  • Switching the agent boundary itself away from Docker (Trinity's current model is fine — this is per-MCP sub-isolation INSIDE the agent container)
  • Replacing the runtime allowlist (npx, uvx, etc.) — Layer 2 keeps that as the first gate
  • Curated MCP server registry (would solve the "evil package" problem differently — by approving packages — but adds operational burden of curation; sandboxing leaves the choice with the owner while limiting blast)

References

Out of scope

  • WebAssembly-based MCP runtime (more isolation but huge ecosystem migration cost — separate question)
  • gVisor as alternative sandbox (heavier than bwrap, designed for untrusted multi-tenant workloads — overkill for owner-installed servers)
  • Network policy at the cluster/host level (Trinity is single-host today; revisit at scale-out)

Metadata

Metadata

Assignees

No one assigned

    Labels

    complexity-highComplexity: high (board points 13)priority-p2Importantstatus-incubatingIdea under consideration — pre-Todo, not yet greenlit for developmenttheme-securityTheme: Securitytype-featureNew functionality

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions