Skip to main content
Glama
Leovilhena

sandbox-mcp

by Leovilhena

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 Images Python License: MIT Checked with mypy Linted with ruff Security tests

Policy-driven MCP 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.

Related MCP server: mcp-safeguard

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 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). See CHANGELOG.md and ROADMAP.md.

Quick start: sandbox-exec

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:

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.

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-fetchfetch_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:

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 and 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:

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/ — 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:

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:

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:

-v ./skills:/skills:ro

Security

Read 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:

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

Development

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 — the one rule is that a change widening what the sandbox permits arrives with an adversarial test that fails without it.

Documentation

SECURITY.md

Threat model, and the non-goals

CONTRIBUTING.md

Setup, style, the rule for security changes

ROADMAP.md

What is next, and what is deliberately not planned

CHANGELOG.md

Keep a Changelog, semver

contrib/approval-broker/

The human end of the approval queue

License

MIT — see LICENSE.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    -
    quality
    B
    maintenance
    A security gateway that enforces policies, tracks data taints, and sandboxes tool calls between AI agents and MCP servers. It provides a secure chokepoint to prevent prompt injection and ensure OWASP ASI compliance through audit logging and deterministic execution.
    Last updated
    1
    Apache 2.0

View all related MCP servers

Related MCP Connectors

  • Security firewall for AI agents — scans MCP calls for injection, secrets, and risks.

  • Security scanner for MCP servers. Detect vulnerabilities, prompt injection, and tool poisoning.

  • MCP server teaching AI agents to implement TideCloak: auth, E2EE, IGA, security analysis

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Leovilhena/sandbox-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server