跳转到内容

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。

终端窗口
# 非特权 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 的故障排查表。

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 文件系统 临时目录可写,但随沙箱销毁

../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 参数显式注入环境变量,默认继承不了宿主任何凭据。
实验 宿主 沙箱 结论
PID 隔离 36 个进程可见 4 个进程 看不到宿主进程
网络隔离 可连外网 连接失败 默认无网络
文件系统 全可写 /etc 只读、/work 可写 只读根
资源限额 — 300MB 分配报 MemoryError 限额生效
逃逸尝试 — 读密钥 / 出网 / 写 /etc 全失败 拦得住

运行方式:

终端窗口
cd ../agent-sandbox-lab
chmod +x exp/*.sh run_all.sh sandbox_run.py
./run_all.sh | tee RESULTS.md
实验 脚本 验证点
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 读密钥 / 出网 / 写系统目录全失败
终端窗口
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 章)。

把沙箱包成 MCP server,Agent 就能通过工具调用使用它:

from mcp.server.fastmcp import FastMCP
from sandbox_run import run, WORK
from 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()

两个安全细节值得注意:

  1. _safe_path 防目录穿越:../../etc/passwd 会被拒绝。文件类工具必须做这层校验,否则等于绕过沙箱读宿主文件。
  2. 工具描述要明确边界:告诉模型「只能操作 /work」,减少它尝试越界的概率。

完整示例见 ../agent-sandbox-lab/code/sandbox_mcp.py。

不要直接给沙箱开放网络。正确做法是走出网代理:

沙箱 (--network none) ──► 本地代理(白名单) ──► 互联网
  • 代理只放行允许的域名(如 pypi.org、files.pythonhosted.org)
  • 所有出网请求记日志,便于审计
  • 需要更强控制时做 TLS 拦截 / 内容检查
  • DNS 也走代理,防止 DNS 外泄

原理与威胁分析见 第 6 章 6.6。

  • 换成 Firecracker microVM(多租户 / 不可信)
  • 环境预热成池,控制面做秒级调度
  • 每会话独立 + 用完即销毁
  • 不放长期 secret,用短期 token
  • 全量命令审计 + 资源计量
  • 超时 / OOM / 僵尸环境自动清理
  • 文件工具做路径校验(防目录穿越)
  • 需要联网时走白名单代理,绝不直连
  1. 把方案 A 跑通,让一个 Agent(或脚本)通过 MCP 工具在沙箱里写文件并执行。
  2. 故意在命令里写 cat /etc/shadow、curl evil.com、echo x > /etc/evil,观察沙箱如何拦截,并记录输出。