Leovilhena/sandbox-mcp

GitHub: Leovilhena/sandbox-mcp

一个通过双容器隔离架构为 LLM agent 提供安全网页抓取与代码执行能力的策略驱动型 MCP 服务器。

Stars: 1 | Forks: 0

# sandbox-mcp **让 LLM 抓取网页、编写脚本并运行它们——同时让这些操作远离你的 agent 进程。** [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/39/39faa54be350a1dab8afd3b2fb8c1c83e4d9cff84abfef2374d19a18053687c4.svg)](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/ci.yml) [![Images](https://static.pigsec.cn/wp-content/uploads/repos/cas/1d/1dc9d2a9e68aa0e60685704e4ea6dea9513b4d478b213346934e6b6f6ed33daf.svg)](https://github.com/Leovilhena/sandbox-mcp/actions/workflows/images.yml) [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](pyproject.toml) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Checked with mypy](https://img.shields.io/badge/mypy-strict-2a6db2)](pyproject.toml) [![Linted with ruff](https://img.shields.io/badge/ruff-passing-261230)](pyproject.toml) [![Security tests](https://img.shields.io/badge/adversarial%20tests-40%20in%20containers-critical)](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/<id>.json server writes, then blocks /approvals/decisions/<id>.json anything can write the verdict ``` 超时即视为拒绝。没有内置任何传输机制——将该队列桥接到 Telegram、Slack、TUI 或 Web UI 是一个独立的组件,这也 使得本项目免于依赖其中任何一个。手动审批只需: ``` echo '{"approved":true,"reason":"looks fine"}' > approvals/decisions/<id>.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)。</div><div><strong>标签:</strong>DLL 劫持, MCP服务, Python, Streamlit, 大语言模型, 容器隔离, 无后门, 沙箱, 访问控制, 请求拦截, 逆向工具</div></article></div> <!-- 人机验证 --> <script> (function () { var base = (document.querySelector('base') && document.querySelector('base').getAttribute('href')) || ''; var path = base.replace(/\/?$/, '') + '/cap-wasm/cap_wasm.min.js'; window.CAP_CUSTOM_WASM_URL = new URL(path, window.location.href).href; })(); </script> </body> </html>