Leovilhena/sandbox-mcp
GitHub: Leovilhena/sandbox-mcp
一个通过双容器隔离架构为 LLM agent 提供安全网页抓取与代码执行能力的策略驱动型 MCP 服务器。
Stars: 1 | Forks: 0
# sandbox-mcp
**让 LLM 抓取网页、编写脚本并运行它们——同时让这些操作远离你的 agent 进程。**
[](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/ci.yml)
[](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/images.yml)
[](pyproject.toml)
[](LICENSE)
[](pyproject.toml)
[](pyproject.toml)
[](tests/security)
策略驱动的 [MCP](https://modelcontextprotocol.io) 服务器,提供
在进程内运行不安全的能力。**什么是被允许的——域名、语言、
资源上限、什么需要审批——由策略文件决定,而不是代码。**
## 核心理念
两个服务器,在关键的维度上进行隔离:
| | `sandbox-fetch` | `sandbox-exec` |
|---|---|---|
| 网络 | 外部访问,白名单允许 | **无** |
| 代码执行 | **无** | 是 |
两者都无法同时执行任意代码*并*连接网络。
抓取内容中的 prompt 注入无法运行。运行的代码无法
向外发送数据。这种隔离才是重点,也是为什么它们不是
一个方便的服务器的原因。
## 与众不同之处
大多数沙箱只是描述其安全保障。而本项目**在 CI 中攻击自身**——
针对真实容器运行 40 个测试,尝试 fork bomb、死循环、
DNS 数据泄露、权限提升、审计伪造、路径遍历,以及通过重定向
进行 SSRF。通过这种方式发现了六个真实的 bug,这些在代码审查中
都不可见:
- `--network=none` 使得 MCP 端口不可达——而显而易见的
替代方案仍然会解析 DNS,这会每次通过一个子域名标签泄露数据。
- 挂钟时间超时**什么也杀不掉**:跨 uid 发送信号需要 `CAP_KILL`。
- Fork bomb 的幸存进程比其调用存活得更久,并导致沙箱性能下降,直到
重启。
- `RLIMIT_NPROC` 是按 UID 在全系统范围内统计的,而不是按进程树。
- `PATH` 被原封不动地从宿主环境中继承。
- 在 uid 不匹配的绑定挂载上,审计追踪静默地写到了**任何地方(无效路径)**。
[SECURITY.md](SECURITY.md) 陈述了威胁模型*以及*非目标。
非目标同样重要:过度吹嘘自己的沙箱,还不如一个
告诉你它止步于何处的沙箱。
## 快速开始:`sandbox-exec`
```
podman build -f Dockerfile.exec -t sandbox-exec:dev .
cp policies/exec.example.yaml ./exec.yaml
mkdir -p -m 700 audit approvals
# 一个*内部*网络,而不是 --network=none:服务器仍然必须能够
# 在其 MCP 端口上被访问。请参阅下面的“Networking”——正是这种组合
# 真正阻止了 egress,并且这已通过 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
```
通过 URL 将其注册到任何 MCP 客户端——例如:
```
hermes mcp add sandbox-exec --url http://127.0.0.1:8801/mcp
```
### 网络
`--network=none` 是显而易见的选择,但它不起作用:它导致
MCP 端口不可达,因此无法调用服务器。我们需要的是一个
*宿主机*可以向内访问,而容器无法
向外访问的网络:
| 标志 | 原因 |
|---|---|
| `--network=
` | 无网关或地址伪装,因此没有离开宿主机的路由。发布的端口仍能访问服务器。 |
| `--dns=none` | 内部网络仍然会连接解析器,DNS 查询会离开宿主机。如果没有这个设置,`example.com` 会发生解析——而任何能解析的域名都可以每次通过一个子域名携带数据外出。 |
| `--cap-add=CHOWN` | 挂载在 `/workspace` 上的 tmpfs 会重置镜像设置的归属权;服务器在启动时需要将工作区共享给执行用户的组。如果缺少此权限,它会直接报错,而不是扩大权限。 |
| `-e AUTH_TOKEN=...` | 要求在每个请求中包含 `Authorization: Bearer `。这是可选的,未设置时服务器会发出警告——但除了回环绑定之外,这是网络和 `run_command` 之间唯一的屏障。 |
| `--cap-add=KILL` | 服务器以容器内的 root 身份运行,但执行的代码以另一个 uid 运行,跨 uid 发送信号需要此权限。**没有它,超时机制无法杀掉任何东西,失控的进程将继续存活**——死循环仍然会死于 CPU rlimit,但处于休眠状态的进程不会。 |
其余权限全被丢弃:没有 `DAC_OVERRIDE`(root 无法绕过文件
权限),没有 `NET_*`,没有 `SYS_*`。
由 `tests/security/test_container.py` 验证:TCP 外发、DNS 解析
和宿主网络访问均被尝试且必须失败,并且 fork bomb
不能导致沙箱处于降级状态。
## 快速开始:`sandbox-fetch`
抓取器则恰恰相反:它可以访问网络,因此它从不执行
任何内容,不需要任何 capabilities,并以无特权方式从 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` 并不是为了好看。如果没有它,绑定挂载的审计
目录将由宿主机用户拥有——这在 rootless 容器中映射为 uid 0——
而服务器以 uid 10003 运行,因此每次审计写入都会失败。
记录仍然会到达 stderr,但你要实际去读取的文件会保持
为空。服务器在启动时会对此发出警告。
## 工具
**`sandbox-exec`** —— 四个工具:
| 工具 | 作用 |
|---|---|
| `workspace(action, path, content)` | `read`、`write`、`list`、`reset` |
| `run_script(language, source)` | 在白名单允许的语言中运行源代码 |
| `run_command(command)` | shell,仅在启用 `shell.enabled` 时 |
| `skill(action, path, content)` | `list`、`read`,以及在可写时进行 `save` |
使用带有动作参数的名词,而不是七个动词:`list_files` 过去常常被拿来与
无关问题中的普通词汇“List”进行匹配,结果 agent
回答了关于沙箱的问题。Schema 在每次
prompt 中还会占用约 1.8 KB 的空间。
命令输出上限为 `limits.max_output_bytes`(默认为 **8000**)并
直接落入调用者的上下文中,因此这是此服务器产生的
最昂贵的开销。将批量结果写入工作区,然后只读回你
需要的内容。截断会保留头部*和*尾部,这样在嘈杂输出
末尾的 traceback 就能留存下来。
**`sandbox-fetch`** —— `fetch_url(url)`。返回提取的文本,或者一个指明
触发了哪条规则的拒绝响应。
### 抓取器的检查顺序
这些检查在**每次重定向跳转**时都会重新运行,而不仅仅是对你
传入的 URL。跳转是指进程实际发起的请求,因此重定向
落地到内部地址上是一个活生生的 SSRF,无论链路
在何处终结。
| 检查项 | 说明 |
|---|---|
| 协议 | 默认仅限 `https`。 |
| URL 中的凭据 | 总是拒绝:`https://allowed.example@evil.example` 在人类看来像是白名单地址,实际上是连接到攻击者。 |
| 黑名单,然后是白名单 | `example.com` 仅匹配该主机,不匹配其他;`*.example.com` 也匹配子域名。基于 punycode 规范化后的标签边界,因此 `*.example.com` 永远不会匹配 `notexample.com`。空的白名单是一个启动错误,而不是隐式地允许所有。 |
| 地址 | 被解析,如果属于私有、回环、链路本地、多播、保留或未指定地址则会被拒绝——包括 IPv4 映射的 IPv6,如 `::ffff:127.0.0.1`。 |
| 大小 | 在流式传输时计数,绝不信任 `Content-Length`。 |
| 注入审查 | 可选,并且在失败时闭合(安全失败):无法访问的审查者将保留内容。 |
User-Agent 是**仅限策略设定,且故意不允许调用者设置**。
一个由模型控制的 Header,被发送到白名单主机,就是一个数据泄露
通道——这正是双容器隔离所要防止的
特性。
### API 路由:请求 API,而不是页面
抓取的 HTML 在你想要的文本周围带有菜单、信息框和编辑链接。
这浪费了调用者的字符预算,并且标记残留物在审查者看来
就像被注入的指令。如果站点为
相同的内容发布了 JSON,策略可以指明这一点:
```
api_routes:
- match: "*.wikipedia.org"
when_path: "^/wiki/(?P.+)$"
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
```
在同一篇文章上测量的结果:**559 个干净文本字符,而不是
大约 8000 个页面装饰字符。**
没有针对特定站点的代码——一个路由只需四行策略,因此添加站点
是一个可审查的 diff,而不是一次发布。并且路由是**重写,绝不是
绕过**:重写后的 URL 在请求任何内容之前,都要经过
完整的白名单和 SSRF 检查,替换的值经过了百分号编码,因此标题
无法逃逸出其路径段,并且指定了不存在占位符的模板
会在启动时失败,而不是在首次使用时。
## 策略
请参阅 [`policies/exec.example.yaml`](policies/exec.example.yaml) 和
[`policies/fetch.example.yaml`](policies/fetch.example.yaml)。将其以
只读方式挂载,这样容器就无法重写其自身的规则。缺少或
格式错误的策略会导致启动失败——这里没有宽松的
回退机制。环境变量仅携带操作设置(`POLICY_PATH`、
`AUDIT_PATH`、`HOST`、`PORT`);能力决策存在于策略文件中,
在那里它们是结构化的、可审查的,并且在 `podman inspect` 中不可见。
## 审批
执行器没有网络,因此审批不能是 webhook。它通过
绑定挂载的目录进行传递:
```
/approvals/pending/.json server writes, then blocks
/approvals/decisions/.json anything can write the verdict
```
超时即视为拒绝。没有内置任何传输机制——将该队列桥接到
Telegram、Slack、TUI 或 Web UI 是一个独立的组件,这也
使得本项目免于依赖其中任何一个。手动审批只需:
```
echo '{"approved":true,"reason":"looks fine"}' > approvals/decisions/.json
```
提供程序:`deny_all`(默认)、`file_queue`、`command`、`webhook`。
一个用于另一端的工作代理程序随
[`contrib/approval-broker/`](contrib/approval-broker/) 一起提供——一个仅使用标准库的文件,
带有控制台和 Telegram 适配器。它故意位于 `src/` 之外,因此没有
聊天传输机制能最终进入沙箱镜像:
```
python3 contrib/approval-broker/approval_broker.py --queue ./approvals
```
## 技能
工作区是一个 tmpfs,并会随容器一起消亡,因此 agent
构建的任何内容都不会保留。**技能**目录是用于可重用
脚本的可持久化存储,默认关闭:
```
skills:
enabled: true
path: /skills
writable: false # see below
max_mb: 16
```
添加了 `skill` 工具:总是允许 `list` 和 `read`,仅在 `writable` 时允许 `save`。
`writable` 默认为 **false** 是有意为之。一个持久的、可写的
可执行代码存储是 prompt 注入可以留下某些东西的
地方,等待稍后的无关调用去拾取——一种不同于这里
其他所有内容的威胁,这里每个工作区都是临时的。当它为 false 时,`save_skill`
工具根本不会被注册,而不是注册了然后再失败;一个不存在的
工具是无法被说服去尝试的。技能永远不会被隐式执行:它们是
惰性文件,调用者可以选择通过正常的受控工具来运行它们。
除非你希望 agent 自行管理其技能,否则请以只读方式
挂载它,如果你这样做了,请注意
审计日志中的 `skill_saved`:
```
-v ./skills:/skills:ro
```
## 安全
阅读 [SECURITY.md](SECURITY.md)——它记录了威胁模型*以及*
非目标,这两者同样重要。简短版本:容器就是
边界;其他一切都只是缩小了其内部
发生的事情的范围。
安全保障由对抗性测试套件覆盖,而不是仅仅口头断言:
```
make check # ruff (incl. bandit rules), mypy --strict, bandit, pip-audit
make test-security # fork bombs, infinite loops, network access, privilege escalation
```
## 开发
```
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` 必须通过。参见
[CONTRIBUTING.md](CONTRIBUTING.md)——唯一的规则是,任何扩大
沙箱权限范围的更改,都必须附带一个在没有它的情况下会失败的
对抗性测试。
## 文档
| | |
|---|---|
| [SECURITY.md](SECURITY.md) | 威胁模型及非目标 |
| [CONTRIBUTING.md](CONTRIBUTING.md) | 配置、风格以及针对安全变更的规则 |
| [ROADMAP.md](ROADMAP.md) | 接下来做什么,以及故意不计划做什么 |
| [CHANGELOG.md](CHANGELOG.md) | 维护更新日志,semver |
| [contrib/approval-broker/](contrib/approval-broker/) | 审批队列的人工操作端 |
## 许可
MIT —— 请参阅 [LICENSE](LICENSE)。标签:DLL 劫持, MCP服务, Python, Streamlit, 大语言模型, 容器隔离, 无后门, 沙箱, 访问控制, 请求拦截, 逆向工具