10 实战搭建
本章导读|预计 25 分钟(含动手)
前面九章都是概念,这一章全部落到能跑的命令上。两套方案任选:
- 方案 A(本机、零依赖):bubblewrap / Linux namespaces —— 不需要 Docker、不需要 root,已实测可跑,代码在
../agent-sandbox-lab/。- 方案 B(容器):Docker —— 更接近生产,但需要 Docker 环境。
建议顺序:先用方案 A 把「隔离是什么」跑出来,再看方案 B 的 flag 对应关系,最后包成 MCP 工具。
10.1 方案 A:bubblewrap 沙箱(先跑这个)
Section titled “10.1 方案 A:bubblewrap 沙箱(先跑这个)”配套代码在 ../agent-sandbox-lab/,实测输出见 ../agent-sandbox-lab/RESULTS.md。
10.1.1 环境自检
Section titled “10.1.1 环境自检”# 非特权 user namespace 是否可用unshare --user --map-root-user echo "user-ns OK"
# bwrap 是否可用bwrap --unshare-all --ro-bind / / echo "bwrap OK"两项都输出 OK 即可继续;失败见 ../agent-sandbox-lab/README.md 的故障排查表。
10.1.2 核心隔离参数
Section titled “10.1.2 核心隔离参数”bwrap \ --unshare-all # 用户/IPC/PID/网络/UTS/cgroup 全隔离 --die-with-parent # 父进程退出,沙箱一并结束 --ro-bind /usr /usr # 系统目录只读 --ro-bind /etc /etc --ro-bind /dev/null /etc/shadow # 屏蔽敏感文件(用空设备覆盖) --symlink usr/bin /bin # 让 PATH 里的 /bin、/sbin 能解析 --symlink usr/sbin /sbin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --proc /proc --dev /dev --tmpfs /tmp --bind ./work /work --chdir /work # 唯一可写工作区 --clearenv --setenv PATH /usr/bin:/usr/sbin:/bin:/sbin \ --setenv HOME /work --setenv LANG C.UTF-8 -- sh -lc 'echo hello from sandbox; id'逐条对应到第 5 章的隔离维度:
| 参数 | 隔离的是 | 效果 |
|---|---|---|
--unshare-all |
命名空间 | 独立 PID / 网络 / 挂载 / 用户视图 |
--ro-bind |
文件系统 | 系统目录只读,改不了 |
--bind ./work /work |
文件系统 | 唯一可写区,任务成果都在这里 |
--clearenv |
环境变量 | 读不到宿主的 API_KEY 等凭据 |
--die-with-parent |
生命周期 | 不产生孤儿进程 |
--tmpfs /tmp |
文件系统 | 临时目录可写,但随沙箱销毁 |
10.1.3 可复用包装器
Section titled “10.1.3 可复用包装器”../agent-sandbox-lab/sandbox_run.py 就是「Agent 沙箱」的最小形态:把上面的参数固化成列表,对外只暴露一个 run()。
from sandbox_run import run
result = run("python3 /work/agent_code.py", timeout=30)print(result["exit_code"], result["stdout"], result["stderr"], result["elapsed"])run() 返回结构化结果,方便回填给 LLM:
{ "exit_code": 0, "stdout": "...", "stderr": "", "elapsed": 0.42,}关键实现细节(值得抄):
- 没有 bwrap 时返回
exit_code=127+ 安装提示,而不是崩溃; - 用
subprocess.run(..., timeout=timeout)硬超时,超时返回exit_code=-1; - 通过
env参数显式注入环境变量,默认继承不了宿主任何凭据。
10.1.4 实测结果(节选)
Section titled “10.1.4 实测结果(节选)”| 实验 | 宿主 | 沙箱 | 结论 |
|---|---|---|---|
| PID 隔离 | 36 个进程可见 | 4 个进程 | 看不到宿主进程 |
| 网络隔离 | 可连外网 | 连接失败 | 默认无网络 |
| 文件系统 | 全可写 | /etc 只读、/work 可写 |
只读根 |
| 资源限额 | — | 300MB 分配报 MemoryError |
限额生效 |
| 逃逸尝试 | — | 读密钥 / 出网 / 写 /etc 全失败 |
拦得住 |
运行方式:
cd ../agent-sandbox-labchmod +x exp/*.sh run_all.sh sandbox_run.py./run_all.sh | tee RESULTS.md10.1.5 五个实验分别验证什么
Section titled “10.1.5 五个实验分别验证什么”| 实验 | 脚本 | 验证点 |
|---|---|---|
| 01 PID 隔离 | 01-pid-isolation.sh |
沙箱内看不到宿主进程 |
| 02 网络隔离 | 02-network-isolation.sh |
默认无网络,无法外泄 |
| 03 文件系统隔离 | 03-filesystem-isolation.sh |
只读根 + 唯一可写 /work |
| 04 资源限额 | 04-resource-limits.sh |
内存 / 文件大小受限 |
| 05 逃逸尝试 | 05-escape-attempt.sh |
读密钥 / 出网 / 写系统目录全失败 |
10.2 方案 B:最小 Docker 沙箱
Section titled “10.2 方案 B:最小 Docker 沙箱”docker run --rm \ --network none \ --read-only \ --tmpfs /tmp:rw,size=64m \ --cpus=1 \ --memory=512m \ --pids-limit=128 \ --cap-drop ALL \ --security-opt no-new-privileges \ --user 65534:65534 \ -v "$PWD/work:/workspace:rw" \ python:3.12-slim \ python /workspace/agent_code.py关键 flag 解释:
| flag | 作用 | 对应方案 A 的哪个参数 |
|---|---|---|
--network none |
默认断网,最重要 | --unshare-all 的网络部分 |
--read-only |
根文件系统只读,防篡改 | --ro-bind /usr /usr |
--tmpfs /tmp:... |
给需要写的目录一个临时可写层 | --tmpfs /tmp |
--cpus / --memory / --pids-limit |
防资源滥用 | ulimit / cgroup |
--cap-drop ALL |
去掉所有 Linux capabilities | bwrap 默认降权 |
--security-opt no-new-privileges |
禁止 setuid/setgid 提权 | 无直接对应(bwrap 默认非 root、无 setuid) |
--user 65534:65534 |
以 nobody 运行,非 root | user namespace |
-v ...:/workspace:rw |
只把工作目录挂进去 | --bind |
⚠️ 容器不等于强隔离:以上是「配置正确的容器」,仍共享宿主内核。多租户场景请升级到 microVM / gVisor(第 5 章)。
10.3 暴露成 MCP 工具
Section titled “10.3 暴露成 MCP 工具”把沙箱包成 MCP server,Agent 就能通过工具调用使用它:
from mcp.server.fastmcp import FastMCPfrom sandbox_run import run, WORKfrom pathlib import Path
mcp = FastMCP("sandbox")
def _safe_path(path: str) -> Path: """确保访问不越出工作区(防目录穿越)。""" p = (Path(WORK) / path).resolve() root = Path(WORK).resolve() if root != p and root not in p.parents: raise ValueError(f"path escapes workspace: {path}") return p
@mcp.tool()def exec(cmd: str, timeout: int = 30) -> dict: """在沙箱里执行 shell 命令。""" return run(cmd, timeout=timeout)
@mcp.tool()def read_file(path: str) -> str: """读取沙箱工作区文件。""" return _safe_path(path).read_text()
@mcp.tool()def write_file(path: str, content: str) -> str: """写入沙箱工作区文件。""" p = _safe_path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content) return f"written {len(content)} bytes to {path}"
@mcp.tool()def list_files(path: str = ".") -> list[str]: """列出沙箱工作区文件。""" return sorted(x.name for x in _safe_path(path).iterdir())
if __name__ == "__main__": mcp.run()两个安全细节值得注意:
_safe_path防目录穿越:../../etc/passwd会被拒绝。文件类工具必须做这层校验,否则等于绕过沙箱读宿主文件。- 工具描述要明确边界:告诉模型「只能操作
/work」,减少它尝试越界的概率。
完整示例见 ../agent-sandbox-lab/code/sandbox_mcp.py。
10.4 要联网时加白名单
Section titled “10.4 要联网时加白名单”不要直接给沙箱开放网络。正确做法是走出网代理:
沙箱 (--network none) ──► 本地代理(白名单) ──► 互联网- 代理只放行允许的域名(如
pypi.org、files.pythonhosted.org) - 所有出网请求记日志,便于审计
- 需要更强控制时做 TLS 拦截 / 内容检查
- DNS 也走代理,防止 DNS 外泄
原理与威胁分析见 第 6 章 6.6。
10.5 生产化清单
Section titled “10.5 生产化清单”- 换成 Firecracker microVM(多租户 / 不可信)
- 环境预热成池,控制面做秒级调度
- 每会话独立 + 用完即销毁
- 不放长期 secret,用短期 token
- 全量命令审计 + 资源计量
- 超时 / OOM / 僵尸环境自动清理
- 文件工具做路径校验(防目录穿越)
- 需要联网时走白名单代理,绝不直连
动手练习 10
Section titled “动手练习 10”- 把方案 A 跑通,让一个 Agent(或脚本)通过 MCP 工具在沙箱里写文件并执行。
- 故意在命令里写
cat /etc/shadow、curl evil.com、echo x > /etc/evil,观察沙箱如何拦截,并记录输出。