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/cgroup | 50-300ms | 中 | 主流生产方案 |
| gVisor | 用户态内核 | 100-500ms | 较强 | 多租户不可信代码 |
| Firecracker microVM | KVM 硬件虚拟化 | 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 限制清单
| 资源 | 手段 | 典型值 | 不设限的后果 |
|---|---|---|---|
| CPU | cgroup cpu.max | 1-2 核 | 占满节点、影响邻居 |
| 内存 | cgroup memory.max | 256-1024 MB | OOM 拖垮宿主 |
| 磁盘 | 配额/tmpfs 大小 | 64-512 MB | 写满磁盘 |
| 进程数 | pids.max | 32-128 | fork 炸弹 |
| 文件描述符 | RLIMIT_NOFILE | 256 | 句柄耗尽 |
| 墙钟时间 | 超时杀死 | 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 挂载策略
| 路径 | 权限 | 说明 |
|---|---|---|
| /workspace | rw(tmpfs) | 唯一可写区,会话结束即销毁 |
| /inputs | ro | 本次任务输入,按调用挂载 |
| /outputs | rw(受限) | 产物落盘,只允许约定格式 |
| /usr /lib | ro | 运行时只读 |
| /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/ 的整体原则使用,效果最佳。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。