MCP 代码执行工具与沙箱:解释器类工具的设计、隔离与审计

MCP 代码执行工具与沙箱实践:代码解释器类工具的接口设计、容器与 gVisor、WASM 等隔离方案选型、CPU 内存磁盘与超时限制、网络与文件系统隔离、输出捕获与截断、会话状态管理以及安全边界与审计。

1. 代码执行工具的定位

模型能写代码,但「写出来」和「跑起来」之间隔着一整个世界:依赖、解释器、文件、网络。代码执行工具(code interpreter / code execution tool)把「跑起来」这一步交给服务器,模型只负责产出代码,服务器负责在一个受控环境里执行并把结果回传。

1.1 为什么模型要「执行」而非「心算」

大模型在算术、日期推导、数据聚合、字符串处理上并不可靠——它是概率续写,不是计算器。把这类任务卸载给真实解释器,本质上是把「语言能力」和「计算能力」解耦。

# 模型心算 vs 真实执行的典型差异
# 1) 大数乘法: 模型常给出"看起来对"的结果,实际错
# 2) 日期推算: 闰年、时区、工作日边界极易出错
# 3) 数据统计: 均值/中位数/分组聚合靠"感觉"
# 4) 正则匹配: 复杂正则的匹配结果不可预测
# 交给解释器: 确定性、可复现、可验证

1.2 典型使用场景

场景输入输出隔离要求
数据分析CSV/JSON + 分析脚本统计结果、图表中(无网络、只读数据)
数学计算表达式/算法数值结果低(纯计算)
文本处理大文本 + 转换逻辑清洗后文本低
图表生成数据 + 绘图代码PNG/SVG 资源中
工具编排调内部 API 的脚本聚合结果高(受控网络出口)

一句话:代码执行工具把「模型不可靠的推理」换成「解释器确定性的执行」,代价是必须给它套上一个足够结实的笼子。

2. 工具接口设计

接口设计的核心问题有三个:一次调用执行多少代码、执行环境是否有状态、结果怎么回来。

2.1 工具定义

{
  "name": "execute_python",
  "description": "在隔离沙箱中执行 Python 代码并返回 stdout/stderr 与产物。适合数据计算、文本处理与图表生成。",
  "inputSchema": {
    "type": "object",
    "properties": {
      "code": { "type": "string", "description": "要执行的 Python 源码" },
      "timeout_ms": { "type": "integer", "default": 10000, "maximum": 60000 },
      "session_id": { "type": "string", "description": "复用状态时可传上次返回的会话 ID" },
      "files": {
        "type": "array",
        "items": { "type": "string" },
        "description": "需要挂载进沙箱的只读输入文件路径"
      }
    },
    "required": ["code"]
  }
}

2.2 有状态与无状态

模型机制优点缺点
无状态每次调用起新进程/容器隔离彻底、易水平扩展重复 import、无法分步调试
有状态(kernel)常驻解释器会话变量跨调用保留、像 Jupyter会话泄漏、状态污染、难回收
混合默认无状态,显式开启会话兼顾实现复杂

2.3 会话化接口

// 有状态会话:一次调用创建,后续调用复用
interface ExecResult {
  session_id: string;
  stdout: string;
  stderr: string;
  exit_code: number;
  truncated: boolean;
  artifacts: Array<{ name: string; uri: string; mime: string }>;
}

server.tool(
  "execute_python",
  "在隔离沙箱中执行 Python 代码",
  { code: z.string(), session_id: z.string().optional(), timeout_ms: z.number().optional() },
  async ({ code, session_id, timeout_ms }) => {
    const session = session_id
      ? await sessions.resume(session_id)
      : await sessions.create({ timeoutMs: timeout_ms ?? 10_000 });
    const result = await session.exec(code);
    return { content: [{ type: "text", text: render(result) }] };
  }
);

一句话:无状态是安全默认值,有状态是效率选项——把「是否保留变量」做成显式参数,而不是服务器的隐式行为。

3. 沙箱技术选型

「沙箱」不是一种技术,而是一个谱系:从进程级限制到硬件虚拟化,隔离强度与启动开销此消彼长。

3.1 隔离级别对比

方案隔离边界启动延迟强度适用
子进程 + rlimit同内核<10ms弱纯计算、可信代码
容器(Docker/nsjail)namespace/cgroup50-300ms中主流生产方案
gVisor用户态内核100-500ms较强多租户不可信代码
Firecracker microVMKVM 硬件虚拟化100-200ms强强隔离 + 快速启动
WASM 运行时线性内存 + 能力模型<5ms强(能力受限)轻量、无系统调用

