Skip to main content
Glama
README.md
# mcp-linux-sandbox

A disposable Debian Linux sandbox exposed as an MCP server, so a chat client
(FlowDown, Claude Desktop, anything speaking Streamable HTTP) can run real shell
commands — `apt install`, `curl`, `ffmpeg`, Python — and hand files back to you.

Each conversation gets its own container. Files under `/workspace` survive
container recycling; installed packages and running processes do not.

```
MCP client ──Bearer──▶ nginx (TLS)
                         ├── /mcp/<random>/  ──▶ 127.0.0.1:8000  FastMCP
                         └── /dl/<signed>    ──▶ (same)  file links
                                                     │
                                                     ▼
                                    podman exec  mcpsb-<label>
                                                     │
                                        ┌────────────┴────────────┐
                                        ▼                         ▼
                              container writable layer     volume /workspace
                              (packages, /tmp — dies)      (files — survives)
```

## Files

| Path | What it is |
|---|---|
| `server.py` | The MCP server: `run_command`, `present_file`, `sandbox_info`, plus the `/dl/` route |
| `Dockerfile` | Base image for sandbox containers (Debian + common CLI tools) |
| `requirements.txt` | Pinned Python dependencies |
| `env.sh` | Creates `mcp-sandbox.env` and generates its signing key |
| `install.sh` | Idempotent install/update: venv, image, systemd units |
| `selftest.py` | End-to-end checks that speak MCP over HTTP |
| `scripts/cleanup.sh` | Stops leftover containers on service stop |
| `scripts/gc-volumes.sh` | Deletes stale volumes (dry-run by default) |
| `systemd/` | `mcp-sandbox.service`, plus the volume-GC service and timer |
| `nginx/mcp-sandbox.conf` | The two `location` blocks to paste into your server |
| `mcp-sandbox.env.example` | Copy to `mcp-sandbox.env`; holds the signing key |

## Install

```bash
sudo dnf install -y podman catatonit        # catatonit is needed by podman --init
./env.sh                                    # creates mcp-sandbox.env, generates the signing key
vim mcp-sandbox.env                         # set MCP_PUBLIC_BASE
./install.sh                                # refuses to run while that is still the placeholder
```

Then wire up nginx — copy both blocks from `nginx/mcp-sandbox.conf` into your
existing TLS `server {}`, replacing the path and token placeholders:

* generate the bearer token with `openssl rand -hex 32`
* on Fedora, `sudo setsebool -P httpd_can_network_connect 1`, or nginx gets 502
* `sudo nginx -t && sudo systemctl reload nginx`

## Client configuration

| Field | Value |
|---|---|
| Endpoint | `https://your.host:8443/mcp/<random-path>/` |
| Headers | `{"Authorization": "Bearer <token>"}` |
| Tool confirmation | **leave it ON** — this is a remote code-execution endpoint |

Add this to the client's system prompt, or the per-conversation isolation does
not happen:

> When you call `run_command`, pass a `sandbox` label. At the start of a new
> conversation pick a NEW short label (for example `amber-otter`) and use that
> same label for every call in the conversation. Never reuse a label from an
> earlier conversation, and do not change it mid-conversation.

Verify with, from the install directory (`install.sh` copies `selftest.py` there):

```bash
cd ~/mcp-linux-sandbox                      # or wherever SANDBOX_HOME points
./venv/bin/python selftest.py --url https://your.host:8443/mcp/<path>/ --token <token>
./venv/bin/python selftest.py --url http://127.0.0.1:8000/mcp    # bypasses nginx
```

## Sandbox labels

Tools take a `sandbox` label, and label → container → volume is one to one: a
label with no container yet gets a fresh, empty one. Labels are normalised, so
`Amber Otter` and `amber-otter` reach the same sandbox.

The label has to come from the model because the client sends one MCP session id
for the whole app rather than one per conversation, and no per-conversation field
exists in the request. It is a convention rather than a guarantee — a model that
reuses a label reaches the old container — but every result echoes
`[sandbox <key>] [new]`, so a collision is visible rather than silent.

## Settings

