Skip to main content
Glama
README.md
# Safe Code Sandbox

An MCP server that runs untrusted, model-generated Python in a locked-down
throwaway container, and reports back what happened.

AI agents write code. Running that code directly on a machine is unsafe: it can
loop forever, eat all the memory, delete things, or be the payload of a prompt
injection. This gives an agent somewhere to run it and get a result, without
giving it the host.

## The idea in one line

The model gets three tools. It never gets Docker.

```
  MCP client (Claude Desktop, Cursor, your agent)
        │  start_run / get_result / stop_run
        ▼
  Safe Code Sandbox  ── validates limits, builds a locked-down container
        │
        ▼
  Docker ── no network, no host paths, not root, destroyed after the run
```

Every restriction lives in server code, not in a prompt. A prompt is a
request; code is a rule.

## Tools

| Tool | Does |
|---|---|
| `start_run(code, files?, limits?)` | Starts a run, returns a `run_id` immediately |
| `get_result(run_id)` | `"running"`, or the full report once finished |
| `stop_run(run_id)` | Kills the run and removes the container |

Runs are asynchronous because a job can legitimately take an hour, and MCP has
no way to hold a call open that long. The `run_id` is the handle.

```python
start_run(code="print(sum(range(10)))")
# → {"run_id": "8f2c...", "status": "running", "limits": {...}}

get_result("8f2c...")
# → {"status": "succeeded", "exit_code": 0, "stdout": "45\n", ...}
```

Retrying a failed run is the agent's job, not the sandbox's. The sandbox
reports; the agent decides.

## Statuses

The distinctions here are the point. An agent deciding what to do next needs to
know *why* a run stopped.

| Status | Meaning | What a client should do |
|---|---|---|
| `succeeded` | Exit code 0 | Use the output |
| `failed` | Non-zero exit | Fix the code |
| `timed_out` | Hit the wall-clock limit | Make it faster, or ask for more time |
| `out_of_memory` | Killed by the memory cap | Ask for more memory, up to the max |
| `stopped` | Cancelled via `stop_run` | Nothing |
| `sandbox_error` | The sandbox itself broke | Not your code; needs a human |

## Limits

The client chooses, the server enforces.

| | Default | Maximum |
|---|---|---|
| Memory | 5 GB | 16 GB |
| Timeout | 10 minutes | 1 hour |
| CPUs | 2 | 4 |
| Processes | 256 | not client-settable |

Asking for more than the maximum is **rejected, not quietly reduced**:

```
Requested 32 GB of memory, max is 16 GB
```

Clamping would be worse than failing. A client that asked for 32 GB and
silently got 16 would read the resulting out-of-memory as a bug in its own
code.

At most four runs execute concurrently, because four runs each individually
allowed 16 GB is 64 GB.

## Isolation

Short version: no network at all, no host filesystem, never root, all Linux
capabilities dropped, memory and CPU and process count capped, scratch space
mounted non-executable, container destroyed on every exit path.

`assert_posture()` re-checks the critical flags before each container is
created, so a refactor that drops one fails the run instead of silently
weakening the sandbox.

**[docs/SECURITY.md](docs/SECURITY.md) is the real document**, including what
this does *not* protect against. The headline limitation: containers share the
host kernel, so a kernel escape is not prevented by any of the above. If you
need a boundary that survives that, you want a VM, not a container.

## Install

Requires Docker and Python 3.11 or newer.

```bash
git clone https://github.com/evrabhinav/safe-code-sandbox.git
cd safe-code-sandbox

python -m venv .venv
.venv/bin/pip install -e ".[dev]"      # Windows: .venv\Scripts\pip

docker build -t safe-code-sandbox:py312 docker/
```

The image is deliberately plain: a Python interpreter, a non-root user, and a
workspace owned by them. No package manager cache, no credentials, and no
`CMD`, so the image cannot decide what runs.

## Run it

```bash
safe-code-sandbox          # speaks MCP over stdio
```

In an MCP client's config:

```json
{
  "mcpServers": {
    "sandbox": {
      "command": "/path/to/.venv/bin/safe-code-sandbox"
    }
  }
}
```

## Design decisions

**The server owns Docker; the model gets high-level tools.** The model cannot
name an image, a mount, a network or a capability.

**Code travels in the request, not through a mount.** The container is created
but not started, the files are copied in as a tar, and then it starts. That
ordering is what makes it possible to never expose a host path.

**Fresh container per run.** Nothing carries between runs, so one run cannot
leave a payload for the next.

**Limits rejected, not clamped.** See above.

**The sandbox does not retry.** It reports precisely enough for the agent to
decide. A sandbox that retried would hide the signal the agent needs.

## Tests

```bash
.venv/bin/pytest
```

83 tests, no Docker required. The Docker client is injected, so a fake drives
the control flow: the four outcomes, the create-copy-start ordering, and the
guarantee that a container is removed even when copying the code fails.

`tests/test_sandbox.py` is the one worth reading. It asserts each isolation
flag individually, names what each one stops, and checks that `assert_posture`
rejects a spec with any of them removed.

## Known limitations

**No integration tests against a real daemon.** Docker was not available on the
machine this was written on, so the control flow is covered by a fake and the
container specification is asserted structurally. The flags are correct per
Docker's API, but nobody has yet watched a real container get OOM-killed here.
That is the first thing to add.

**Python only.** The entrypoint is `python -I main.py`.

**No internet, not even opt-in.** Which means no `pip install` inside a run.

**No auth.** Whoever can reach the MCP server can run code. Appropriate for a
local tool, not for a shared deployment.

**In-memory run registry.** Reports are kept for an hour and lost on restart.
Deliberate: a sandbox that accumulates other people's code on disk is a worse
problem than one that forgets.

## License

MIT. See [LICENSE](LICENSE).