3.2 选型决策

# 决策路径
# 1) 代码是否可信? 否 → 至少 gVisor 级别
# 2) 是否多租户共享节点? 是 → 拒绝普通容器,选 microVM/gVisor
# 3) 是否需要真实系统调用/原生依赖? 否 → WASM 是性价比最高
# 4) 是否需要 GPU/大内存? 是 → 容器 + 独占节点更现实
# 默认建议: 容器起步,多租户场景升级到 gVisor,敏感场景用 microVM

3.3 容器沙箱配置要点

# docker run 关键安全参数
# 用户: 非 root,无 sudo
--user 65534:65534
# 只读根文件系统 + 独立可写 tmpfs
--read-only --tmpfs /tmp:size=64m,mode=1777
# 丢弃全部 capability,只按需加回
--cap-drop ALL --security-opt no-new-privileges
# 禁用特权与设备
--security-opt seccomp=/etc/mcp/seccomp.json
--pids-limit 64
# 资源上限
--memory 512m --memory-swap 512m --cpus 1.0
# 网络: 默认无
--network none

4. 资源限制

沙箱的「笼子」由四类资源构成:CPU、内存、磁盘、进程/时间。任何一类不设限,其它三类都会被绕过。

4.1 限制清单

资源手段典型值不设限的后果
CPUcgroup cpu.max1-2 核占满节点、影响邻居
内存cgroup memory.max256-1024 MBOOM 拖垮宿主
磁盘配额/tmpfs 大小64-512 MB写满磁盘
进程数pids.max32-128fork 炸弹
文件描述符RLIMIT_NOFILE256句柄耗尽
墙钟时间超时杀死10-60s挂死会话
输出大小截断64 KB-1 MB撑爆上下文

4.2 超时与 kill 的实现

import resource, signal, subprocess

def run_with_limits(code_path: str, timeout_s: int = 10) -> dict:
    def preexec():
        # 地址空间上限 512MB
        resource.setrlimit(resource.RLIMIT_AS, (512 << 20, 512 << 20))
        # CPU 时间上限(秒)
        resource.setrlimit(resource.RLIMIT_CPU, (timeout_s, timeout_s))
        # 进程数上限
        resource.setrlimit(resource.RLIMIT_NPROC, (32, 32))
        # 单文件大小上限 64MB
        resource.setrlimit(resource.RLIMIT_FSIZE, (64 << 20, 64 << 20))

    proc = subprocess.Popen(
        ["python", "-I", "-S", code_path],  # -I 隔离模式: 忽略环境变量与用户 site
        preexec_fn=preexec,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        start_new_session=True,  # 独立进程组,便于整组 kill
    )
    try:
        out, err = proc.communicate(timeout=timeout_s)
        return {"stdout": out, "stderr": err, "exit_code": proc.returncode}
    except subprocess.TimeoutExpired:
        os.killpg(os.getpgid(proc.pid), signal.SIGKILL)  # 杀整个进程组
        return {"stdout": b"", "stderr": b"timeout", "exit_code": -9}

一句话:资源限制必须「全都要」——只限内存不限进程数,fork 炸弹照样能拖垮节点。

5. 网络隔离

网络是代码执行沙箱里最危险的出口:它能外联 C2、扫描内网、泄露数据、下载恶意载荷。默认应该是「没有网络」。

5.1 网络策略层级

# 从最严到最松
# L0 无网络: --network none,loopback 都不通(推荐默认)
# L1 仅 loopback: 允许本地进程间通信,不出口
# L2 白名单出口: 只允许访问指定域名/IP(包管理器、内部 API)
# L3 全出口: 几乎等于放弃网络隔离(仅可信场景)
# 绝大多数代码执行任务落在 L0/L1;确需装包时才用 L2

5.2 白名单出口实现

# 用 iptables 只放行指定目标,其余 DROP
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -d 10.0.0.0/8 -p tcp --dport 443 -j ACCEPT   # 内部 API
iptables -A OUTPUT -d 1.2.3.4 -p tcp --dport 443 -j ACCEPT      # 镜像源
# DNS 也要限制,否则可被用作数据外泄通道
iptables -A OUTPUT -d 10.0.0.53 -p udp --dport 53 -j ACCEPT

