Files
sundynix-agentix/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.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

145 lines
5.4 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.
"""MCP 协议网关:注册算法型工具并经 NATS 分发调用。
与 Go 侧 sundynix-shared/bus 的 ServeTool 同契约(core NATS request-reply):
请求体 {"tool": str, "args": {...}, "task_id": str}
应答体 {"ok": bool, "content": str, "error": str}
订阅 sundynix.tools.py.>,队列组 mcp-py-workers(多副本负载均衡)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import nats
from .interpreter import CodeInterpreter
from .mineru import MultimodalParser
from .sandbox import SecureSandbox
log = logging.getLogger("mcp_py")
# 与 contract.SubjectToolsPyAll / QueueToolsPy 保持一致。
SUBJECT_PY_ALL = "sundynix.tools.py.>"
QUEUE_PY = "mcp-py-workers"
class McpGateway:
def __init__(self) -> None:
self.sandbox = SecureSandbox() # gVisor / KataVM + Static Code Guard
self.parser = MultimodalParser() # MinerU / PaddleOCR
self.interpreter = CodeInterpreter() # Docker 隔离沙箱
self._nc: nats.NATS | None = None
self._tools: dict[str, callable] = {}
async def register_tools(self) -> None:
"""注册工具名 → 处理协程。"""
self._tools = {
"echo": self._echo,
"run_code": self._run_code,
"parse_document": self._parse_document,
"secure_sandbox": self._secure_sandbox,
}
async def serve(self, url: str | None = None) -> None:
url = url or os.getenv("NATS_URL", "nats://localhost:4222")
# 容忍服务先于 NATS 启动:无限重连 + 等待间隔。
self._nc = await nats.connect(
url,
allow_reconnect=True,
max_reconnect_attempts=-1,
reconnect_time_wait=1,
connect_timeout=5,
)
log.info("[mcp_py] connected %s", url)
await self._nc.subscribe(SUBJECT_PY_ALL, queue=QUEUE_PY, cb=self._on_call)
log.info(
"[mcp_py] tools ready on %s (queue=%s): %s",
SUBJECT_PY_ALL, QUEUE_PY, ", ".join(self._tools),
)
await asyncio.Event().wait()
async def _on_call(self, msg) -> None:
"""解析 ToolCall → 路由到工具 → Respond ToolResult。"""
try:
req = json.loads(msg.data)
except Exception as e: # noqa: BLE001
await self._reply(msg, ok=False, error=f"bad tool call: {e}")
return
tool = req.get("tool", "")
task_id = req.get("task_id", "")
args = req.get("args") or {}
log.info("[mcp_py] tool=%s task=%s args=%s", tool, task_id, args)
fn = self._tools.get(tool)
if fn is None:
await self._reply(msg, ok=False, error=f"unknown tool: {tool}")
return
try:
content = await fn(args)
await self._reply(msg, ok=True, content=content)
except Exception as e: # noqa: BLE001
await self._reply(msg, ok=False, error=str(e))
@staticmethod
async def _reply(msg, *, ok: bool, content: str = "", error: str = "") -> None:
payload = json.dumps({"ok": ok, "content": content, "error": error})
await msg.respond(payload.encode())
# ---- 工具实现(算法层目前为桩,但调用链路做真)----
async def _echo(self, args: dict) -> str:
return str(args.get("text", ""))
async def _run_code(self, args: dict) -> str:
"""静态守卫 → Docker 隔离执行(标准档 256m/0.5cpu/10s)。"""
code = str(args.get("code", ""))
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 决定解析器。"""
import base64
from . import parsers
filename = str(args.get("filename", ""))
content_b64 = str(args.get("content_b64", ""))
if not content_b64:
return str(args.get("text", ""))
data = base64.b64decode(content_b64)
# 解析是 CPU 密集,丢到线程池避免阻塞事件循环。
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", ""))
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)