From cad5b14382ea9121e26d0a8c5fd65915e2be4429 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 18 Jun 2026 11:26:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp-py):=20=E4=BB=A3=E7=A0=81=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E8=90=BD=E5=9C=B0=20=E2=80=94=E2=80=94=20AST=20?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=AE=88=E5=8D=AB=20+=20Docker=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=89=A7=E8=A1=8C=EF=BC=88=E5=BC=83=E7=94=A8=E6=A1=A9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROGRESS.md | 6 +- sundynix-mcp-py/pyproject.toml | 2 +- .../src/sundynix_mcp_py/interpreter.py | 96 ++++++++++++++++++- .../src/sundynix_mcp_py/mcp_gateway.py | 31 +++++- .../src/sundynix_mcp_py/sandbox.py | 74 +++++++++++--- sundynix-mcp-py/tests/test_sandbox.py | 61 ++++++++++++ 6 files changed, 245 insertions(+), 25 deletions(-) create mode 100644 sundynix-mcp-py/tests/test_sandbox.py diff --git a/PROGRESS.md b/PROGRESS.md index d75abb2..1416102 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -72,8 +72,8 @@ ### sundynix-mcp-py(算法型) - [ ] 🟡 parse_document(docx/pdf/xlsx 解析器在;MinerU / PaddleOCR 多模态为骨架) -- [ ] 代码解释器(docker 隔离执行,TODO) -- [ ] 安全沙箱(gVisor / KataVM 强隔离 + AST 静态守卫,全为 TODO 桩) +- [x] 代码解释器(Docker 隔离真执行:禁网/非root(65534)/丢能力/只读根+tmpfs/限内存CPU进程/超时kill/一次性;无 Docker 优雅降级) +- [x] 安全沙箱 AST 静态守卫(拦危险导入/调用/逃逸属性,6 单测)+ 4 项隔离实跑验证(正常/非root/禁网/超时);gVisor/Kata 作生产加固标注 ## 跨层 / 工程 @@ -89,7 +89,7 @@ ## 未实现的大块(路线图) - [x] **真实登录 / 鉴权(JWT)** —— 后端 + 前端闭环已完成 ✅ -- [ ] **代码解释器 + 安全沙箱**(mcp-py 核心能力,目前全桩) +- [x] **代码解释器 + 安全沙箱**(AST 守卫 + Docker 隔离已落地 ✅;生产可换 gVisor/Kata) - [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成) - [ ] **长期记忆抽取** + external_api 工具 - [ ] **计费 / 商业化**真实实现 diff --git a/sundynix-mcp-py/pyproject.toml b/sundynix-mcp-py/pyproject.toml index bc09e17..7d3cf80 100644 --- a/sundynix-mcp-py/pyproject.toml +++ b/sundynix-mcp-py/pyproject.toml @@ -8,9 +8,9 @@ dependencies = [ "python-docx>=1.1.0", # Word 解析 "openpyxl>=3.1.0", # Excel 解析 "pypdf>=4.0.0", # PDF 文本解析 + "docker>=7.1.0", # Docker 隔离沙箱 / Code Interpreter(代码执行隔离) # 以下随对应功能接真时再开(当前为桩,避免拖累安装): # "mcp>=1.2.0", # MCP 协议(真实 MCP server) - # "docker>=7.1.0", # Docker 隔离沙箱 / Code Interpreter # "magic-pdf", # MinerU 多模态解析 (PaddleOCR),扫描件 OCR ] diff --git a/sundynix-mcp-py/src/sundynix_mcp_py/interpreter.py b/sundynix-mcp-py/src/sundynix_mcp_py/interpreter.py index 227e393..48a1898 100644 --- a/sundynix-mcp-py/src/sundynix_mcp_py/interpreter.py +++ b/sundynix-mcp-py/src/sundynix_mcp_py/interpreter.py @@ -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 diff --git a/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py b/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py index 227ad4b..251b2b3 100644 --- a/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py +++ b/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py @@ -93,9 +93,14 @@ class McpGateway: return str(args.get("text", "")) async def _run_code(self, args: dict) -> str: + """静态守卫 → Docker 隔离执行(标准档 256m/0.5cpu/10s)。""" code = str(args.get("code", "")) - result = await self.interpreter.execute(code) # Docker 隔离沙箱(桩) - return f"[run_code] Docker 隔离执行(桩) stdout={result.get('stdout','')!r}" + if not code.strip(): + 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: """文件 → 纯文本。content_b64=文件内容(base64),filename 决定解析器。""" @@ -112,10 +117,28 @@ class McpGateway: return await asyncio.to_thread(parsers.parse, filename, data) async def _secure_sandbox(self, args: dict) -> str: + """同 run_code 的守卫 + 隔离,更紧资源档(128m/5s),用于高风险代码。""" code = str(args.get("code", "")) - out = await self.sandbox.run(code) # gVisor/KataVM(桩,含静态守卫) - return f"[secure_sandbox] gVisor 隔离执行(桩) out={out!r}" + if not code.strip(): + 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: if self._nc is not None: 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) diff --git a/sundynix-mcp-py/src/sundynix_mcp_py/sandbox.py b/sundynix-mcp-py/src/sundynix_mcp_py/sandbox.py index a897153..77c774a 100644 --- a/sundynix-mcp-py/src/sundynix_mcp_py/sandbox.py +++ b/sundynix-mcp-py/src/sundynix_mcp_py/sandbox.py @@ -1,19 +1,69 @@ -"""安全代码沙箱:gVisor / KataVM 强隔离 + 静态代码守卫。""" +"""安全代码沙箱 · 静态代码守卫(AST 扫描,纯逻辑,执行前的第一道防线)。 + +真正的执行隔离在 interpreter.CodeInterpreter(Docker:禁网/非 root/丢能力/限资源/一次性)。 +本守卫是纵深防御:在进容器前先拦掉明显的逃逸/越权/原生调用向量。 +生产可把执行运行时换成 gVisor(runsc) / Kata 进一步硬化。 +""" 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: - """Harness: Secure Code Sandbox。""" + """Harness · 静态代码守卫。""" - def static_guard(self, code: str) -> bool: - """静态代码守卫:危险调用/导入检测,返回是否放行。""" - # TODO: AST 扫描,拦截 os/subprocess/网络等高危调用 - return True + def static_guard(self, code: str) -> tuple[bool, str]: + """AST 扫描危险导入 / 调用 / 属性。返回 (是否放行, 拒绝原因)。""" + try: + 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 隔离环境中执行代码。""" - if not self.static_guard(code): - raise PermissionError("static code guard rejected") - # TODO: 调度到 gVisor(runsc) / Kata 容器执行并回收 - return "" + +def _call_name(func: ast.AST) -> str: + """取被调用者的名字:普通名(eval) 或属性末段(o.system)。""" + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return "" diff --git a/sundynix-mcp-py/tests/test_sandbox.py b/sundynix-mcp-py/tests/test_sandbox.py new file mode 100644 index 0000000..7e8c540 --- /dev/null +++ b/sundynix-mcp-py/tests/test_sandbox.py @@ -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