5.3 DNS 与元数据端点

# 两个常被忽略的出口
# 1) DNS 隧道: 把数据编码进子域名查询外泄 → 限制解析器
# 2) 云元数据端点 169.254.169.254: 可窃取实例凭证
#    → 强制 IMDSv2、iptables DROP 该地址、或用无角色实例
# 网络隔离的漏洞往往不在"能不能连百度",而在这些侧信道

6. 文件系统隔离

代码需要读输入、写产物,但绝不能看到宿主机的敏感文件。文件系统隔离要回答三个问题:能看什么、能写哪里、产物怎么出来。

6.1 挂载策略

路径权限说明
/workspacerw(tmpfs)唯一可写区,会话结束即销毁
/inputsro本次任务输入,按调用挂载
/outputsrw(受限)产物落盘,只允许约定格式
/usr /libro运行时只读
/etc/passwd屏蔽/伪造不给真实用户信息
/proc /sys受限挂载不暴露宿主信息

6.2 输入输出的安全处理

import os, shutil

ALLOWED_INPUT_ROOTS = ["/data/tasks"]

def stage_inputs(task_id: str, files: list[str]) -> str:
    workdir = f"/workspace/{task_id}"
    os.makedirs(workdir, exist_ok=True)
    for f in files:
        real = os.path.realpath(f)
        # 防路径穿越: 解析后的真实路径必须在允许根目录内
        if not any(real.startswith(r + os.sep) for r in ALLOWED_INPUT_ROOTS):
            raise PermissionError(f"input path not allowed: {f}")
        shutil.copy2(real, workdir)
    return workdir

def collect_outputs(workdir: str) -> list[dict]:
    arts = []
    for name in os.listdir(workdir):
        path = os.path.join(workdir, name)
        # 只回收约定产物,不回收中间文件
        if name.endswith((".png", ".csv", ".json")) and os.path.isfile(path):
            arts.append({"name": name, "size": os.path.getsize(path)})
    return arts

6.3 符号链接与挂载逃逸

# 常见逃逸手法
# 1) 符号链接指向宿主敏感文件 → realpath 校验 + 禁 symlink 跟随
# 2) /proc/self/root 绕过路径检查 → 限制 /proc 挂载
# 3) 挂载传播把宿主目录带进来 → 挂载用 private 传播模式
# 4) 硬链接 + 可写目录组合 → 只挂载必要目录
# 文件系统隔离的正确姿势是"白名单挂载",不是"黑名单屏蔽"

7. 输出捕获与截断

模型能看到的只有服务器回传的文本。捕获策略直接决定「模型能不能看懂结果」与「上下文会不会爆」。

7.1 分离三类输出

MAX_STDOUT = 32 * 1024   # 32 KB
MAX_STDERR = 8 * 1024

def capture(proc_result: dict) -> dict:
    out = proc_result["stdout"].decode("utf-8", "replace")
    err = proc_result["stderr"].decode("utf-8", "replace")
    return {
        "stdout": truncate(out, MAX_STDOUT),
        "stderr": truncate(err, MAX_STDERR),
        "exit_code": proc_result["exit_code"],
        # 明确告知是否被截断,让模型知道结果不完整
        "truncated": len(out) > MAX_STDOUT or len(err) > MAX_STDERR,
    }

