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
+3 -3
View File
@@ -72,8 +72,8 @@
### sundynix-mcp-py(算法型) ### sundynix-mcp-py(算法型)
- [ ] 🟡 parse_documentdocx/pdf/xlsx 解析器在;MinerU / PaddleOCR 多模态为骨架) - [ ] 🟡 parse_documentdocx/pdf/xlsx 解析器在;MinerU / PaddleOCR 多模态为骨架)
- [ ] 代码解释器(docker 隔离执行TODO - [x] 代码解释器(Docker 隔离执行:禁网/非root(65534)/丢能力/只读根+tmpfs/限内存CPU进程/超时kill/一次性;无 Docker 优雅降级
- [ ] 安全沙箱gVisor / KataVM 强隔离 + AST 静态守卫,全为 TODO 桩) - [x] 安全沙箱 AST 静态守卫(拦危险导入/调用/逃逸属性,6 单测)+ 4 项隔离实跑验证(正常/非root/禁网/超时);gVisor/Kata 作生产加固标注
## 跨层 / 工程 ## 跨层 / 工程
@@ -89,7 +89,7 @@
## 未实现的大块(路线图) ## 未实现的大块(路线图)
- [x] **真实登录 / 鉴权(JWT** —— 后端 + 前端闭环已完成 ✅ - [x] **真实登录 / 鉴权(JWT** —— 后端 + 前端闭环已完成 ✅
- [ ] **代码解释器 + 安全沙箱**mcp-py 核心能力,目前全桩 - [x] **代码解释器 + 安全沙箱**AST 守卫 + Docker 隔离已落地 ✅;生产可换 gVisor/Kata
- [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成) - [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成)
- [ ] **长期记忆抽取** + external_api 工具 - [ ] **长期记忆抽取** + external_api 工具
- [ ] **计费 / 商业化**真实实现 - [ ] **计费 / 商业化**真实实现
+1 -1
View File
@@ -8,9 +8,9 @@ dependencies = [
"python-docx>=1.1.0", # Word 解析 "python-docx>=1.1.0", # Word 解析
"openpyxl>=3.1.0", # Excel 解析 "openpyxl>=3.1.0", # Excel 解析
"pypdf>=4.0.0", # PDF 文本解析 "pypdf>=4.0.0", # PDF 文本解析
"docker>=7.1.0", # Docker 隔离沙箱 / Code Interpreter(代码执行隔离)
# 以下随对应功能接真时再开(当前为桩,避免拖累安装): # 以下随对应功能接真时再开(当前为桩,避免拖累安装):
# "mcp>=1.2.0", # MCP 协议(真实 MCP server # "mcp>=1.2.0", # MCP 协议(真实 MCP server
# "docker>=7.1.0", # Docker 隔离沙箱 / Code Interpreter
# "magic-pdf", # MinerU 多模态解析 (PaddleOCR),扫描件 OCR # "magic-pdf", # MinerU 多模态解析 (PaddleOCR),扫描件 OCR
] ]
@@ -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 from __future__ import annotations
import asyncio
import logging
log = logging.getLogger("mcp_py")
class CodeInterpreter: class CodeInterpreter:
"""Docker 隔离沙箱 · Code Interpreter。""" """Docker 隔离沙箱 · Code Interpreter。"""
async def execute(self, code: str, *, image: str = "python:3.11-slim") -> dict: def __init__(self) -> None:
"""在一次性 Docker 容器中执行代码,返回 stdout/stderr/artifacts。""" self._client = None
# TODO: docker.from_env().containers.run(..., network_disabled=True, mem_limit=...) self._init_err = "未初始化"
return {"stdout": "", "stderr": "", "artifacts": []} 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
@@ -93,9 +93,14 @@ class McpGateway:
return str(args.get("text", "")) return str(args.get("text", ""))
async def _run_code(self, args: dict) -> str: async def _run_code(self, args: dict) -> str:
"""静态守卫 → Docker 隔离执行(标准档 256m/0.5cpu/10s)。"""
code = str(args.get("code", "")) code = str(args.get("code", ""))
result = await self.interpreter.execute(code) # Docker 隔离沙箱(桩) if not code.strip():
return f"[run_code] Docker 隔离执行(桩) stdout={result.get('stdout','')!r}" return "(空代码)"
ok, reason = self.sandbox.static_guard(code)
if not ok:
return f"🚫 静态守卫拒绝:{reason}"
return _fmt_result(await self.interpreter.execute(code))
async def _parse_document(self, args: dict) -> str: async def _parse_document(self, args: dict) -> str:
"""文件 → 纯文本。content_b64=文件内容(base64)filename 决定解析器。""" """文件 → 纯文本。content_b64=文件内容(base64)filename 决定解析器。"""
@@ -112,10 +117,28 @@ class McpGateway:
return await asyncio.to_thread(parsers.parse, filename, data) return await asyncio.to_thread(parsers.parse, filename, data)
async def _secure_sandbox(self, args: dict) -> str: async def _secure_sandbox(self, args: dict) -> str:
"""同 run_code 的守卫 + 隔离,更紧资源档(128m/5s),用于高风险代码。"""
code = str(args.get("code", "")) code = str(args.get("code", ""))
out = await self.sandbox.run(code) # gVisor/KataVM(桩,含静态守卫) if not code.strip():
return f"[secure_sandbox] gVisor 隔离执行(桩) out={out!r}" return "(空代码)"
ok, reason = self.sandbox.static_guard(code)
if not ok:
return f"🚫 静态守卫拒绝:{reason}"
return "[secure] " + _fmt_result(await self.interpreter.execute(code, mem="128m", timeout=5))
async def close(self) -> None: async def close(self) -> None:
if self._nc is not None: if self._nc is not None:
await self._nc.drain() await self._nc.drain()
def _fmt_result(r: dict) -> str:
"""把执行结果 {ok,stdout,stderr,exit,degraded} 整理成可读文本。"""
if r.get("degraded"):
return f"⚠️ {r.get('stderr', 'Docker 不可用')}"
parts = []
if r.get("stdout", "").strip():
parts.append("stdout:\n" + r["stdout"].strip())
if r.get("stderr", "").strip():
parts.append("stderr:\n" + r["stderr"].strip())
parts.append(f"exit={r.get('exit')}")
return "\n".join(parts)
+62 -12
View File
@@ -1,19 +1,69 @@
"""安全代码沙箱gVisor / KataVM 强隔离 + 静态代码守卫。""" """安全代码沙箱 · 静态代码守卫(AST 扫描,纯逻辑,执行前的第一道防线)。
真正的执行隔离在 interpreter.CodeInterpreterDocker:禁网/非 root/丢能力/限资源/一次性)。
本守卫是纵深防御:在进容器前先拦掉明显的逃逸/越权/原生调用向量。
生产可把执行运行时换成 gVisor(runsc) / Kata 进一步硬化。
"""
from __future__ import annotations from __future__ import annotations
import ast
# 危险模块:导入即拒(执行 / 原生 / 网络 / 序列化反序列化 / 进程等逃逸或越权向量)。
_DENY_MODULES = {
"os", "sys", "subprocess", "socket", "ctypes", "importlib", "pickle",
"marshal", "shutil", "multiprocessing", "threading", "pty", "signal",
"mmap", "fcntl", "resource", "requests", "urllib", "http", "ftplib",
"telnetlib", "smtplib", "asyncio", "platform", "pdb", "gc",
}
# 危险内置调用:拒(动态执行 / 文件 / 解释器内省入口)。
_DENY_CALLS = {
"eval", "exec", "compile", "__import__", "open", "input",
"breakpoint", "exit", "quit", "globals", "vars", "memoryview",
}
# 危险属性:拒(典型沙箱逃逸链:obj.__class__.__subclasses__()... / __globals__ ...)。
_DENY_ATTRS = {
"__subclasses__", "__globals__", "__builtins__", "__bases__",
"__mro__", "__reduce__", "__reduce_ex__", "__code__", "__dict__",
"__getattribute__", "__base__",
}
class SecureSandbox: class SecureSandbox:
"""Harness: Secure Code Sandbox""" """Harness · 静态代码守卫"""
def static_guard(self, code: str) -> bool: def static_guard(self, code: str) -> tuple[bool, str]:
"""静态代码守卫:危险调用/导入检测,返回是否放行""" """AST 扫描危险导入 / 调用 / 属性。返回 (是否放行, 拒绝原因)"""
# TODO: AST 扫描,拦截 os/subprocess/网络等高危调用 try:
return True tree = ast.parse(code)
except SyntaxError as e:
return False, f"语法错误:{e.msg}"
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
top = alias.name.split(".")[0]
if top in _DENY_MODULES:
return False, f"禁止导入模块:{alias.name}"
elif isinstance(node, ast.ImportFrom):
top = (node.module or "").split(".")[0]
if top in _DENY_MODULES:
return False, f"禁止导入模块:{node.module}"
elif isinstance(node, ast.Call):
name = _call_name(node.func)
if name in _DENY_CALLS:
return False, f"禁止调用:{name}"
elif isinstance(node, ast.Attribute):
if node.attr in _DENY_ATTRS:
return False, f"禁止访问属性:{node.attr}"
return True, ""
async def run(self, code: str, *, runtime: str = "gvisor") -> str:
"""在 gVisor/KataVM 隔离环境中执行代码。""" def _call_name(func: ast.AST) -> str:
if not self.static_guard(code): """取被调用者的名字:普通名(eval) 或属性末段(o.system)。"""
raise PermissionError("static code guard rejected") if isinstance(func, ast.Name):
# TODO: 调度到 gVisor(runsc) / Kata 容器执行并回收 return func.id
return "" if isinstance(func, ast.Attribute):
return func.attr
return ""
+61
View File
@@ -0,0 +1,61 @@
"""静态守卫与隔离器的单测(守卫纯逻辑无依赖;隔离器测无 Docker 时的降级)。"""
import asyncio
from sundynix_mcp_py.interpreter import CodeInterpreter
from sundynix_mcp_py.sandbox import SecureSandbox
def test_guard_allows_safe_code():
g = SecureSandbox()
for code in [
"x = sum(range(10))\nprint(x)",
"import math, json\nprint(math.sqrt(2))",
"import re\nprint(re.findall(r'\\d+', 'a1b2'))",
]:
ok, reason = g.static_guard(code)
assert ok, f"安全代码被误拒: {code}{reason}"
def test_guard_blocks_dangerous_imports():
g = SecureSandbox()
for code in [
"import os\nos.listdir('/')",
"import subprocess",
"from socket import socket",
"import ctypes",
"import pickle",
"import requests",
]:
ok, reason = g.static_guard(code)
assert not ok, f"危险导入未拦: {code}"
assert "禁止导入" in reason
def test_guard_blocks_dangerous_calls():
g = SecureSandbox()
for code in ["eval('1+1')", "exec('x=1')", "__import__('os')", "open('/etc/passwd')"]:
ok, reason = g.static_guard(code)
assert not ok and "禁止调用" in reason, f"危险调用未拦: {code}"
def test_guard_blocks_escape_attrs():
g = SecureSandbox()
ok, reason = g.static_guard("().__class__.__subclasses__()")
assert not ok and "禁止访问属性" in reason
ok2, _ = g.static_guard("(lambda: 0).__globals__")
assert not ok2
def test_guard_rejects_syntax_error():
g = SecureSandbox()
ok, reason = g.static_guard("def f(:\n pass")
assert not ok and "语法错误" in reason
def test_interpreter_degrades_without_docker():
ci = CodeInterpreter()
if ci.available():
return # 本机有 Docker:跳过降级断言(执行路径走集成验证)
r = asyncio.run(ci.execute("print(1)"))
assert r["degraded"] is True and r["ok"] is False