Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions cmd/mcpproxy/sandbox_exec_cmd.go
Original file line number Diff line number Diff line change
@@ -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 -- <command> [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))
},
}
}
69 changes: 69 additions & 0 deletions docs/features/sandbox-isolation.md
Original file line number Diff line number Diff line change
@@ -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: [Docker Isolation](/features/docker-isolation) for the Docker mode, and
the [non-Docker sandbox spike](/development/sandbox-spike-mcp-34) for the
mechanism evaluation.
20 changes: 20 additions & 0 deletions internal/sandbox/runchild_other.go
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions internal/sandbox/runchild_unix.go
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions internal/sandbox/sandbox_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/sandbox/sandbox_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
62 changes: 62 additions & 0 deletions internal/sandbox/wrap.go
Original file line number Diff line number Diff line change
@@ -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 -- <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.
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
}
Loading
Loading