You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
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.
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)
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:
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"
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
Non-owner data lives in agent containers (e.g., shared agents where the workspace contains files belonging to a different user)
A pentest finds a way to escalate from MCP RCE to host RCE via the cap_dac_override / cap_sys_ptrace surface
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)
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)
Summary
Layers 1 (#590) and 2 (#598) close the shell-injection class of attack on
.mcp.jsonediting. They do not stop a malicious npm/uvx package once installed:{"mcpServers": {"weather": {"command": "npx", "args": ["-y", "evil-weather-mcp"]}}}evil-weather-mcppasses every Layer 2 check (npx is allowlisted, args are clean, no SSRF in URL), and oncenpxfetches 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:cat /proc/1/environ→ exfilCLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_API_KEY,TRINITY_MCP_API_KEYGOOGLE_TOKEN,OPENAI_API_KEY)/home/developer/.envattacker.comto exfil what it found/home/developer/content/*(any user data)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:
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
Each MCP gets:
/proc/1/environ(sees only its own PID)/home/developer/.envor other MCPs' files.mcp.jsonentry declared (no leak of other servers' creds)Three independent sub-fixes (can ship separately)
3a. OAuth token isolation
Move
CLAUDE_CODE_OAUTH_TOKENandTRINITY_MCP_API_KEYout of/proc/1/environ:/run/secrets/anthropic-tokenreadable only by Claude Code's UID with restrictive permissionsOnce these are out of process env, an MCP that does
cat /proc/1/environgets nothing useful. Smallest blast-radius reduction even without sandboxing.3b. Per-MCP bubblewrap sandbox (the main work)
bubblewraptotrinity-agent-baseDockerfile (apt-get install bubblewrap)commandin 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--share-pidfor 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_ptracefor 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:
@some-vendor/mcp-serverfrom anyone) — currently you're trusting the publisher of every npm/uvx package an owner installsAcceptance Criteria (when this is scheduled)
Phase 1 — OAuth token isolation (3a)
CLAUDE_CODE_OAUTH_TOKENno longer present in/proc/1/environTRINITY_MCP_API_KEYlikewisecat /proc/1/environ→ token absentPhase 2 — Bubblewrap sandbox (3b)
bubblewrapintrinity-agent-baseimage.mcp.jsonrendering pipelinePhase 3 — Capability tightening (3c)
cap_dac_overridedropped from agent container baselinecap_sys_ptracedropped from agent container baselinecap_sys_adminif present, etc.)Effort estimate
Total: ~3-4 weeks for the full Layer 3 rollout.
What this PR does NOT propose
npx,uvx, etc.) — Layer 2 keeps that as the first gateReferences
docker/base-image/Dockerfile(where bubblewrap install would land)services/mcp_validator.py(where wrapper-emission would land)Out of scope