| Setting | Default | Effect |
|---|---|---|
| `SANDBOX_IDLE` | 300 s | Recycle idle containers. Cleanup only: lowering it loses `apt` packages sooner, nothing else. |
| `SANDBOX_MAX` | 2 | Concurrent containers. Beyond it the least-recently-used is evicted (`rm -f`; the volume stays, so files survive). On a 4-core / 3.66 GiB host, `2 × 1 GiB` leaves ~1.6 GiB for nginx, the server and the system. |
| `SANDBOX_MAX_TIMEOUT` | 900 s | Ceiling on the model's `timeout_seconds`. |
| `SANDBOX_MIN_FREE` | 2 GiB | Refuse to start a sandbox when the volume filesystem is nearly full. Checked at container creation only — it will not stop a single large write. |
| `SANDBOX_LINK_TTL` | 3600 s | Download links are HMAC-signed with an expiry, so a leaked URL is bounded in time and scope. |
| `SANDBOX_IMAGE_MAX` / `SANDBOX_INLINE_MAX` | 1.5 MiB / 48 KiB | Attached images are also sent to the model (~2 MB ≈ 20k tokens); oversized files fall back to a link. |
| `--memory=1g --cpus=2 --pids-limit=256` | | Verified to land in the cgroup (`memory.max`, `cpu.max`, `pids.max`). |
| `--init` | | Without an init as PID 1, exited children pile up as zombies against `--pids-limit` until `exec` starts failing. |
| `bash -lc` | | `/bin/sh` is dash on Debian: no `[[ ]]`, no arrays, no `pipefail`. |
| `--security-opt=no-new-privileges` | | Keeps setuid binaries from being exploitable. |

Volumes are never deleted by the server. `gc-volumes.sh` handles that on a timer
and is dry-run by default.

## Security

This endpoint executes arbitrary commands. Treat it accordingly.

* Keep the client's tool-confirmation prompt **enabled**. It is the only human
  gate on what actually runs.
* Anything that can reach `/mcp/` can run code; anything that can reach `/dl/`
  can read the sandbox volume. Keep the bearer token and `MCP_SIGN_KEY` out of
  URLs, screenshots, `.fdmcp`-style exports, cloud drives and git.
* `present_file` serves regular files under `/workspace` and follows no symlink
  in any path component, so a link created inside the sandbox cannot reach host
  files.
* Containers are **rootless** (`podman`, no `sudo`) and can reach the LAN by
  default. If that matters, drop the network (`--network=none`) or move the
  container onto an `--internal` network behind a proxy — but installing packages
  needs network, so pick one.
* Isolation is namespace + seccomp + cgroups, not a VM. It is the right level for
  "stop the model from trashing my host"; it is not gVisor/Kata.

## Limitations

* The sandbox boundary is the model's own label discipline, as described above.
* Concurrent conversations share the container budget. With `SANDBOX_MAX=2`, a
  third conversation evicts the LRU container — packages go, files stay.
* Restarting the service invalidates the client's MCP session; the client has to
  re-verify.
* Image attachments depend on the client. FlowDown attaches MCP `image` content;
  other clients may not.
* `gc-volumes.sh`'s age heuristic (newest mtime in the volume) has not been
  validated on btrfs. Keep `DRY_RUN=1` until you have watched it for a while.

## Verified behaviour

Checked end-to-end against a live deployment (Raspberry Pi, Fedora 44 aarch64,
rootless podman, cgroup v2, behind nginx):

| Check | Result |
|---|---|
| Debian userland, tools present | ✅ trixie; `curl jq git rg fdfind ps less nano python3` |
| Network + `apt install` | ✅ `sqlite3` installed and usable |
| Same label reuses container | ✅ same `hostname`, no `[new]` |
| New label, fresh container | ✅ different `hostname`, empty `/workspace` |
| Files survive recycling | ✅ marker file kept its old hostname after rebuild |
| Writable layer is discarded | ✅ `/tmp` contents gone after rebuild |
| Idle reaper | ✅ containers recycled after the idle window |
| LRU eviction at `MAX_SANDBOXES` | ✅ count stayed `2/2`, oldest evicted, its files kept |
| Exit codes | ✅ `false`→1, `exit 42`→42, unknown command→127 |
| Container-side timeout | ✅ rc=124, process actually killed |
| `--init` as PID 1 | ✅ `podman-init`, 0 zombies |
| cgroup limits applied | ✅ `memory.max=1G`, `cpu.max=2 cores`, `pids.max=256` |
| `present_file` image | ✅ rendered as an attachment |
| `present_file` text / link | ✅ inlined / signed URL |
| Path traversal | ✅ rejected |
| Symlink escape | ✅ rejected |
| Oversized image | ✅ falls back to a link |
| Link expiry and signature tampering | ✅ expired and tampered URLs rejected |
| Credential redaction in `sandbox_info` | ✅ `<redacted>` |

Not yet exercised: the over-age branch of `gc-volumes.sh`.