sandbox-mcp
Policy-driven MCP servers that split web fetching and code execution into isolated containers, preventing prompt injection and data exfiltration by ensuring neither can both execute code and reach the network.
README
<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.
</div>
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.
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=nonemakes 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_NPROCcounts per-UID system-wide, not per process tree.PATHwas 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-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:
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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。