Files
sundynix-agentix/sundynix-mcp-py/src/sundynix_mcp_py/interpreter.py
T
Blizzard cad5b14382 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>
2026-06-18 11:26:08 +08:00

99 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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。"""
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