Skip to content

Commit 3536ac4

Browse files
feat(security): scanner-flow parity under sandbox isolation (MCP-34.4)
D3 option (b) from the MCP-34 spike: clean, surfaced degradation. Under a non-Docker isolation mode (sandbox/none) the Docker-based scanner plugins (Spec 039) cannot run, so the scan engine now skips them with an honest, mode-specific reason pointing at MCPX_DOCKER_SNAP_APPARMOR instead of the misleading "pull the image" guidance. The always-on in-process tpa-descriptions scanner still runs, and the skipped Docker scanners stay in the resolved set (recorded as failed) so the existing coverage check downgrades the server's security_scan.status to "degraded" rather than a silent all-clear. - Engine: new isolationMode gate in resolveScanners/checkImage - Service: SetIsolationMode setter (+ startup WARN), wired in server.go from cfg.DockerIsolation.ResolvedMode() - Tests: engine skip under sandbox/none + docker-mode back-compat guard + service setter propagation - Docs: rewrite docs/docker-isolation.md to cover all three modes (docker/ sandbox/none), the snap-docker AppArmor failure mode, the honest uid/gid limitation, and a platform support matrix; add sandbox option to the MCPX_DOCKER_SNAP_APPARMOR error doc Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent e98c80a commit 3536ac4

7 files changed

Lines changed: 267 additions & 5 deletions

File tree

docs/docker-isolation.md

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# Docker Security Isolation
1+
# Security Isolation (Docker · Sandbox · None)
22

