feat(mcp-py): 代码沙箱落地 —— AST 静态守卫 + Docker 隔离执行(弃用桩)

mcp-py 的 run_code/secure_sandbox 此前全是桩。落地两层防御:

1) 静态守卫 sandbox.SecureSandbox.static_guard(纯 AST,执行前第一道)
   - 拦危险导入(os/sys/subprocess/socket/ctypes/pickle/requests…)、危险调用
     (eval/exec/compile/__import__/open…)、逃逸属性(__subclasses__/__globals__…)、语法错误。
   - 返回 (放行, 原因)。

2) 隔离执行 interpreter.CodeInterpreter.execute(Docker,真隔离)
   - network_disabled 禁网;user=65534 非 root + cap_drop=ALL + no-new-privileges;
     read_only 根 + /tmp tmpfs;mem/memswap(禁swap)/nano_cpus/pids_limit 限资源;
     python -I 隔离模式;wait 超时即 kill;容器一次性 remove。
   - 无 Docker SDK/daemon 时 available()=False 优雅降级,不阻断服务。

gateway:run_code(标准档 256m/0.5cpu/10s) 与 secure_sandbox(紧档 128m/5s) 均走
守卫→隔离,结果整理为 stdout/stderr/exit 可读文本。pyproject 启用 docker 依赖。

验证:
- 守卫 6 单测(放行安全码 / 拦危险导入·调用·逃逸属性 / 语法错误)全过。
- 隔离 4 项实跑(真 Docker):sum(range(10))→45 exit0;非root uid=65534;
  禁网 urlopen 失败(DNS解析错);while True 超时 3s 被 kill。
- 无 Docker 降级测过。

生产加固:可把执行运行时换 gVisor(runsc)/Kata(已在注释/PROGRESS 标注)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-18 11:26:08 +08:00
parent 9657a07bb5
commit cad5b14382
6 changed files with 245 additions and 25 deletions
@@ -1,12 +1,98 @@
"""Docker 隔离沙箱:Code Interpreter 执行不可信代码并采集产物。"""
"""Docker 隔离沙箱:Code Interpreter 在一次性容器里执行不可信代码。
隔离要点(安全默认):
- network_disabled:禁网(无外联 / 不能回连)。
- user=65534(nobody) + cap_drop=ALL + no-new-privileges:非 root、丢全部能力、禁提权。
- read_only 根文件系统 + 仅 /tmp 给小块 tmpfs:不能写镜像、不留痕。
- mem_limit + memswap=mem(禁 swap+ nano_cpus + pids_limit:限内存/CPU/进程数。
- python -I(隔离模式,忽略环境与用户 site)+ wait 超时即 kill + 一次性 remove。
Docker 不可用时优雅降级(available()=False),不阻断服务。
"""
from __future__ import annotations
import asyncio
import logging
log = logging.getLogger("mcp_py")
class CodeInterpreter:
"""Docker 隔离沙箱 · Code Interpreter。"""
async def execute(self, code: str, *, image: str = "python:3.11-slim") -> dict:
"""在一次性 Docker 容器中执行代码,返回 stdout/stderr/artifacts。"""
# TODO: docker.from_env().containers.run(..., network_disabled=True, mem_limit=...)
return {"stdout": "", "stderr": "", "artifacts": []}
def __init__(self) -> None:
self._client = None
self._init_err = "未初始化"
try:
import docker # 延迟导入:未装 docker SDK 时不影响其它工具
self._client = docker.from_env()
self._client.ping()
self._init_err = ""
log.info("[interpreter] Docker 就绪")
except Exception as e: # noqa: BLE001
self._init_err = str(e)
log.warning("[interpreter] Docker 不可用,代码执行降级: %s", e)
def available(self) -> bool:
return self._client is not None
async def execute(
self,
code: str,
*,
image: str = "python:3.11-slim",
mem: str = "256m",
cpu: float = 0.5,
timeout: int = 10,
pids: int = 64,
) -> dict:
"""在一次性隔离容器中执行代码,返回 {ok,stdout,stderr,exit,degraded}。"""
if self._client is None:
return {"ok": False, "stdout": "", "stderr": f"Docker 不可用:{self._init_err}", "exit": -1, "degraded": True}
# Docker SDK 为阻塞式,丢线程池避免卡事件循环。
return await asyncio.to_thread(self._run_blocking, code, image, mem, cpu, timeout, pids)
def _run_blocking(self, code: str, image: str, mem: str, cpu: float, timeout: int, pids: int) -> dict:
container = None
try:
container = self._client.containers.run(
image,
["python", "-I", "-c", code],
detach=True,
network_disabled=True,
mem_limit=mem,
memswap_limit=mem, # = mem → 禁用 swap
nano_cpus=int(cpu * 1e9),
pids_limit=pids,
read_only=True,
tmpfs={"/tmp": "size=16m"},
cap_drop=["ALL"],
security_opt=["no-new-privileges"],
user="65534:65534", # nobody
stdin_open=False,
tty=False,
)
timed_out = False
try:
res = container.wait(timeout=timeout)
exit_code = int(res.get("StatusCode", -1))
except Exception: # noqa: BLE001 容器 wait 超时(requests ReadTimeout
try:
container.kill()
except Exception: # noqa: BLE001
pass
exit_code, timed_out = -1, True
stdout = container.logs(stdout=True, stderr=False).decode("utf-8", "replace")
stderr = container.logs(stdout=False, stderr=True).decode("utf-8", "replace")
if timed_out:
stderr = (stderr + f"\n[超时 {timeout}s,已终止]").strip()
return {"ok": exit_code == 0 and not timed_out, "stdout": stdout, "stderr": stderr, "exit": exit_code, "degraded": False}
except Exception as e: # noqa: BLE001
return {"ok": False, "stdout": "", "stderr": f"执行失败:{e}", "exit": -1, "degraded": False}
finally:
if container is not None:
try:
container.remove(force=True)
except Exception: # noqa: BLE001
pass