Safe Code Sandbox
by evrabhinav
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).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues