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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user