Documentation
¶
Overview ¶
Package sandbox is a spike (MCP-3232) proving an *unprivileged*, non-Docker confinement primitive for stdio MCP servers on hosts where Docker is unavailable or broken — notably Ubuntu 24.04 with snap-installed Docker under AppArmor, where the deb's systemd hardening fights snap-confine (see internal repo issue #457 / cmd/mcpproxy/doctor_env_snapdocker.go).
The mechanism chosen by the spike is the Linux Landlock LSM (kernel 5.13+). Unlike user-namespace / bubblewrap sandboxes, Landlock does NOT require unprivileged user namespaces and is therefore NOT blocked by `kernel.apparmor_restrict_unprivileged_userns=1`, which Ubuntu 23.10+/24.04 enable by default. See docs/development/sandbox-spike-mcp-34.md for the full recommendation and the honest limits (no uid/gid separation without privilege, filesystem-allowlist + rlimits only).
This package is intentionally minimal: it confines the *current thread* and, because a Landlock domain is inherited across execve, every process that thread then execs (the npx/uvx server and its descendants).
The per-thread scope is load-bearing, not a footnote: landlock_restrict_self(2) commits the new credentials on the calling thread only. A multithreaded Go process therefore CANNOT be confined by calling Apply in-process — the other threads keep full filesystem access, and even the calling goroutine may be rescheduled onto an unrestricted thread. The only sound shape is the one this package implements: a tiny re-exec wrapper that calls Apply and immediately execs the untrusted command from the very thread Apply restricted. Apply pins that thread (runtime.LockOSThread) and RunChild verifies the thread identity again just before execve; the package test exercises exactly that shape.
Index ¶
Constants ¶
const ( // Subcommand is the hidden mcpproxy subcommand that runs the re-exec child. Subcommand = "__sandbox_exec" // EnvSpec carries the JSON-encoded Spec from the parent to the re-exec child. EnvSpec = "MCPPROXY_SANDBOX_SPEC" )
Re-exec wrapper protocol (MCP-34.3).
Landlock confines the *calling thread* and every process that thread then execs, and the confinement is irreversible — so it cannot be applied in-process before mcp-go spawns an upstream stdio server (mcpproxy is multithreaded; the other threads would stay unrestricted). The integration is therefore a tiny re-exec wrapper: mcpproxy launches itself as
mcpproxy __sandbox_exec -- <command> [args...]
with the desired confinement encoded in the environment. The child decodes the Spec, calls Apply, then execs <command>, replacing its own image so the untrusted server inherits the locked-down Landlock domain and the server's stdin/stdout/stderr pass straight through with no intervening mux.
Variables ¶
var ErrUnsupported = errors.New("sandbox: confinement primitive unavailable on this platform/kernel")
ErrUnsupported is returned by Apply on platforms or kernels that do not provide the requested confinement primitive (e.g. non-Linux, or a kernel without Landlock). Callers that want fail-open behaviour set Spec.BestEffort.
Functions ¶
func Available ¶ added in v0.47.0
func Available() bool
Available reports whether the native sandbox primitive (Landlock) can be enforced on this kernel right now. It lets callers log an honest diagnostic ("sandbox requested but kernel lacks Landlock; running degraded") and lets tests skip enforcement assertions on kernels without Landlock.
func RunChild ¶ added in v0.47.0
RunChild is the entrypoint for the hidden `__sandbox_exec` subcommand. argv is the command line after the `--` separator: argv[0] is the program to run and argv[1:] are its arguments. It decodes the Spec from EnvSpec, applies the confinement, performs a best-effort uid/gid drop, then execs the target, replacing this process image — so the untrusted server inherits the locked Landlock domain and its stdin/stdout/stderr stay wired straight through to the parent with no mux.
On success it never returns (execve replaces the image). It returns a non-zero exit code on any failure; diag receives human-readable confinement notes and errors (os.Stderr in production, so they land in the per-server upstream log). Exit codes: 2 bad invocation or missing spec, 3 confinement unavailable while fail-closed, 4 the Landlock domain's thread was lost before execve, 126 execve failed, 127 target not found on PATH.
func WrapCommand ¶ added in v0.47.0
func WrapCommand(self string, spec Spec, command string, args []string) (wrappedCommand string, wrappedArgs []string, extraEnv []string, err error)
WrapCommand builds the argv and extra environment needed to launch command/args confined by spec, by re-executing self (the absolute path to the running mcpproxy binary) as the sandbox child. The returned extraEnv entries must be appended to the child process's environment so SpecFromEnv can decode the Spec on the other side.
It performs no syscalls and is safe to call on every platform; whether the confinement actually takes effect is decided later by Apply inside the child (a no-op on non-Linux / Landlock-less kernels).
Types ¶
type Report ¶
type Report struct {
// LandlockABI is the kernel's reported Landlock ABI version that was
// enforced (>=1), 0 if Landlock was not requested, or -1 if Landlock is
// unavailable on this kernel/platform.
LandlockABI int
// LandlockNote is a human-readable note (e.g. why Landlock was skipped, or
// which paths were missing).
LandlockNote string
// RlimitsSet is the number of rlimits successfully applied.
RlimitsSet int
// NoNewPrivs reports whether PR_SET_NO_NEW_PRIVS was set (always true when
// Landlock is enforced; Landlock requires it).
NoNewPrivs bool
// LandlockTID is the OS thread id (Linux tid) the Landlock domain was
// committed on, or 0 when no domain was enforced. A caller about to execve
// must confirm it is still running on this thread: an execve from any other
// thread runs the target unconfined.
LandlockTID int
}
Report records what Apply actually enforced, so callers can log an honest account of the confinement (important because Apply is best-effort across kernels with different Landlock ABI levels).
func Apply ¶
Apply confines the calling THREAD per spec (rlimits, being per-process, apply process-wide). On success that thread — and every process it subsequently execs — can only touch the filesystem subtrees in the allowlist. The restriction is irreversible for the lifetime of the thread.
Apply cannot confine a multithreaded Go process: landlock_restrict_self(2) commits credentials on the calling thread alone, so every other thread stays unrestricted. The only sound caller is a short-lived re-exec wrapper that calls Apply and immediately execs the untrusted command from this same thread — see RunChild, which re-checks the thread identity before execve.
type Rlimit ¶
Rlimit is a single setrlimit request. Resource is one of the unix.RLIMIT_* constants (e.g. RLIMIT_AS, RLIMIT_NOFILE, RLIMIT_NPROC, RLIMIT_CPU).
type Spec ¶
type Spec struct {
// ReadOnlyPaths are filesystem subtrees the confined process may read and
// execute. Anything not covered by a ReadOnlyPaths/ReadWritePaths entry is
// denied. Missing paths are skipped (best-effort) and noted in the Report.
ReadOnlyPaths []string
// ReadWritePaths are subtrees the confined process may read, execute, and
// write (create/delete/truncate within).
ReadWritePaths []string
// Rlimits are resource limits applied via setrlimit before confinement.
Rlimits []Rlimit
// BestEffort, when true, downgrades "primitive unavailable" from an error
// to a no-op recorded in Report.LandlockNote, mirroring go-landlock's
// BestEffort semantics. When false (the default, fail-closed), Apply
// returns ErrUnsupported if Landlock cannot be enforced — a security
// boundary should fail closed rather than silently run unconfined.
BestEffort bool
}
Spec describes the confinement to apply to the current process before it execs an untrusted stdio MCP server. The zero value applies no filesystem restriction (only rlimits, if any).
func SpecFromEnv ¶ added in v0.47.0
SpecFromEnv decodes the Spec the parent encoded into EnvSpec. ok is false when the variable is absent — i.e. the process was not launched as a sandbox child.