3-
MCPProxy provides Docker isolation for stdio MCP servers to enhance security by running each server in its own isolated container.
3+
MCPProxy can confine stdio MCP servers so a malicious or buggy server cannot freely touch the host. There are **three isolation modes**`docker`, `sandbox`, and `none` — selected by `docker_isolation.mode` (global) or `isolation.mode` (per-server). This document covers all three; most of it describes the **Docker** mode (the default and most capable), with the **Sandbox** mode and the **scanner behaviour under each mode** in [Isolation Modes](#isolation-modes) below.
4+
5+
> **Naming note:** the global config key is still `docker_isolation` for backward compatibility, but its `mode` field selects any of the three modes — it is not Docker-only.
46
57
> **New installs:** Docker isolation is turned on automatically when mcpproxy creates its initial `mcp_config.json` and a Docker daemon is reachable (`docker info` responds within 2 seconds). If Docker isn't available at first run, isolation stays off so stdio servers still work — you can enable it later from the **Security** page in the Web UI or by editing the config below.
68
>
@@ -26,6 +28,58 @@ Docker isolation automatically wraps stdio-based MCP servers in Docker container
2628
- **Resource Limits**: Memory and CPU limits prevent resource exhaustion
2729
- **Automatic Runtime Detection**: Maps commands to appropriate Docker images
2830
31+
## Isolation Modes
32+
33+
MCPProxy resolves an **isolation mode** for every stdio server. Set it globally with `docker_isolation.mode` and override per-server with `isolation.mode`:
34+
35+
| Mode | What it does | Where it works | uid/gid drop |
36+
|------|--------------|----------------|--------------|
37+
| `docker` | Wraps the server in a Docker container (process/FS/network isolation, resource limits). The default and most capable mode. | Any host with a working Docker daemon. | Yes (container user) |
38+
| `sandbox` | Runs the server **natively** under a Linux [Landlock](https://docs.kernel.org/userspace-api/landlock.html) filesystem allowlist + `setrlimit` resource caps — **no Docker required**. For hosts where Docker isolation is unavailable or broken (e.g. snap-docker + AppArmor). | Linux 5.13+ only (Landlock). Best-effort downgrade across ABI 1–5. macOS/Windows: documented no-op ⇒ behaves like `none`. | **No** — see [Honest limitations](#honest-limitations) |
39+
| `none` | No confinement; the server runs directly on the host. | Everywhere. | n/a |
40+
41+
```json
42+
{
43+
"docker_isolation": {
44+
"mode": "sandbox"
45+
},
46+
"mcpServers": [
47+
{ "name": "trusted-local", "command": "uvx", "args": ["x"], "isolation": { "mode": "none" } }
48+
]
49+
}
50+
```
51+
52+
### Back-compat with the legacy `enabled` flag
53+
54+
The older boolean `docker_isolation.enabled` (and per-server `isolation.enabled`) still works and is mapped to a mode:
55+
56+
- an explicit `mode` always wins;
57+
- otherwise `enabled: true``docker`, `enabled: false``none`;
58+
- a missing/`nil` isolation config ⇒ `none`.
59+
60+
Per-server precedence: explicit per-server `mode` → per-server legacy `enabled` → global `mode` → global legacy `enabled`. A per-server `mode` (e.g. `none` for a trusted server) overrides the global gate.
61+
62+
### Sandbox mode (Landlock)
63+
64+
`sandbox` mode confines a stdio server **without Docker** by applying a Linux Landlock LSM ruleset (a writable-path allowlist) plus `setrlimit` resource caps to the process before it `exec`s, then preserving the raw stdin/stdout JSON-RPC pipes. It is unaffected by `kernel.apparmor_restrict_unprivileged_userns=1` (it needs no user namespaces), which is exactly why it works where bubblewrap/userns-based sandboxes are blocked. See the spike write-up in [docs/development/sandbox-spike-mcp-34.md](development/sandbox-spike-mcp-34.md) for the mechanism comparison and PoC.
65+
66+
### Scanner behaviour under each mode (MCP-34.4)
67+
68+
The security **scanner plugins** (Spec 039) are Docker-based. Under a non-Docker isolation mode they cannot run, so MCPProxy **degrades cleanly and surfaces it** rather than failing silently:
69+
70+
| Mode | Docker scanner plugins | In-process scanner (`tpa-descriptions`) | Scan result for a server with only Docker scanners |
71+
|------|------------------------|------------------------------------------|----------------------------------------------------|
72+
| `docker` | Run normally | Runs | As scanned |
73+
| `sandbox` / `none` | **Skipped** with an honest, mode-specific reason pointing at [`MCPX_DOCKER_SNAP_APPARMOR`](errors/MCPX_DOCKER_SNAP_APPARMOR.md) | **Still runs** | `security_scan.status: "degraded"` (a low/zero risk score from incomplete coverage is not reported as a trustworthy all-clear) |
74+
75+
This is **decision D3 option (b)** from the [MCP-34 spike](development/sandbox-spike-mcp-34.md#recommendation-for-the-d3-scanner-question): clean, surfaced degradation. A native (non-Docker) scanner runtime — option (a) — is a larger follow-up and is not yet implemented. To run the full Docker-based scanner fleet, use `mode: docker` on a host with a working Docker daemon, or replace snap-docker with a distro Docker package (see the error doc).
76+
77+
The skip is also logged at startup:
78+
79+
```
80+
WARN Isolation mode runs no Docker for scanner plugins; Docker-based scanners will be skipped … {"isolation_mode": "sandbox"}
81+
```
82+
2983
## Configuration
3084

3185
### Global Docker Isolation
@@ -297,6 +351,34 @@ docker stats
297351
exists at the bundle path above, or pre-pull the image with
298352
`docker pull <image>`.
299353

354+
## Snap-docker (AppArmor) failure mode
355+
356+
On Ubuntu hosts where Docker is installed via **snap**, AppArmor's profile transition fights the security flags the scanner sandbox requires (`--security-opt no-new-privileges` + a pinned AppArmor profile), so in-container commands fail with *operation not permitted*. This is the original driver for non-Docker `sandbox` mode. Symptoms, root cause, and fixes are documented in [`docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md`](errors/MCPX_DOCKER_SNAP_APPARMOR.md). The related systemd/snap-confine variant for *upstream* docker servers is detected by `mcpproxy doctor` (issue #457).
357+
358+
Your options on such a host:
359+
360+
1. Replace snap Docker with a distro/upstream Docker package (full Docker mode works).
361+
2. Set `docker_isolation.mode: "sandbox"` — stdio servers are confined natively with Landlock; Docker-based scanners degrade cleanly (see [Scanner behaviour](#scanner-behaviour-under-each-mode-mcp-344)).
362+
3. Set `security.scanner_disable_no_new_privileges: true` to drop the `no-new-privileges` flag from scanner containers (weakens scanner hardening; prefer 1 or 2).
363+
364+
## Honest limitations
365+
366+
`sandbox` mode is deliberately scoped. Known limitations:
367+
368+
- **No uid/gid drop.** Dropping to an unprivileged uid/gid requires `CAP_SETUID`/`CAP_SETGID` (i.e. running as root). When mcpproxy runs unprivileged, the uid/gid drop is **best-effort and typically a no-op** — the sandboxed process keeps the launching user's identity. Landlock (filesystem) and `setrlimit` (resource caps) still apply. Docker mode does drop to a container user. This is an honest trade-off, not a bug.
369+
- **Linux-only.** Landlock is a Linux 5.13+ feature. On older kernels the launcher degrades best-effort (fewer access-right bits enforced on ABI 1). On macOS/Windows `sandbox` is a documented **no-op** and behaves like `none`.
370+
- **Filesystem + resources only.** Landlock confines the filesystem write-allowlist; it does not provide network namespacing. Pair with care for network-sensitive servers, or use `docker` mode with `network_mode: none`.
371+
- **Docker-based scanners do not run under `sandbox`/`none`.** They are skipped (the scan reports `degraded`). A native scanner runtime is a future enhancement (D3 option a).
372+
373+
## Platform support matrix
374+
375+
| Platform | `docker` | `sandbox` | `none` | Docker scanner plugins |
376+
|----------|----------|-----------|--------|------------------------|
377+
| Linux (kernel ≥ 5.13) | ✅ (needs Docker daemon) | ✅ Landlock + rlimits (no uid/gid drop) || ✅ under `docker`; skipped+degraded under `sandbox`/`none` |
378+
| Linux (kernel < 5.13) | ✅ (needs Docker daemon) | ⚠️ best-effort: rlimits apply, Landlock partial/unavailable || same as above |
379+
| macOS | ✅ (Docker Desktop) | ⚠️ no-op ⇒ effectively `none` || ✅ under `docker`; n/a otherwise |
380+
| Windows | ✅ (Docker Desktop) | ⚠️ no-op ⇒ effectively `none` || ✅ under `docker`; n/a otherwise |
381+
300382
## Security Considerations
301383

302384
Docker isolation provides strong security boundaries but consider:

docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Docker isolation works fine.
2424

2525
## How to fix
2626

27-
You have three options:
27+
You have four options:
2828

2929
### 1. Switch to non-snap Docker (recommended)
3030

@@ -34,7 +34,25 @@ sudo snap remove docker
3434
# https://docs.docker.com/engine/install/ubuntu/
3535
```
3636

37-
### 2. Disable the scanner for this server (dry-run shown by default)
37+
### 2. Use native `sandbox` isolation instead of Docker (no Docker daemon needed)
38+
39+
If the real goal is to confine stdio servers on a snap-docker host, switch the
40+
isolation **mode** to `sandbox`. Servers are confined natively with a Linux
41+
Landlock filesystem allowlist + `setrlimit` (kernel 5.13+), which is unaffected
42+
by the snap-docker/AppArmor conflict because it needs no Docker and no user
43+
namespaces:
44+
45+
```json
46+
{ "docker_isolation": { "mode": "sandbox" } }
47+
```
48+
49+
Trade-off: the Docker-based scanner plugins cannot run under `sandbox`, so they
50+
are **skipped** and the affected server's `security_scan.status` becomes
51+
`degraded` (the always-on in-process `tpa-descriptions` scanner still runs).
52+
This is MCP-34.4 / D3 option (b) — clean, surfaced degradation. See
53+
[Security Isolation → Scanner behaviour](../docker-isolation.md#scanner-behaviour-under-each-mode-mcp-344).
54+
55+
### 3. Disable the scanner for this server (dry-run shown by default)
3856

3957
The error panel includes a **Disable scanner for this server** fix-step. The
4058
CLI equivalent:
@@ -46,7 +64,7 @@ mcpproxy upstream patch <server-name> --no-scanner --dry-run
4664
Drop `--dry-run` to apply. The server will still run with isolation, but
4765
without TPA pre-flight scanning.
4866

49-
### 3. Run mcpproxy without isolation for that server
67+
### 4. Run mcpproxy without isolation for that server
5068

5169
If you trust the upstream and don't need isolation:
5270

internal/security/scanner/engine.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ type Engine struct {
3232
// docker × AppArmor transition would otherwise deny entrypoint exec.
3333
disableNoNewPrivileges bool
3434

35+
// isolationMode is the resolved global isolation mode ("docker", "sandbox",
36+
// "none", or "" == docker for back-compat). Scanner *plugins* are
37+
// Docker-based (Spec 039); under "sandbox"/"none" the host runs no Docker
38+
// for scanners, so Docker-based scanners are cleanly skipped (prefailed with
39+
// an honest, mode-specific reason) while in-process scanners still run. This
40+
// is MCP-34.4 / D3 option (b): clean, surfaced degradation rather than a
41+
// misleading "pull the image" failure. Set via Service.SetIsolationMode.
42+
isolationMode string
43+
3544
// Track active scans (one per server)
3645
mu sync.Mutex
3746
activeScans map[string]*ScanJob // keyed by server name
@@ -198,6 +207,18 @@ func (e *Engine) resolveScanners(requestedIDs []string) ([]resolvedScanner, erro
198207
if s.InProcess {
199208
return ""
200209
}
210+
// MCP-34.4 / D3 option (b): under a non-Docker isolation mode the host
211+
// runs no Docker for scanner plugins, so Docker-based scanners cannot
212+
// run at all. Skip them with an honest, mode-specific reason (pointing
213+
// at the snap-docker/AppArmor failure doc) instead of the misleading
214+
// "pull the image locally" guidance below — there is nothing to pull.
215+
// They stay in the resolved set (recorded as failed), which downgrades
216+
// the scan summary to "degraded" rather than a silent all-clear.
217+
if mode := e.isolationMode; mode == "sandbox" || mode == "none" {
218+
return fmt.Sprintf("Docker-based scanner %s skipped: isolation mode %q runs no Docker containers, so Docker scanner plugins cannot run on this host. "+
219+
"In-process scanners still ran. To run Docker scanners, set isolation.mode to \"docker\" on a host with a working Docker daemon. "+
220+
"See docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md.", s.ID, mode)
221+
}
201222
if e.docker == nil {
202223
return ""
203224
}

internal/security/scanner/engine_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,99 @@ func TestEngineInProcessScannerAlwaysAvailable(t *testing.T) {
600600
}
601601
}
602602

603+
// TestEngineResolveScannersSkipsDockerUnderSandbox verifies D3 option (b)
604+
// (MCP-34.4): when the resolved isolation mode is "sandbox" (or "none") — i.e.
605+
// no Docker is available to run scanner containers — Docker-based scanner
606+
// plugins are cleanly skipped with an honest, mode-specific prefail message
607+
// (referencing MCPX_DOCKER_SNAP_APPARMOR) instead of the misleading
608+
// "pull the image" message, while the always-on in-process scanner still runs.
609+
// The skipped Docker scanners are still surfaced (recorded as failed) so the
610+
// scan summary degrades rather than silently pretending all-clear.
611+
func TestEngineResolveScannersSkipsDockerUnderSandbox(t *testing.T) {
612+
for _, mode := range []string{"sandbox", "none"} {
613+
t.Run(mode, func(t *testing.T) {
614+
dir := t.TempDir()
615+
logger := zap.NewNop()
616+
registry := NewRegistry(dir, logger)
617+
registry.scanners["mcp-scan"].Status = ScannerStatusInstalled
618+
619+
// A non-nil docker runner with a real image present would normally
620+
// let the Docker scanner pass checkImage; the sandbox/none gate must
621+
// short-circuit before any Docker probe.
622+
engine := NewEngine(nil, registry, dir, logger)
623+
engine.isolationMode = mode
624+
625+
resolved, err := engine.resolveScanners(nil)
626+
if err != nil {
627+
t.Fatalf("resolveScanners: %v", err)
628+
}
629+
630+
byID := make(map[string]resolvedScanner)
631+
for _, rs := range resolved {
632+
byID[rs.plugin.ID] = rs
633+
}
634+
635+
// Docker scanner is still surfaced (visible in report) but prefailed.
636+
dockerScanner, ok := byID["mcp-scan"]
637+
if !ok {
638+
t.Fatalf("expected mcp-scan to remain in resolved set under %s mode, got %v", mode, byID)
639+
}
640+
if dockerScanner.prefail == "" {
641+
t.Fatalf("expected Docker scanner mcp-scan to be prefailed (skipped) under %s mode", mode)
642+
}
643+
if !strings.Contains(dockerScanner.prefail, mode) {
644+
t.Errorf("prefail should name the isolation mode %q; got %q", mode, dockerScanner.prefail)
645+
}
646+
if !strings.Contains(dockerScanner.prefail, "MCPX_DOCKER_SNAP_APPARMOR") {
647+
t.Errorf("prefail should reference the MCPX_DOCKER_SNAP_APPARMOR error doc; got %q", dockerScanner.prefail)
648+
}
649+
// The misleading "pull the image" guidance must NOT appear — you
650+
// cannot pull/run Docker scanners at all under sandbox/none mode.
651+
if strings.Contains(dockerScanner.prefail, "docker pull") {
652+
t.Errorf("prefail must not tell the user to docker pull under %s mode; got %q", mode, dockerScanner.prefail)
653+
}
654+
655+
// In-process scanner still runs (never prefailed).
656+
inProc, ok := byID[inProcessTPAScannerID]
657+
if !ok {
658+
t.Fatalf("expected in-process scanner to remain available under %s mode", mode)
659+
}
660+
if inProc.prefail != "" {
661+
t.Errorf("in-process scanner must not be prefailed under %s mode; got %q", mode, inProc.prefail)
662+
}
663+
})
664+
}
665+
}
666+
667+
// TestEngineResolveScannersDockerModeUnaffected is the back-compat guard: with
668+
// the default isolation mode (docker / empty) the sandbox skip path is inert,
669+
// so a Docker scanner with a nil docker runner resolves without a prefail (the
670+
// historical behaviour relied on by existing tests).
671+
func TestEngineResolveScannersDockerModeUnaffected(t *testing.T) {
672+
for _, mode := range []string{"", "docker"} {
673+
t.Run("mode="+mode, func(t *testing.T) {
674+
dir := t.TempDir()
675+
logger := zap.NewNop()
676+
registry := NewRegistry(dir, logger)
677+
registry.scanners["mcp-scan"].Status = ScannerStatusInstalled
678+
679+
engine := NewEngine(nil, registry, dir, logger)
680+
engine.isolationMode = mode
681+
682+
resolved, err := engine.resolveScanners([]string{"mcp-scan"})
683+
if err != nil {
684+
t.Fatalf("resolveScanners: %v", err)
685+
}
686+
if len(resolved) != 1 {
687+
t.Fatalf("expected 1 scanner, got %d", len(resolved))
688+
}
689+
if resolved[0].prefail != "" {
690+
t.Errorf("Docker scanner unexpectedly prefailed under mode %q (nil docker runner skips image check): %q", mode, resolved[0].prefail)
691+
}
692+
})
693+
}
694+
}
695+
603696
func TestEngineParseResultsSARIF(t *testing.T) {
604697
dir := t.TempDir()
605698
logger := zap.NewNop()

internal/security/scanner/service.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ func (s *Service) SetScannerDisableNoNewPrivileges(disable bool) {
172172
}
173173
}
174174

175+
// SetIsolationMode records the resolved global isolation mode ("docker",
176+
// "sandbox", "none", or "" == docker) so the scan engine can decide whether
177+
// Docker-based scanner plugins can run. Under "sandbox"/"none" the host runs no
178+
// Docker for scanners, so Docker scanner plugins are cleanly skipped (the scan
179+
// summary degrades) while in-process scanners still run. This is MCP-34.4 / D3
180+
// option (b): clean, surfaced degradation on snap-docker / non-Docker hosts.
181+
func (s *Service) SetIsolationMode(mode string) {
182+
if s.engine == nil {
183+
return
184+
}
185+
s.engine.isolationMode = mode
186+
if mode == "sandbox" || mode == "none" {
187+
s.logger.Warn("Isolation mode runs no Docker for scanner plugins; Docker-based scanners "+
188+
"will be skipped and the security scan will report 'degraded' for affected servers. "+
189+
"In-process scanners (e.g. tpa-descriptions) still run. See docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md.",
190+
zap.String("isolation_mode", mode))
191+
}
192+
}
193+
175194
// SetFetchPackageSource toggles whether the source resolver may fetch the
176195
// published source of package-runner servers (npx/uvx) for scanning. See
177196
// SecurityConfig.ScannerFetchPackageSource (MCP-2206). Default is enabled.

internal/security/scanner/service_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,28 @@ func newTestService(t *testing.T) (*Service, *mockStorage, *mockEmitter) {
369369
return svc, store, emitter
370370
}
371371

372+
// TestServiceSetIsolationMode verifies the setter propagates the resolved
373+
// isolation mode to the scan engine (MCP-34.4 / D3 option b), which is what
374+
// gates the Docker-scanner skip path. Default ("") leaves Docker behaviour
375+
// intact.
376+
func TestServiceSetIsolationMode(t *testing.T) {
377+
svc, _, _ := newTestService(t)
378+
379+
if svc.engine.isolationMode != "" {
380+
t.Fatalf("expected default isolation mode to be empty, got %q", svc.engine.isolationMode)
381+
}
382+
383+
svc.SetIsolationMode("sandbox")
384+
if svc.engine.isolationMode != "sandbox" {
385+
t.Errorf("expected engine isolation mode 'sandbox', got %q", svc.engine.isolationMode)
386+
}
387+
388+
svc.SetIsolationMode("docker")
389+
if svc.engine.isolationMode != "docker" {
390+
t.Errorf("expected engine isolation mode 'docker', got %q", svc.engine.isolationMode)
391+
}
392+
}
393+
372394
func TestServiceListScannersEmpty(t *testing.T) {
373395
svc, _, _ := newTestService(t)
374396

internal/server/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,6 +1961,13 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se
19611961
if cfg != nil && cfg.Security != nil && cfg.Security.ScannerDisableNoNewPrivileges {
19621962
secService.SetScannerDisableNoNewPrivileges(true)
19631963
}
1964+
// MCP-34.4 / D3 option (b): tell the scanner which isolation mode is
1965+
// active. Under "sandbox"/"none" the host runs no Docker for scanner
1966+
// plugins, so they degrade cleanly (skip + "degraded" scan summary)
1967+
// instead of failing with a misleading "pull the image" message.
1968+
if cfg != nil && cfg.DockerIsolation != nil {
1969+
secService.SetIsolationMode(string(cfg.DockerIsolation.ResolvedMode()))
1970+
}
19641971
// Published-package-source fetch is enabled by default; only an explicit
19651972
// false in config disables it (MCP-2206).
19661973
if cfg != nil && cfg.Security != nil && cfg.Security.ScannerFetchPackageSource != nil && !*cfg.Security.ScannerFetchPackageSource {

0 commit comments

Comments
 (0)