Skip to main content
Glama
Leovilhena

sandbox-mcp

by Leovilhena
README.md
<div align="center">

# sandbox-mcp

**Let an LLM fetch the web, write scripts, and run them — without letting it do
those things anywhere near your agent's process.**

[![CI](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/ci.yml)
[![Images](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/images.yml/badge.svg)](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/images.yml)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](pyproject.toml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Checked with mypy](https://img.shields.io/badge/mypy-strict-2a6db2)](pyproject.toml)
[![Linted with ruff](https://img.shields.io/badge/ruff-passing-261230)](pyproject.toml)
[![Security tests](https://img.shields.io/badge/adversarial%20tests-40%20in%20containers-critical)](tests/security)

</div>

Policy-driven [MCP](https://modelcontextprotocol.io) servers for capabilities
that are unsafe to run in-process. **What is allowed — domains, languages,
resource ceilings, what needs approval — is a policy file, not code.**

## The one idea

Two servers, split on the axis that matters:

| | `sandbox-fetch` | `sandbox-exec` |
|---|---|---|
| Network | egress, allowlisted | **none** |
| Code execution | **none** | yes |

Neither can both execute arbitrary code *and* reach the network.

A prompt injection in fetched content has no way to run. Code that runs has no
way to phone home. That separation is the whole point, and it is why these are
not one convenient server.

## What makes this different

Most sandboxes describe their guarantees. This one **attacks itself in CI** —
40 tests run against real containers and attempt fork bombs, infinite loops,
DNS exfiltration, privilege escalation, audit forgery, path traversal, and SSRF
via redirect. Six real bugs were found that way, none of which were visible in
review:

- `--network=none` makes the MCP port unreachable — and the obvious
  replacement still resolves DNS, which exfiltrates data one subdomain label
  at a time.
- The wall-clock timeout **could not kill anything**: signalling across uids
  needs `CAP_KILL`.
- Fork-bomb survivors outlived their call and degraded the sandbox until
  restart.
- `RLIMIT_NPROC` counts per-UID system-wide, not per process tree.
- `PATH` was inherited wholesale from the host environment.
- The audit trail silently wrote **nowhere** on a uid-mismatched bind mount.

[SECURITY.md](SECURITY.md) states the threat model *and* the non-goals. The
non-goals matter as much: a sandbox that oversells itself is worse than one
that tells you where it stops.

> **Status:** 0.6.0, early development but in daily use. Both servers, the
> approval broker, and the adversarial suite are complete, and an independent
> security audit has been run and acted on
> ([AUDIT-fable5-2026-07-25.md](AUDIT-fable5-2026-07-25.md)). See
> [CHANGELOG.md](CHANGELOG.md) and [ROADMAP.md](ROADMAP.md).

## Quick start: `sandbox-exec`

```bash
podman build -f Dockerfile.exec -t sandbox-exec:dev .
cp policies/exec.example.yaml ./exec.yaml
mkdir -p -m 700 audit approvals

# An *internal* network, not --network=none: the server still has to be
# reachable on its MCP port. See "Networking" below -- this combination is
# what actually blocks egress, and it is verified by the test suite.
podman network create --internal sandbox-net

podman run --rm \
  --network=sandbox-net --dns=none \
  --read-only \
  --cap-drop=ALL --cap-add=SETUID --cap-add=SETGID --cap-add=CHOWN --cap-add=KILL \
  --security-opt no-new-privileges \
  --memory=512m --cpus=2 --pids-limit=256 \
  --mount type=tmpfs,destination=/workspace,tmpfs-size=64m \
  --mount type=tmpfs,destination=/tmp,tmpfs-size=16m \
  -v ./exec.yaml:/policy/exec.yaml:ro \
  -v ./audit:/audit \
  -v ./approvals:/approvals \
  -p 127.0.0.1:8801:8801 \
  sandbox-exec:dev
```

Register it with any MCP client by URL — for example:

```bash
hermes mcp add sandbox-exec --url http://127.0.0.1:8801/mcp
```

### Networking

`--network=none` is the obvious choice and it does not work: it leaves the
MCP port unreachable, so nothing can call the server. What is needed is a
network the *host* can reach inward on while the container cannot reach
outward:

| Flag | Why |
|---|---|
| `--network=<internal net>` | No gateway or masquerading, so no route off-host. Published ports still reach the server. |
| `--dns=none` | An internal network still wires up a resolver, and DNS queries leave the host. Without this, `example.com` resolves — and anything that resolves can carry data out one subdomain at a time. |
| `--cap-add=CHOWN` | A tmpfs mounted over `/workspace` resets the ownership the image set; the server needs to share the workspace with the exec user's group at startup. It fails loudly if this is missing rather than widening permissions. |
| `-e AUTH_TOKEN=...` | Requires `Authorization: Bearer <token>` on every request. Optional, and the server warns when unset — but it is the only thing besides loopback binding standing between the network and `run_command`. |
| `--cap-add=KILL` | The server runs as container-root but executed code runs as another uid, and signalling across uids needs this. **Without it the timeout cannot kill anything and runaway processes survive** — an infinite loop still dies on the CPU rlimit, but a sleeping one does not. |

Everything else is dropped: no `DAC_OVERRIDE` (root cannot bypass file
permissions), no `NET_*`, no `SYS_*`.

Verified by `tests/security/test_container.py`: TCP egress, DNS resolution,
and host-network access are each attempted and must fail, and a fork bomb
must not leave the sandbox degraded.

## Quick start: `sandbox-fetch`

The fetcher is the inverse: it may reach the network, so it never executes
anything, needs no capabilities at all, and runs unprivileged from PID 1.

```bash
podman build -f Dockerfile.fetch -t sandbox-fetch:dev .
cp policies/fetch.example.yaml ./fetch.yaml
mkdir -p -m 700 audit

podman run --rm \
  --read-only \
  --cap-drop=ALL \
  --security-opt no-new-privileges \
  --userns=keep-id:uid=10003,gid=10003 \
  --memory=256m --cpus=1 --pids-limit=64 \
  -e OLLAMA_BASE_URL=http://127.0.0.1:11434 \
  -v ./fetch.yaml:/policy/fetch.yaml:ro \
  -v ./audit:/audit \
  -p 127.0.0.1:8802:8802 \
  sandbox-fetch:dev
```

`--userns=keep-id` is not cosmetic. Without it the bind-mounted audit
directory is owned by the host user — which maps to uid 0 inside a rootless
container — while the server runs as uid 10003, so every audit write fails.
Records still reach stderr, but the file you would actually go read stays
empty. The server warns about this at startup.

## Tools

**`sandbox-exec`** — four tools:

| tool | what |
|---|---|
| `workspace(action, path, content)` | `read`, `write`, `list`, `reset` |
| `run_script(language, source)` | run source in an allowlisted language |
| `run_command(command)` | shell, only if `shell.enabled` |
| `skill(action, path, content)` | `list`, `read`, and `save` when writable |

Nouns with an action argument, not seven verbs: `list_files` used to be matched
against the ordinary word "List" in an unrelated question, and the agent
answered about the sandbox instead. Schemas also cost ~1.8 KB each in every
prompt.

Command output is capped at `limits.max_output_bytes` (default **8000**) and
lands directly in the caller's context, so it is the most expensive thing this
server produces. Write bulk results to the workspace and read back only what
you need. Truncation keeps the head *and* the tail, so a traceback at the end
of a noisy run survives.

**`sandbox-fetch`** — `fetch_url(url)`. Returns extracted text, or a refusal
naming the rule that fired.

### What the fetcher checks, in order

Every one of these re-runs on **each redirect hop**, not just on the URL you
passed. A hop is a request the process actually makes, so a redirect landing
on an internal address is a live SSRF no matter where the chain ends.

| Check | Notes |
|---|---|
| Scheme | `https` only by default. |
| Credentials in URL | Always refused: `https://allowed.example@evil.example` reads as allowlisted to a human and connects to the attacker. |
| Deny list, then allow list | `example.com` matches that host and nothing else; `*.example.com` also matches subdomains. Label boundaries after punycode normalization, so `*.example.com` never matches `notexample.com`. An empty allow list is a startup error, not an implicit allow-all. |
| Address | Resolved, then rejected if private, loopback, link-local, multicast, reserved, or unspecified — including IPv4-mapped IPv6 like `::ffff:127.0.0.1`. |
| Size | Counted while streaming, never trusted from `Content-Length`. |
| Injection review | Optional, and fails closed: an unreachable reviewer withholds the content. |

The User-Agent is **policy-only and deliberately not caller-settable**. A
header the model controls, sent to an allowlisted host, is an exfiltration
channel — the exact property the two-container split exists to prevent.

### API routes: ask for the API, not the page

Scraped HTML carries menus, infoboxes, and edit links around the text you
wanted. That wastes the caller's character budget, and markup remnants read
like injected instructions to a reviewer. Where a site publishes JSON for the
same content, policy can say so:

```yaml
api_routes:
  - match: "*.wikipedia.org"
    when_path: "^/wiki/(?P<title>.+)$"
    url: "https://{host}/api/rest_v1/page/summary/{title}"
    extract: ["description", "extract"]   # dotted paths, joined in order
    on_miss: fallback                     # or `error` to require the API
```

Measured on the same article: **559 characters of clean prose instead of about
8000 of page furniture.**

There is no per-site code — a route is four lines of policy, so adding a site
is a reviewable diff rather than a release. And a route is a **rewrite, never a
bypass**: the rewritten URL goes through the full allowlist and SSRF check
before anything is requested, substituted values are percent-encoded so a title
cannot climb out of its path segment, and a template naming a placeholder that
does not exist fails at startup rather than on first use.

## Policy

See [`policies/exec.example.yaml`](policies/exec.example.yaml) and
[`policies/fetch.example.yaml`](policies/fetch.example.yaml). Mount it
read-only so the container cannot rewrite its own rules. A missing or
malformed policy is a startup failure — there is no permissive fallback.

Environment variables carry only operational settings (`POLICY_PATH`,
`AUDIT_PATH`, `HOST`, `PORT`); capability decisions live in the policy file,
where they are structured, reviewable, and not visible in `podman inspect`.

## Approval

The executor has no network, so approval cannot be a webhook. It travels
over a bind-mounted directory:

```
/approvals/pending/<id>.json     server writes, then blocks
/approvals/decisions/<id>.json   anything can write the verdict
```

Timeout means deny. No transport is baked in — bridging that queue to
Telegram, Slack, a TUI, or a web UI is a separate component, which is what
keeps this project free of any of them. Approving by hand is just:

```bash
echo '{"approved":true,"reason":"looks fine"}' > approvals/decisions/<id>.json
```

Providers: `deny_all` (default), `file_queue`, `command`, `webhook`.

A working broker for the other end ships in
[`contrib/approval-broker/`](contrib/approval-broker/) — one stdlib-only file,
with console and Telegram adapters. It lives outside `src/` on purpose, so no
chat transport can end up in the sandbox image:

```bash
python3 contrib/approval-broker/approval_broker.py --queue ./approvals
```

## Skills

The workspace is a tmpfs and dies with the container, so nothing an agent
builds survives. A **skills** directory is durable storage for reusable
scripts, off by default:

```yaml
skills:
  enabled: true
  path: /skills
  writable: false     # see below
  max_mb: 16
```

Adds the `skill` tool: `list` and `read` always, `save` only when `writable`.

`writable` defaults to **false** deliberately. A persistent, writable store of
executable code is somewhere a prompt injection can leave something behind for
a later, unrelated call to pick up — a different threat from everything else
here, where every workspace is ephemeral. When it is false the `save_skill`
tool is not registered at all, rather than registered and failing; an absent
tool cannot be talked into trying. Skills are never executed implicitly: they
are inert files the caller may choose to run through the normal gated tools.

Mount it read-only unless you want the agent curating its own skills, and watch
`skill_saved` in the audit log if you do:

```bash
-v ./skills:/skills:ro
```

## Security

Read [SECURITY.md](SECURITY.md) — it documents the threat model *and* the
non-goals, which matter just as much. The short version: the container is
the boundary; everything else narrows what happens inside it.

Guarantees are covered by an adversarial test suite rather than asserted:

```bash
make check          # ruff (incl. bandit rules), mypy --strict, bandit, pip-audit
make test-security  # fork bombs, infinite loops, network access, privilege escalation
```

## Development

```bash
make install         # venv + dev extras
make check           # lint, types, static analysis, dep CVEs, non-container tests
make build           # both images
make test-security   # the adversarial suite against real containers
make scan-image      # trivy, both images
```

`make check` must pass before anything is done. See
[CONTRIBUTING.md](CONTRIBUTING.md) — the one rule is that a change widening
what the sandbox permits arrives with an adversarial test that fails without
it.

## Documentation

| | |
|---|---|
| [SECURITY.md](SECURITY.md) | Threat model, and the non-goals |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Setup, style, the rule for security changes |
| [ROADMAP.md](ROADMAP.md) | What is next, and what is deliberately not planned |
| [CHANGELOG.md](CHANGELOG.md) | Keep a Changelog, semver |
| [contrib/approval-broker/](contrib/approval-broker/) | The human end of the approval queue |

## License

MIT — see [LICENSE](LICENSE).