Status: Draft / Implementation
1. Overview
This document outlines the architecture for safely executing untrusted or semi-trusted MCP (Model Context Protocol) servers. The system uses a "Defense in Depth" strategy, combining OS-level User Separation (for network control) with Bubblewrap Sandboxing (for filesystem control).
The core design philosophy is Privilege Separation: The massive, complex Chat Server runs unprivileged, while a tiny, audited SUID Helper manages the transition to secure sandboxes.
2. The Architecture Stack
The execution chain consists of four distinct stages:
- The Controller (Chat Server):
- Role: Logic & Orchestration.
- Privilege: Standard User (
app-user). - Network: Full Access.
- Action: Requests a plugin spawn (e.g., "Start Weather Plugin").
- The Switchboard (SUID Launcher):
- Role: Security Gateway.
- Privilege: Root (via SUID bit), efficiently drops to Target User.
- Action: Maps the "Profile" (e.g.,
external) to a System UID (e.g.,999) and maps the specific.solibrary to a generic path.
- The Jail (Bubblewrap):
- Role: Filesystem & Namespace Isolation.
- Privilege: Target User (e.g.,
mcp-external). - Action: Constructs a temporary, empty root filesystem. Binds only necessary libraries and the generic plugin.
- The Worker (Generic Loader):
- Role: Execution.
- Privilege: Target User (Sandboxed).
- Action:
dlopen("/app/plugin.so")and connects via Stdio.
3. Security Layers
Layer A: Network Isolation (IPTables + User ID)
We do not use complex container networking. We use standard Linux kernel filtering based on the Process Owner (UID).
-
Profiles:
-
disconnected(UID: 65534 / nobody): Usesbwrap --unshare-net. Physically impossible to send packets. -
local(UID: 998 / mcp-local): Allowed to talk to Docker Subnets & Localhost. Blocked from WAN. -
external(UID: 999 / mcp-external): Blocked from Localhost/LAN. Allowed to talk to WAN. -
Implementation: Static
iptablesrules loaded at boot.
# Example: Block External Profile from Localhost
iptables -A OUTPUT -m owner --uid-owner mcp-external -d 127.0.0.0/8 -j REJECT
Layer B: Filesystem Isolation (Bubblewrap)
The worker process sees a constructed reality. It does not see /home, /var, or /etc.
-
Composition:
-
/usr,/lib: Read-only (from host). -
/tmp: Tmpfs (vanishes on exit). -
/app/plugin.so: Read-only bind mount of the specific host library (e.g.,/opt/plugins/weather.so). -
Benefit: A compromised "Weather" plugin cannot physically load or read the "Finance" plugin, as the file does not exist in its namespace.
Layer C: The Bridge (Unix Sockets)
For "Local" profiles needing database access, we avoid TCP. We map the Unix Domain Socket file into the sandbox.
- Host Path:
/var/run/postgresql/.s.PGSQL.5432 - Sandbox Path:
/run/postgresql/.s.PGSQL.5432 - Result: Granular access to Postgres only, with no network stack required.
4. Implementation Guide
A. System Setup
Create the dedicated "Profile Users" without login shells.
sudo useradd -r -s /bin/false mcp-local # UID ~998
sudo useradd -r -s /bin/false mcp-external # UID ~999
B. The Rust SUID Launcher
The launcher is a small Rust binary owned by root with the SetUID bit (chmod u+s launcher).
Key Responsibilities:
- Sanitization: Clear
ENVvariables. - Mapping: Resolve
profile_name->target_uid. - Privilege Drop: Use
pre_execto switch identities before execution.
Pseudo-Code Logic:
use std::process::Command;
use std::os::unix::process::CommandExt; // for pre_exec
fn spawn(profile: &str, plugin_path: &str) {
// 1. Determine Target Identity
let (uid, gid, net_flag) = match profile {
"external" => (999, 999, "--share-net"),
"disconnected" => (65534, 65534, "--unshare-net"),
_ => panic!("Invalid profile"),
};
let mut cmd = Command::new("bwrap");
// 2. Configure Sandbox
cmd.arg(net_flag)
.arg("--ro-bind").arg("/usr").arg("/usr")
// ... other base mounts ...
// 3. Inject the specific plugin as the generic alias
.arg("--bind").arg(plugin_path).arg("/app/plugin.so")
.arg("/bin/generic-loader");
// 4. The Critical Privilege Drop
unsafe {
cmd.pre_exec(move || {
// Drop Group first, then User
libc::setgid(gid);
libc::setuid(uid);
Ok(())
});
}
cmd.spawn().expect("Launch failed");
}
C. The Generic Loader
A dumb executable that expects exactly one file to exist.
fn main() {
// 1. Load the "Phantom" Library
let lib = unsafe { libloading::Library::new("/app/plugin.so") };
// 2. Find the entry point
let entry: Symbol<fn()> = unsafe { lib.get(b"mcp_main") };
// 3. Hand over control
entry();
}
5. Lifecycle Summary
- Request: Chat Server calls
./launcher --profile=external --plugin=weather. - Elevation: Launcher starts as
app-userbut runs with Effective UIDroot. - Setup: Launcher calculates paths and arguments.
- Drop: Launcher calls
fork(). Child callssetuid(999). - Isolation: Child
execsbwrap. Namespace is scrubbed. - Binding:
bwrapbinds/opt/plugins/weather.soto/app/plugin.so. - Execution:
bwrapexecsgeneric-loader. - Runtime: Loader runs as UID 999. IPTables enforces "External Only" traffic rules.