def truncate(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    head = text[: limit * 2 // 3]
    tail = text[-limit // 3 :]   # 保留头部与尾部,中间省略
    return f"{head}\n... [省略 {len(text) - limit} 字符] ...\n{tail}"

7.2 结构化结果优先

输出形式优点风险
原始 stdout通用、无损噪声大、易超长
JSON 包装结构清晰、可裁剪需约定协议
摘要 + 产物引用省 token模型可能看不到细节
分页读取按需取用增加往返

一句话:截断要「保头保尾 + 显式告知」,否则模型会基于残缺输出得出自信的错误结论。

8. 会话与状态管理

有状态执行像 Jupyter:变量、导入、文件跨调用保留。方便的另一面是资源泄漏与状态污染。

8.1 会话生命周期

// 会话回收策略
const sessions = new Map<string, Session>();
const IDLE_MS = 5 * 60_000;

setInterval(() => {
  const now = Date.now();
  for (const [id, s] of sessions) {
    if (now - s.lastUsedAt > IDLE_MS) {
      s.kernel.kill();          // 杀解释器
      s.container.dispose();    // 销毁沙箱
      sessions.delete(id);      // 释放索引
    }
  }
}, 30_000);

8.2 状态污染与隔离

# 有状态会话的三类污染
# 1) 变量污染: 上一轮定义的变量影响下一轮 → 会话按任务隔离
# 2) 文件污染: 临时文件残留 → 每会话独立 /workspace
# 3) 依赖污染: pip install 装进共享镜像 → 每会话 overlay 文件系统
# 会话 ID 必须绑定到"调用者身份",不能跨用户复用

9. 安全边界与审计

代码执行是 MCP 工具里权限最高的一类——它等价于「在服务器上跑任意代码」。安全边界与审计不是可选项。

9.1 纵深防御

# 七层防线
# 1) 认证: 谁在调用(OAuth / mTLS)
# 2) 授权: 该用户能执行代码吗、能用多大配额
# 3) 沙箱: 内核级隔离(gVisor/microVM)
# 4) 资源: cgroup + rlimit
# 5) 网络: 默认无出口
# 6) 文件: 白名单挂载
# 7) 审计: 全量记录
# 任何一层都不是万能的,组合才有意义

9.2 审计事件

{
  "event": "code_execution",
  "ts": "2026-10-04T10:12:33.481+08:00",
  "principal": "user:alice@example.com",
  "session_id": "sess_7f3a91",
  "code_hash": "sha256:9c1f...",
  "code_size": 2048,
  "sandbox": { "runtime": "gvisor", "image": "py-sandbox:3.12" },
  "limits": { "cpu": 1.0, "mem_mb": 512, "timeout_ms": 10000 },
  "network": "none",
  "exit_code": 0,
  "duration_ms": 1832,
  "stdout_bytes": 4096,
  "artifacts": ["plot.png"],
  "decision": "allow"
}

9.3 审计要点

# 记录什么
# 1) 代码全文或哈希(合规要求留存时存全文,否则存哈希+样本)
# 2) 谁、什么时候、从哪个会话
# 3) 沙箱配置(镜像版本、限制参数)——复现问题靠它
# 4) 是否命中可疑模式(外联、大文件写、敏感路径访问)
# 5) 产物清单与去向
# 审计的价值在于"事后能还原",而不是"事后有日志"

一句话:代码执行的审计不只是留痕,更要能回答「这次执行到底碰了什么、谁授权的」。

10. 常见陷阱

  • 只用普通容器做多租户隔离:内核共享,逃逸即拿到宿主——多租户必须上 gVisor 或 microVM。
  • 忘记限制进程数:内存限了、CPU 限了,fork 炸弹照样打爆 pids 表。
  • 默认允许网络:沙箱里的代码可以扫内网、外联 C2——默认 --network none。
  • 不校验输入路径:../../etc/shadow 直接读走——realpath 白名单校验。
  • 输出不截断:一段死循环 print 撑爆上下文窗口——强制截断 + 显式标记。
  • 会话永不过期:kernel 常驻、容器不销毁,几小时后内存爆掉——空闲回收。
  • 审计只记「执行了」:出事时无法还原执行内容与配置——记代码、配置、产物、决定。
  • 以 root 跑解释器:容器内 root 虽被 namespace 限制,但仍放大风险——非 root + cap-drop ALL。

11. 总结

MCP 代码执行工具是「给模型一个真解释器」的能力放大器,也是整个 MCP 生态里权限最高、风险最集中的一类工具。设计上要抓住四个支柱:接口清晰(无状态为默认、会话为显式选项、schema 表达限额)、隔离到位(容器起步、多租户升级到 gVisor/microVM、WASM 做轻量场景)、限制齐全(CPU、内存、磁盘、进程、时间、输出六项全限,网络默认关闭,文件白名单挂载)、审计可还原(代码、主体、配置、产物、决定五要素齐全)。把这四件事做扎实,模型就能安全地「跑代码」;做不扎实,代码执行工具就会变成整个系统里最容易被打穿的那扇门。配合 https://plumephp.com/mcp-security-practices/ 与 https://plumephp.com/mcp-tools-design-patterns/ 的整体原则使用,效果最佳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「AI工程」更多文章

  1. MCP 服务器评估与基准测试:工具选择、参数填充与任务成功率
  2. MCP 云基础设施与 IaC 工具:plan/apply 分离与爆炸半径控制
  3. MCP Git 与 DevOps 工具服务器:从只读查询到 CI/CD 触发