From d72b89829920e7433ab85752c5181432223608d4 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 25 Jun 2026 06:30:28 +0300 Subject: [PATCH 1/2] feat(upstream): native sandbox launcher (Landlock + rlimits) for stdio servers (MCP-34.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the `sandbox` spawn branch alongside docker/plain in connectStdio and buildLauncherCmd, using the Landlock mechanism proven by the MCP-34.1 spike and wired into the mode-aware selector from MCP-34.2. Mechanism: a tiny re-exec wrapper. mcpproxy launches itself as `mcpproxy __sandbox_exec -- [args...]` with the confinement Spec encoded in the environment; the hidden child applies Landlock + setrlimit, does a best-effort uid/gid drop, then execs the real command — so confinement is inherited across execve and the server's stdin/stdout pass straight through with no mux. Reuses the existing SysProcAttr{Setpgid} process-group cleanup. - internal/sandbox: WrapCommand/SpecFromEnv (cross-platform), RunChild (unix + non-unix guard), Available() honest-diagnostic helper. - internal/upstream/core: wrapWithSandbox + buildSandboxSpec (filesystem WRITE allowlist: RO "/", RW working_dir + temp + package caches; RLIMIT_CORE=0 + NOFILE; BestEffort graceful degrade). Wired into both stdio + launcher plain branches behind ResolveMode == sandbox. - cmd/mcpproxy: hidden __sandbox_exec subcommand. - Graceful fallback: non-Linux / Landlock-less kernels degrade to unconfined (effective "none") with a logged diagnostic; fail-closed when BestEffort=false. - docs/features/sandbox-isolation.md documents enforcement, honest limits (no read confinement, uid/gid no-op without root), and platform support. Tests (red→green): WrapCommand argv/env round-trip, SpecFromEnv absent/malformed, buildSandboxSpec write-allowlist defaults; Linux end-to-end (cmd construction, RLIMIT_NOFILE application, write-outside-allowlist denied, stdin/stdout passthrough) + fail-closed refuses to run unconfined. Landlock enforcement runs on ubuntu-latest CI (24.04), mirroring the MCP-34.1 spike's empirical proof. Co-Authored-By: Paperclip --- cmd/mcpproxy/main.go | 1 + cmd/mcpproxy/sandbox_exec_cmd.go | 34 +++++ docs/features/sandbox-isolation.md | 69 +++++++++ internal/sandbox/runchild_other.go | 20 +++ internal/sandbox/runchild_unix.go | 112 +++++++++++++++ internal/sandbox/sandbox_linux.go | 9 ++ internal/sandbox/sandbox_other.go | 4 + internal/sandbox/wrap.go | 62 +++++++++ internal/sandbox/wrap_test.go | 67 +++++++++ internal/upstream/core/connection_launcher.go | 9 ++ internal/upstream/core/connection_stdio.go | 11 ++ internal/upstream/core/sandbox.go | 95 +++++++++++++ internal/upstream/core/sandbox_linux_test.go | 131 ++++++++++++++++++ internal/upstream/core/sandbox_main_test.go | 27 ++++ .../upstream/core/sandbox_rlimits_linux.go | 22 +++ .../upstream/core/sandbox_rlimits_other.go | 9 ++ internal/upstream/core/sandbox_test.go | 50 +++++++ 17 files changed, 732 insertions(+) create mode 100644 cmd/mcpproxy/sandbox_exec_cmd.go create mode 100644 docs/features/sandbox-isolation.md create mode 100644 internal/sandbox/runchild_other.go create mode 100644 internal/sandbox/runchild_unix.go create mode 100644 internal/sandbox/wrap.go create mode 100644 internal/sandbox/wrap_test.go create mode 100644 internal/upstream/core/sandbox.go create mode 100644 internal/upstream/core/sandbox_linux_test.go create mode 100644 internal/upstream/core/sandbox_main_test.go create mode 100644 internal/upstream/core/sandbox_rlimits_linux.go create mode 100644 internal/upstream/core/sandbox_rlimits_other.go create mode 100644 internal/upstream/core/sandbox_test.go diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 6aa29229c..1ef8859b9 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -190,6 +190,7 @@ func main() { // Add commands to root rootCmd.AddCommand(serverCmd) + rootCmd.AddCommand(newSandboxExecCommand()) rootCmd.AddCommand(searchCmd) rootCmd.AddCommand(GetRegistryCommand()) rootCmd.AddCommand(toolsCmd) diff --git a/cmd/mcpproxy/sandbox_exec_cmd.go b/cmd/mcpproxy/sandbox_exec_cmd.go new file mode 100644 index 000000000..39e7d2ec3 --- /dev/null +++ b/cmd/mcpproxy/sandbox_exec_cmd.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/sandbox" +) + +// newSandboxExecCommand returns the hidden `__sandbox_exec` subcommand (MCP-34.3). +// +// mcpproxy re-executes itself as `mcpproxy __sandbox_exec -- [args...]` +// to launch a stdio MCP server under native sandbox isolation: the child applies +// Landlock + rlimits confinement (encoded in the environment by the upstream +// launcher) and then execs the real command, replacing this process so stdin/ +// stdout pass straight through. It is not meant to be invoked by users directly. +func newSandboxExecCommand() *cobra.Command { + return &cobra.Command{ + Use: sandbox.Subcommand + " -- command [args...]", + Short: "internal: re-exec wrapper applying native sandbox confinement (do not call directly)", + Hidden: true, + // Pass everything after the subcommand through untouched — the wrapped + // command has its own flags we must not interpret. + DisableFlagParsing: true, + Run: func(_ *cobra.Command, args []string) { + // cobra may retain the "--" separator; drop a single leading one. + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + os.Exit(sandbox.RunChild(args, os.Stderr)) + }, + } +} diff --git a/docs/features/sandbox-isolation.md b/docs/features/sandbox-isolation.md new file mode 100644 index 000000000..2c6fd06ed --- /dev/null +++ b/docs/features/sandbox-isolation.md @@ -0,0 +1,69 @@ +# Native Sandbox Isolation (Linux, no Docker) + +MCPProxy can isolate a stdio MCP server **without Docker** using the Linux +[Landlock LSM](https://docs.kernel.org/userspace-api/landlock.html) plus resource +limits (`setrlimit`). This is the `sandbox` isolation mode (MCP-34), built for +hosts where Docker is unavailable or broken — notably Ubuntu 24.04 with +snap-installed Docker under AppArmor. Unlike bubblewrap / user-namespace +sandboxes, Landlock does **not** require unprivileged user namespaces, so it is +not blocked by `kernel.apparmor_restrict_unprivileged_userns=1` (default on +Ubuntu 23.10+). + +## Enabling + +Set the isolation **mode** to `sandbox`, globally or per server: + +```json +{ + "docker_isolation": { "mode": "sandbox" }, + "mcpServers": [ + { "name": "obsidian", "command": "uvx", "args": ["obsidian-mcp"], + "isolation": { "mode": "sandbox" } } + ] +} +``` + +A per-server `isolation.mode` wins over the global mode. The legacy +`docker_isolation.enabled` boolean still maps to `docker`/`none` for +back-compat; `mode` supersedes it (see MCP-34.2). + +## What it enforces + +- **Filesystem write allowlist.** Reads stay broad (the runtime can load + interpreters, `node_modules`, and `site-packages` from anywhere), but **writes** + are denied outside a small allowlist: the server's `working_dir`, the OS temp + dir, and the common package caches (`~/.npm`, `~/.cache`, `~/.local/share`). + Tightening reads is deferred — a read allowlist breaks tool discovery. +- **Resource limits.** Core dumps are disabled (`RLIMIT_CORE=0`, so in-memory + secrets can't spill to disk) and the descriptor table is capped + (`RLIMIT_NOFILE`). +- **Process-group cleanup.** Reuses the existing `Setpgid` group teardown, so a + sandboxed server and its children are killed together on disconnect. + +Confinement is applied by a tiny re-exec wrapper (`mcpproxy __sandbox_exec`) that +calls Landlock/`setrlimit` and then `exec`s the real command — so the server's +stdin/stdout pass straight through with no intervening multiplexer. + +## Honest limits + +- **No uid/gid separation by default.** The wrapper performs a *best-effort* + privilege drop only when it is running as root with a non-root real user. In + the personal edition mcpproxy runs as your user (not root), so this is a + documented no-op — real uid/gid separation needs root or `CAP_SETUID`. +- **Reads are not confined** (write-allowlist only; see above). +- **Graceful degrade.** If the kernel lacks Landlock (pre-5.13, or LSM disabled), + the server still starts but runs **unconfined**, and the host log records a + `DEGRADED/unconfined` diagnostic. This favors availability; use `docker` mode + when you need a hard guarantee. + +## Platform support + +| Platform | `mode: sandbox` behavior | +|----------|--------------------------| +| **Linux** (kernel 5.13+ with Landlock) | Enforced: write-allowlist + rlimits | +| **Linux** (no Landlock) | Degraded → runs unconfined, logged | +| **macOS / Windows** | Documented **no-op** → effective `none` (Landlock is Linux-only) | + +See also: [docs/docker-isolation.md](../docker-isolation.md) for the Docker mode, +and [docs/development/sandbox-spike-mcp-34.md](../development/sandbox-spike-mcp-34.md) +for the mechanism spike. diff --git a/internal/sandbox/runchild_other.go b/internal/sandbox/runchild_other.go new file mode 100644 index 000000000..3c082f871 --- /dev/null +++ b/internal/sandbox/runchild_other.go @@ -0,0 +1,20 @@ +//go:build !unix + +package sandbox + +import ( + "fmt" + "io" +) + +// RunChild is unsupported on non-unix platforms: there is no execve to replace +// the wrapper image. mcpproxy never builds the sandbox re-exec wrapper on these +// platforms (the launcher resolves sandbox mode to a documented no-op), so this +// only guards a misconfigured invocation. +func RunChild(_ []string, diag io.Writer) int { + if diag == nil { + diag = io.Discard + } + fmt.Fprintln(diag, "sandbox: re-exec wrapper unsupported on this platform") + return 2 +} diff --git a/internal/sandbox/runchild_unix.go b/internal/sandbox/runchild_unix.go new file mode 100644 index 000000000..5e98b9fdd --- /dev/null +++ b/internal/sandbox/runchild_unix.go @@ -0,0 +1,112 @@ +//go:build unix + +package sandbox + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +// 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). +func RunChild(argv []string, diag io.Writer) int { + if diag == nil { + diag = io.Discard + } + if len(argv) == 0 { + fmt.Fprintln(diag, "sandbox: no command to exec") + return 2 + } + + spec, ok, err := SpecFromEnv() + if err != nil { + fmt.Fprintln(diag, err) + return 2 + } + if !ok { + // The wrapper must never run a command unconfined just because the spec + // went missing — that would silently defeat the isolation request. + fmt.Fprintln(diag, "sandbox: missing spec env; refusing to run unconfined") + return 2 + } + + // Resolve the target before confinement so a bare command name can be looked + // up on PATH while the filesystem is still fully visible. + target := argv[0] + if filepath.Base(target) == target { + resolved, lerr := exec.LookPath(target) + if lerr != nil { + fmt.Fprintf(diag, "sandbox: lookup %q: %v\n", target, lerr) + return 127 + } + target = resolved + } + + rep, err := Apply(spec) + if err != nil { + // fail-closed: BestEffort was false and the primitive is unavailable. + fmt.Fprintf(diag, "sandbox: confinement unavailable and fail-closed: %v\n", err) + return 3 + } + fmt.Fprintf(diag, "sandbox: %s\n", describeReport(rep)) + + dropPrivilegesBestEffort(diag) + + if err := syscall.Exec(target, argv, os.Environ()); err != nil { + fmt.Fprintf(diag, "sandbox: exec %q: %v\n", target, err) + return 126 + } + return 0 // unreachable: Exec replaced the image on success. +} + +// describeReport renders a one-line honest summary of what Apply enforced. +func describeReport(rep Report) string { + switch { + case rep.LandlockABI >= 1: + s := fmt.Sprintf("Landlock enforced (ABI %d), %d rlimit(s) set", rep.LandlockABI, rep.RlimitsSet) + if rep.LandlockNote != "" { + s += "; " + rep.LandlockNote + } + return s + case rep.LandlockABI < 0: + return fmt.Sprintf("running DEGRADED/unconfined — %s (%d rlimit(s) set)", rep.LandlockNote, rep.RlimitsSet) + default: + return fmt.Sprintf("%s (%d rlimit(s) set)", rep.LandlockNote, rep.RlimitsSet) + } +} + +// dropPrivilegesBestEffort drops to the real uid/gid when the wrapper is running +// as root with a non-root real user (e.g. a setuid/elevated launch). This is a +// best-effort defense-in-depth step: in the personal edition mcpproxy runs as +// the user, not root, so this is a documented no-op. A real, unconditional +// privilege drop requires root/CAP_SETUID and an explicit target uid/gid, which +// is out of scope here. +func dropPrivilegesBestEffort(diag io.Writer) { + euid, ruid := os.Geteuid(), os.Getuid() + if euid != 0 || ruid == 0 { + return // not privileged, or already the real user — nothing to drop. + } + rgid := os.Getgid() + if err := syscall.Setgid(rgid); err != nil { + fmt.Fprintf(diag, "sandbox: best-effort setgid(%d) failed: %v\n", rgid, err) + return + } + if err := syscall.Setuid(ruid); err != nil { + fmt.Fprintf(diag, "sandbox: best-effort setuid(%d) failed: %v\n", ruid, err) + return + } + fmt.Fprintf(diag, "sandbox: dropped privileges to uid=%d gid=%d\n", ruid, rgid) +} diff --git a/internal/sandbox/sandbox_linux.go b/internal/sandbox/sandbox_linux.go index c4d1d1272..897fa5ff6 100644 --- a/internal/sandbox/sandbox_linux.go +++ b/internal/sandbox/sandbox_linux.go @@ -54,6 +54,15 @@ func handledAccessFS(abi int) uint64 { return h } +// 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 Available() bool { + abi, err := landlockABI() + return err == nil && abi >= 1 +} + // Apply confines the current process per spec. On success the calling process // — and every process it subsequently execs — can only touch the filesystem // subtrees in the allowlist, under the supplied rlimits. The restriction is diff --git a/internal/sandbox/sandbox_other.go b/internal/sandbox/sandbox_other.go index 24b50613d..8865040cd 100644 --- a/internal/sandbox/sandbox_other.go +++ b/internal/sandbox/sandbox_other.go @@ -10,6 +10,10 @@ package sandbox // Note: macOS/Windows already have their own first-class sandbox stories // (Seatbelt / Docker Desktop / Windows containers); this package targets the // Linux snap-docker gap specifically (see package doc). +// Available reports whether the native sandbox primitive can be enforced. It is +// always false off Linux: Landlock is a Linux-only LSM. +func Available() bool { return false } + func Apply(spec Spec) (Report, error) { // No filesystem allowlist requested → nothing to enforce, same as Linux. if !spec.wantsLandlock() { diff --git a/internal/sandbox/wrap.go b/internal/sandbox/wrap.go new file mode 100644 index 000000000..b99f9e20a --- /dev/null +++ b/internal/sandbox/wrap.go @@ -0,0 +1,62 @@ +package sandbox + +import ( + "encoding/json" + "fmt" + "os" +) + +// Re-exec wrapper protocol (MCP-34.3). +// +// Landlock confines the *current* process and every process it then execs, and +// the confinement is irreversible — so it cannot be applied in-process before +// mcp-go spawns an upstream stdio server. The integration is therefore a tiny +// re-exec wrapper: mcpproxy launches itself as +// +// mcpproxy __sandbox_exec -- [args...] +// +// with the desired confinement encoded in the environment. The child decodes the +// Spec, calls Apply, then execs , 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. +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" +) + +// 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). +func WrapCommand(self string, spec Spec, command string, args []string) (wrappedCommand string, wrappedArgs []string, extraEnv []string, err error) { + enc, err := json.Marshal(spec) + if err != nil { + return "", nil, nil, fmt.Errorf("sandbox: encode spec: %w", err) + } + wrappedArgs = make([]string, 0, len(args)+3) + wrappedArgs = append(wrappedArgs, Subcommand, "--", command) + wrappedArgs = append(wrappedArgs, args...) + extraEnv = []string{EnvSpec + "=" + string(enc)} + return self, wrappedArgs, extraEnv, nil +} + +// 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. +func SpecFromEnv() (spec Spec, ok bool, err error) { + raw, present := os.LookupEnv(EnvSpec) + if !present { + return Spec{}, false, nil + } + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + return Spec{}, true, fmt.Errorf("sandbox: decode %s: %w", EnvSpec, err) + } + return spec, true, nil +} diff --git a/internal/sandbox/wrap_test.go b/internal/sandbox/wrap_test.go new file mode 100644 index 000000000..e37d98742 --- /dev/null +++ b/internal/sandbox/wrap_test.go @@ -0,0 +1,67 @@ +package sandbox + +import ( + "os" + "reflect" + "strings" + "testing" +) + +func TestWrapCommand_ArgvAndEnv(t *testing.T) { + spec := Spec{ + ReadOnlyPaths: []string{"/"}, + ReadWritePaths: []string{"/tmp/work"}, + Rlimits: []Rlimit{{Resource: 7, Cur: 64, Max: 64}}, + BestEffort: true, + } + self := "/opt/mcpproxy/mcpproxy" + + cmd, args, env, err := WrapCommand(self, spec, "/bin/zsh", []string{"-l", "-c", "npx foo"}) + if err != nil { + t.Fatalf("WrapCommand: %v", err) + } + if cmd != self { + t.Errorf("wrapped command = %q, want self %q", cmd, self) + } + wantArgs := []string{Subcommand, "--", "/bin/zsh", "-l", "-c", "npx foo"} + if !reflect.DeepEqual(args, wantArgs) { + t.Errorf("wrapped args = %v, want %v", args, wantArgs) + } + if len(env) != 1 || !strings.HasPrefix(env[0], EnvSpec+"=") { + t.Fatalf("extraEnv = %v, want single %s=... entry", env, EnvSpec) + } + + // The spec must round-trip through the env back to an identical Spec, so the + // re-exec child reconstructs exactly what the parent intended. + t.Setenv(EnvSpec, strings.TrimPrefix(env[0], EnvSpec+"=")) + got, ok, err := SpecFromEnv() + if err != nil || !ok { + t.Fatalf("SpecFromEnv: ok=%v err=%v", ok, err) + } + if !reflect.DeepEqual(got, spec) { + t.Errorf("round-tripped spec = %+v, want %+v", got, spec) + } +} + +func TestSpecFromEnv_Absent(t *testing.T) { + // Snapshot + restore so we can prove the truly-absent case. + if orig, had := os.LookupEnv(EnvSpec); had { + t.Cleanup(func() { os.Setenv(EnvSpec, orig) }) + } + os.Unsetenv(EnvSpec) + _, ok, err := SpecFromEnv() + if ok || err != nil { + t.Fatalf("absent env: ok=%v err=%v, want ok=false err=nil", ok, err) + } +} + +func TestSpecFromEnv_Malformed(t *testing.T) { + t.Setenv(EnvSpec, "{not json") + _, ok, err := SpecFromEnv() + if !ok { + t.Errorf("ok = false, want true (var is present even if malformed)") + } + if err == nil { + t.Errorf("err = nil, want decode error for malformed spec") + } +} diff --git a/internal/upstream/core/connection_launcher.go b/internal/upstream/core/connection_launcher.go index efead2d70..f9ce20241 100644 --- a/internal/upstream/core/connection_launcher.go +++ b/internal/upstream/core/connection_launcher.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/launcher" ) @@ -296,6 +297,14 @@ func (c *Client) buildLauncherCmd(_ context.Context, willUseDocker bool) (*exec. } else { // Plain (non-docker) launcher command. Shell-wrap for login-env inheritance. finalCommand, finalArgs = c.wrapWithUserShell(c.config.Command, args) + + // Native sandbox isolation (MCP-34.3): mirror connectStdio — re-exec the + // shell-wrapped command through the sandbox wrapper when mode is "sandbox". + if c.isolationManager != nil && c.isolationManager.ResolveMode(c.config) == config.IsolationModeSandbox { + var extraEnv []string + finalCommand, finalArgs, extraEnv = c.wrapWithSandbox(finalCommand, finalArgs) + envVars = append(envVars, extraEnv...) + } } cmd := exec.Command(finalCommand, finalArgs...) diff --git a/internal/upstream/core/connection_stdio.go b/internal/upstream/core/connection_stdio.go index e5a06de2b..a08eee990 100644 --- a/internal/upstream/core/connection_stdio.go +++ b/internal/upstream/core/connection_stdio.go @@ -207,6 +207,17 @@ func (c *Client) connectStdio(ctx context.Context) error { // .bashrc, .zshrc, etc.). finalCommand, finalArgs = c.wrapWithUserShell(c.config.Command, args) c.isDockerCommand = false + + // Native sandbox isolation (MCP-34.3): when the resolved mode is + // "sandbox", re-exec the shell-wrapped command through the mcpproxy + // sandbox wrapper, which applies Landlock + rlimits before exec. Stdin/ + // stdout passthrough is preserved (no mux). Non-Linux / unavailable + // kernels degrade to unconfined inside wrapWithSandbox. + if c.isolationManager != nil && c.isolationManager.ResolveMode(c.config) == config.IsolationModeSandbox { + var extraEnv []string + finalCommand, finalArgs, extraEnv = c.wrapWithSandbox(finalCommand, finalArgs) + envVars = append(envVars, extraEnv...) + } } // Upstream transport with working directory support and process group management diff --git a/internal/upstream/core/sandbox.go b/internal/upstream/core/sandbox.go new file mode 100644 index 000000000..5402d3ce0 --- /dev/null +++ b/internal/upstream/core/sandbox.go @@ -0,0 +1,95 @@ +package core + +import ( + "os" + "path/filepath" + "runtime" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/sandbox" +) + +// wrapWithSandbox wraps an already-prepared (command, args) — typically the +// login-shell-wrapped stdio command — with the native sandbox re-exec wrapper +// (MCP-34.3). It returns the wrapped command/args plus extra env entries that +// must be appended to the child's environment. +// +// Graceful fallback: when sandboxing is unsupported on this OS, the binary path +// can't be resolved, or encoding fails, it logs a diagnostic and returns the +// inputs unchanged so the server still launches unconfined — a documented +// degrade to "none" rather than a hard failure. On Linux kernels that lack +// Landlock, the wrapper itself still runs but Apply degrades inside the child +// (Spec.BestEffort is set), which is logged from the child's stderr. +func (c *Client) wrapWithSandbox(command string, args []string) (wrappedCommand string, wrappedArgs []string, extraEnv []string) { + if runtime.GOOS != "linux" { + c.logger.Warn("sandbox isolation requested but unsupported on this OS; running unconfined (none)", + zap.String("server", c.config.Name), + zap.String("os", runtime.GOOS)) + return command, args, nil + } + + self, err := os.Executable() + if err != nil { + c.logger.Warn("sandbox isolation requested but executable path unresolved; running unconfined (none)", + zap.String("server", c.config.Name), + zap.Error(err)) + return command, args, nil + } + + spec := buildSandboxSpec(c.config) + wrappedCommand, wrappedArgs, extraEnv, err = sandbox.WrapCommand(self, spec, command, args) + if err != nil { + c.logger.Warn("sandbox wrap failed; running unconfined (none)", + zap.String("server", c.config.Name), + zap.Error(err)) + return command, args, nil + } + + if !sandbox.Available() { + // Wrapper still runs (Spec.BestEffort downgrades inside the child), but + // be honest in the host log that confinement won't actually take effect. + c.logger.Warn("sandbox isolation requested but kernel lacks Landlock; server will run DEGRADED/unconfined", + zap.String("server", c.config.Name)) + } else { + c.logger.Info("sandbox isolation enabled for server (Landlock + rlimits)", + zap.String("server", c.config.Name), + zap.Strings("read_write", spec.ReadWritePaths)) + } + return wrappedCommand, wrappedArgs, extraEnv +} + +// buildSandboxSpec derives the default confinement for a server. +// +// It implements a filesystem WRITE allowlist, which is what MCP-34.3 scopes: +// reads stay broad (read-only "/") so package-manager runtimes can load +// interpreters, node_modules, and site-packages from anywhere on the host, while +// WRITES are denied outside a small allowlist — the server's working directory, +// the OS temp dir, and the common package caches the runtimes need. Tightening +// reads is deliberately deferred: a read allowlist breaks tool discovery and +// belongs to a future per-server explicit allowlist. +// +// BestEffort is set so a kernel without Landlock degrades to unconfined-with- +// diagnostic instead of failing the connection outright. +func buildSandboxSpec(cfg *config.ServerConfig) sandbox.Spec { + rw := []string{os.TempDir()} + if cfg != nil && cfg.WorkingDir != "" { + rw = append(rw, cfg.WorkingDir) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + // Caches the npx/uvx/pip runtimes write to during a cold start. Paths + // that don't exist are skipped best-effort by Apply. + rw = append(rw, + filepath.Join(home, ".npm"), + filepath.Join(home, ".cache"), + filepath.Join(home, ".local", "share"), + ) + } + return sandbox.Spec{ + ReadOnlyPaths: []string{"/"}, + ReadWritePaths: rw, + Rlimits: defaultSandboxRlimits(), + BestEffort: true, + } +} diff --git a/internal/upstream/core/sandbox_linux_test.go b/internal/upstream/core/sandbox_linux_test.go new file mode 100644 index 000000000..7b10ce4c1 --- /dev/null +++ b/internal/upstream/core/sandbox_linux_test.go @@ -0,0 +1,131 @@ +//go:build linux + +package core + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/sandbox" +) + +// TestSandboxWrapper_EndToEnd drives the full launcher integration on a real +// kernel: it builds the wrapped argv with sandbox.WrapCommand (as connectStdio +// does), re-execs this test binary (TestMain dispatches into sandbox.RunChild), +// and asserts the four behaviors MCP-34.3 requires: +// +// 1. cmd construction — the wrapper actually runs and reaches the target; +// 2. rlimit application — RLIMIT_NOFILE is lowered inside the confined child; +// 3. write-allowlist enforcement — a write inside the allowlist succeeds and a +// write OUTSIDE it is denied; +// 4. stdin→stdout passthrough — JSON-RPC framing survives confinement (no mux). +func TestSandboxWrapper_EndToEnd(t *testing.T) { + if !sandbox.Available() { + t.Skip("Landlock unavailable on this kernel (needs 5.13+ with Landlock LSM enabled)") + } + + rw := t.TempDir() + outside := t.TempDir() + + // The confined shell: read system files (RO "/"), write only under rw. + spec := sandbox.Spec{ + ReadOnlyPaths: []string{"/"}, + ReadWritePaths: []string{rw, "/dev"}, // /dev so the shell can open /dev/null + Rlimits: []sandbox.Rlimit{{Resource: unix.RLIMIT_NOFILE, Cur: 64, Max: 64}}, + BestEffort: false, // fail-closed: this test requires real enforcement + } + + // Script: echo stdin back (passthrough), report the fd limit, write inside + // the allowlist, then try to write OUTSIDE it (must fail). + script := fmt.Sprintf(` +read line +echo "$line" +ulimit -n +echo allowed > %s/inside.txt +if echo denied > %s/outside.txt 2>/dev/null; then echo WROTE_OUTSIDE; fi +`, shellQuote(rw), shellQuote(outside)) + + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd, args, extraEnv, err := sandbox.WrapCommand(self, spec, "/bin/sh", []string{"-c", script}) + if err != nil { + t.Fatalf("WrapCommand: %v", err) + } + + c := exec.Command(cmd, args...) //nolint:gosec // re-exec of this test binary by design + c.Env = append(os.Environ(), extraEnv...) + c.Stdin = strings.NewReader("PING-3234\n") + out, runErr := c.CombinedOutput() + if runErr != nil { + t.Fatalf("confined wrapper failed: %v\noutput:\n%s", runErr, out) + } + got := string(out) + + // (4) passthrough + if !strings.Contains(got, "PING-3234") { + t.Errorf("stdin→stdout passthrough broken; output:\n%s", got) + } + // (2) rlimit applied + if !strings.Contains(got, "64") { + t.Errorf("expected RLIMIT_NOFILE=64 in output; got:\n%s", got) + } + // (3a) allowlisted write succeeded + if _, err := os.Stat(filepath.Join(rw, "inside.txt")); err != nil { + t.Errorf("expected write inside allowlist to succeed: %v", err) + } + // (3b) write outside the allowlist denied + if strings.Contains(got, "WROTE_OUTSIDE") { + t.Errorf("write OUTSIDE the allowlist was NOT denied; output:\n%s", got) + } + if _, err := os.Stat(filepath.Join(outside, "outside.txt")); err == nil { + t.Errorf("file written outside the allowlist exists; sandbox did not deny the write") + } +} + +// TestSandboxWrapper_FailClosed proves the fallback contract's strict side: when +// the spec asks for confinement that the wrapper cannot honor and BestEffort is +// false, the child refuses to exec (non-zero) rather than run the server +// unconfined. We simulate "cannot honor" by stripping the spec env so +// SpecFromEnv fails — RunChild must not fall through to an unconfined exec. +func TestSandboxWrapper_FailClosed(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + // Build wrapped argv but deliberately DROP extraEnv (no spec reaches child). + cmd, args, _, err := sandbox.WrapCommand(self, sandbox.Spec{}, "/bin/sh", []string{"-c", "echo SHOULD_NOT_RUN"}) + if err != nil { + t.Fatal(err) + } + c := exec.Command(cmd, args...) //nolint:gosec // re-exec of this test binary by design + // Scrub any inherited spec env so the child sees it as absent. + c.Env = scrubEnv(os.Environ(), sandbox.EnvSpec) + out, runErr := c.CombinedOutput() + if runErr == nil { + t.Fatalf("expected non-zero exit when spec is missing, got success; output:\n%s", out) + } + if strings.Contains(string(out), "SHOULD_NOT_RUN") { + t.Errorf("child exec'd the command unconfined despite missing spec; output:\n%s", out) + } +} + +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +func scrubEnv(env []string, key string) []string { + out := env[:0:0] + for _, e := range env { + if strings.HasPrefix(e, key+"=") { + continue + } + out = append(out, e) + } + return out +} diff --git a/internal/upstream/core/sandbox_main_test.go b/internal/upstream/core/sandbox_main_test.go new file mode 100644 index 000000000..40186ef76 --- /dev/null +++ b/internal/upstream/core/sandbox_main_test.go @@ -0,0 +1,27 @@ +package core + +import ( + "os" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/sandbox" +) + +// TestMain doubles as the sandbox re-exec child for this package's launcher +// integration tests. When the test binary is invoked with the production +// `__sandbox_exec -- …` argv (exactly how wrapWithSandbox / sandbox.WrapCommand +// build it), we dispatch straight into sandbox.RunChild instead of running the +// test suite — so the end-to-end path (WrapCommand → re-exec → Apply → exec) is +// exercised against the real binary rather than a mock. +func TestMain(m *testing.M) { + for i, a := range os.Args { + if a == sandbox.Subcommand { + rest := os.Args[i+1:] + if len(rest) > 0 && rest[0] == "--" { + rest = rest[1:] + } + os.Exit(sandbox.RunChild(rest, os.Stderr)) + } + } + os.Exit(m.Run()) +} diff --git a/internal/upstream/core/sandbox_rlimits_linux.go b/internal/upstream/core/sandbox_rlimits_linux.go new file mode 100644 index 000000000..59bfba715 --- /dev/null +++ b/internal/upstream/core/sandbox_rlimits_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package core + +import ( + "golang.org/x/sys/unix" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/sandbox" +) + +// defaultSandboxRlimits returns the resource limits applied to a sandboxed +// stdio server. Kept deliberately safe and per-process: RLIMIT_CORE=0 disables +// core dumps (which could otherwise leak in-memory secrets to disk) and +// RLIMIT_NOFILE caps the descriptor table. We intentionally avoid RLIMIT_NPROC +// (counted per real-uid, so a low value would starve the user's other +// processes) and RLIMIT_CPU (cumulative — it would kill long-lived servers). +func defaultSandboxRlimits() []sandbox.Rlimit { + return []sandbox.Rlimit{ + {Resource: unix.RLIMIT_CORE, Cur: 0, Max: 0}, + {Resource: unix.RLIMIT_NOFILE, Cur: 4096, Max: 4096}, + } +} diff --git a/internal/upstream/core/sandbox_rlimits_other.go b/internal/upstream/core/sandbox_rlimits_other.go new file mode 100644 index 000000000..d2ed12e3d --- /dev/null +++ b/internal/upstream/core/sandbox_rlimits_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package core + +import "github.com/smart-mcp-proxy/mcpproxy-go/internal/sandbox" + +// defaultSandboxRlimits is empty off Linux: the native sandbox only runs on +// Linux (Landlock), and the rlimit resource constants differ across platforms. +func defaultSandboxRlimits() []sandbox.Rlimit { return nil } diff --git a/internal/upstream/core/sandbox_test.go b/internal/upstream/core/sandbox_test.go new file mode 100644 index 000000000..fcf792892 --- /dev/null +++ b/internal/upstream/core/sandbox_test.go @@ -0,0 +1,50 @@ +package core + +import ( + "os" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func TestBuildSandboxSpec_WriteAllowlistDefaults(t *testing.T) { + work := t.TempDir() + spec := buildSandboxSpec(&config.ServerConfig{Name: "srv", WorkingDir: work}) + + // Reads stay broad: a single read-only "/" root. + if len(spec.ReadOnlyPaths) != 1 || spec.ReadOnlyPaths[0] != "/" { + t.Errorf("ReadOnlyPaths = %v, want [\"/\"]", spec.ReadOnlyPaths) + } + // Writes are allowlisted: working dir + OS temp must be present. + if !contains(spec.ReadWritePaths, work) { + t.Errorf("ReadWritePaths %v missing working dir %q", spec.ReadWritePaths, work) + } + if !contains(spec.ReadWritePaths, os.TempDir()) { + t.Errorf("ReadWritePaths %v missing temp dir %q", spec.ReadWritePaths, os.TempDir()) + } + // Degrade-gracefully flag is set so a Landlock-less kernel doesn't hard-fail. + if !spec.BestEffort { + t.Errorf("BestEffort = false, want true (graceful fallback)") + } +} + +func TestBuildSandboxSpec_NoWorkingDir(t *testing.T) { + spec := buildSandboxSpec(&config.ServerConfig{Name: "srv"}) + if len(spec.ReadWritePaths) == 0 || !contains(spec.ReadWritePaths, os.TempDir()) { + t.Errorf("ReadWritePaths %v should include temp dir even without a working dir", spec.ReadWritePaths) + } + // A working dir that was never set must not sneak an empty path into the + // allowlist (an empty Landlock path would be a silent no-op at best). + if contains(spec.ReadWritePaths, "") { + t.Errorf("ReadWritePaths %v contains an empty path", spec.ReadWritePaths) + } +} From 110cc832e9bf6c95a46a067d01eab35a9d61a15c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 26 Jun 2026 15:58:27 +0300 Subject: [PATCH 2/2] docs(sandbox): fix broken Docusaurus links on sandbox-isolation page The new docs/features/sandbox-isolation.md linked to ../docker-isolation.md (resolving to the unrouted /docker-isolation.md) and a relative spike path, breaking the Docusaurus `build` (onBrokenLinks: throw) + Cloudflare Pages checks on PR #768. Point both "See also" links at the canonical published routes (/features/docker-isolation, /development/sandbox-spike-mcp-34), matching the pattern sibling pages use. Verified locally: `npm run build` succeeds with zero broken links. Co-Authored-By: Paperclip --- docs/features/sandbox-isolation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/sandbox-isolation.md b/docs/features/sandbox-isolation.md index 2c6fd06ed..973da132d 100644 --- a/docs/features/sandbox-isolation.md +++ b/docs/features/sandbox-isolation.md @@ -64,6 +64,6 @@ stdin/stdout pass straight through with no intervening multiplexer. | **Linux** (no Landlock) | Degraded → runs unconfined, logged | | **macOS / Windows** | Documented **no-op** → effective `none` (Landlock is Linux-only) | -See also: [docs/docker-isolation.md](../docker-isolation.md) for the Docker mode, -and [docs/development/sandbox-spike-mcp-34.md](../development/sandbox-spike-mcp-34.md) -for the mechanism spike. +See also: [Docker Isolation](/features/docker-isolation) for the Docker mode, and +the [non-Docker sandbox spike](/development/sandbox-spike-mcp-34) for the +mechanism evaluation.