diff --git a/.env.example b/.env.example index 84044066..03af079b 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,10 @@ # AGENTLAB_WEB_PORT=5273 # ---- 沙箱 ---- -# auto(有 Docker 用 Docker,否则降级本地子进程)| docker | local | off +# auto(macOS→seatbelt,Linux→bubblewrap,都没有则 local)| seatbelt | bubblewrap | local | off # AGENTLAB_SANDBOX_BACKEND=auto -# AGENTLAB_SANDBOX_IMAGE=python:3.12-slim # AGENTLAB_SANDBOX_TIMEOUT=30 +# 内存限额仅在 Linux 生效,macOS 不强制 RLIMIT_AS # AGENTLAB_SANDBOX_MEMORY_MB=512 # AGENTLAB_SANDBOX_NETWORK=false diff --git a/README.md b/README.md index 8b8fe4d0..ba16498f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 快速开始 -需要 Python 3.12+(`uv` 会自动装)、Node 20+、pnpm;Docker 可选但强烈建议(代码沙箱靠它做隔离)。 +需要 Python 3.12+(`uv` 会自动装)、Node 20+、pnpm。**不需要 Docker** —— 代码沙箱用操作系统自带的隔离原语。 ```bash ./scripts/dev.sh @@ -44,7 +44,7 @@ | **可视化编排** | 15 种节点拖拽连线;运行时节点实时高亮、边上数据流动画、卡片里直接看到流式 token | | **多模型接入** | Anthropic / OpenAI / 任意 OpenAI 兼容服务(DeepSeek、Kimi、通义、智谱、硅基流动、Ollama…)+ Mock | | **工具链** | 15 个内置工具 + 自定义工具(HTTP 模板 / 沙箱 Python)+ MCP server 接入 | -| **代码沙箱** | Docker 隔离:断网、只读根文件系统、非 root、内存/CPU/进程数限额、超时强杀 | +| **代码沙箱** | macOS 用 Seatbelt、Linux 用 bubblewrap:断网、家目录不可读、只有工作区可写、超时强杀,冷启动 ~25ms | | **人工介入** | 图暂停并落盘,人在界面上批准/驳回/改稿,从断点继续——进程重启也不丢 | | **记忆与知识库** | 跨运行的长期记忆(自动去重)+ 文档知识库(向量 + BM25 混合检索,可调配比) | | **Skill 管理** | 把「怎么做事」抽成可复用的方法论,挂到节点上注入 system prompt | @@ -85,7 +85,7 @@ backend/ FastAPI + LangGraph expressions.py 模板插值 + AST 白名单表达式求值 nodes/ 各类节点的执行器 app/providers/ 多模型接入与成本估算 - app/sandbox/ Docker / 本地子进程双后端 + app/sandbox/ Seatbelt / bubblewrap / 裸子进程三后端 app/tools/ 工具注册表、内置工具、MCP、自定义工具 app/memory/ 记忆、知识库、混合检索 app/api/ REST + WebSocket @@ -111,11 +111,24 @@ LangGraph 的 `@task` 里,结果进 checkpoint,重放时直接取缓存。 `RunEvent` 落库并广播。画布高亮、token 流、工具卡片、时间线全部由同一份事件驱动, 所以刷新页面或中途接入都能拿到完整过程(WebSocket 先补历史再接实时)。 -**沙箱的隔离姿态。** 容器 `read_only` + tmpfs 工作区 + `cap_drop ALL` + -`no-new-privileges` + 非 root + 默认断网 + 内存/CPU/pids 限额 + 超时整容器销毁。 -注意 Docker 的 `put_archive` 在只读容器上会被拒,所以代码是用 exec + base64 管道写进去的 -——为的是不牺牲只读根文件系统。Docker 不可用时降级到子进程 + rlimit, -**那条路径没有内核级隔离**,界面上会明确标注。 +**沙箱不用容器。** 跑一段不可信代码不需要一整套容器运行时——操作系统自己就有 +内核级的强制访问控制。macOS 用 Seatbelt(`sandbox-exec`,系统自带),Linux 用 +bubblewrap(几百 KB 的二进制)。冷启动 ~25ms,对比容器方案的 ~250ms,且不用装任何东西。 + +守住的是三件事:**默认断网**、**家目录不可读**、**只有工作区可写**。策略由内核强制且 +对子进程继承——沙箱里 `subprocess` 出来的 `cat` 读家目录一样是 `Operation not permitted`。 +解释器刻意用 `sys.base_prefix` 下那个干净的 Python,而不是后端自己的 venv, +所以沙箱代码 import 不到 FastAPI、SQLAlchemy 和凭据处理相关的任何东西。 + +**和容器比,少了什么要说清楚**:Seatbelt 做的是访问控制而非虚拟化——进程表是共享的 +(能看到宿主机进程列表),代码以当前用户身份运行(不像容器会降到 nobody), +而且 **macOS 不强制 `RLIMIT_AS`,内存用量限不住**(实测设 256MB 仍能分配 900MB)。 +CPU 时间、单文件大小、墙钟超时这三项有效。bubblewrap 那边多隔离了 PID/IPC/挂载视图, +内存限额也真实生效。要更强的隔离(内存配额、完整虚拟化),该上的是 gVisor 或 microVM, +而不是回到容器。 + +写 SBPL 策略有个坑:`(deny default)` 会让 Python 直接 SIGABRT 起不来——要枚举的路径和 +syscall 太多,漏一个就崩。所以用的是「默认允许 + 精确拒绝」,只把那三件事收紧。 **检索为什么是混合的。** 默认 embedding 是本地特征哈希,不联网不下模型,零配置可用, 但语义泛化弱;BM25 补上精确关键词匹配这一半。两者各自归一化后加权,权重在界面上可调, @@ -127,8 +140,10 @@ LangGraph 的 `@task` 里,结果进 checkpoint,重放时直接取缓存。 ## 常见问题 -**沙箱不可用?** 设置页「运行环境」会显示实际后端和原因。Docker 没启动时会自动降级到 -本地子进程,功能可用但没有隔离。首次执行会拉 `python:3.12-slim` 镜像,需要等一会儿。 +**沙箱用的是哪个后端?** 设置页「运行环境」会列出所有候选及其可用性,并标出当前选中的那个。 +macOS 上应该是 `seatbelt`;Linux 上装了 `bwrap`(`apt install bubblewrap`)就是 `bubblewrap`, +没装则降级到 `local`——**那条路径没有任何访问控制**,界面上会明确标红。 +可以用 `AGENTLAB_SANDBOX_BACKEND` 强制指定。 **模型返回空内容?** Claude 4.6 之后的模型默认开着 thinking,`max_tokens` 给小了会出现 「思考完就没额度写正文」。节点里把 max_tokens 调大,或把思考模式设为关闭。时间线里会有提示。 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9051f943..744dacf8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -31,8 +31,7 @@ class Settings(BaseSettings): secret_key: str = "" # --- 沙箱 --- - sandbox_backend: str = "auto" # auto | docker | local | off - sandbox_image: str = "python:3.12-slim" + sandbox_backend: str = "auto" # auto | seatbelt | bubblewrap | local | off sandbox_timeout: int = 30 sandbox_memory_mb: int = 512 sandbox_cpus: float = 1.0 diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 3e422bbf..e3b112bf 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -61,6 +61,18 @@ async def resolve_provider( for p in enabled: if p.default_model == model: return p + # 模型认得出来,但它所属的 provider 被停用了。这时候不能默默换一家 —— + # 拿着 A 家的模型名去调 B 家,必然 404,而且报错完全指不到真正的原因。 + for p in providers: + if not p.enabled and ( + any((m or {}).get("id") == model for m in (p.models or [])) + or p.default_model == model + ): + raise ProviderNotConfigured( + f"模型 {model!r} 属于 provider {p.name!r},但它已被停用。" + f"请在设置里启用它,或给节点换一个模型。" + ) + # 认不出来的模型名就放行:用户可能手填了一个目录里还没有的新模型 real = [p for p in enabled if p.kind != "mock"] return (real or enabled or [None])[0] diff --git a/backend/app/sandbox/bubblewrap_sandbox.py b/backend/app/sandbox/bubblewrap_sandbox.py new file mode 100644 index 00000000..d71d7e82 --- /dev/null +++ b/backend/app/sandbox/bubblewrap_sandbox.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import os +import resource +import shutil +import sys +import time +from pathlib import Path + +from app.core.config import settings +from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate + +_LANG = { + "python": ("main.py", ["python3", "-I", "-u", "/work/main.py"]), + "bash": ("main.sh", ["/bin/bash", "/work/main.sh"]), + "sh": ("main.sh", ["/bin/sh", "/work/main.sh"]), + "node": ("main.js", ["node", "/work/main.js"]), + "javascript": ("main.js", ["node", "/work/main.js"]), +} + +# 只读挂进沙箱的系统目录,存在才挂 +_RO_BINDS = ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives", "/etc/ssl"] + + +class BubblewrapSandbox(Sandbox): + """Linux 上的对等方案:bubblewrap(Flatpak 的沙箱内核)。 + + 一个几百 KB 的二进制,直接用 namespace + seccomp 建隔离环境, + 不需要 daemon、不需要 root、不需要镜像。相比 Seatbelt 它还多隔离了 + PID、IPC 和挂载视图,隔离强度更接近容器。 + """ + + name = "bubblewrap" + + def __init__(self) -> None: + self._root = settings.workspace_dir / "bwrap" + self._bwrap = shutil.which("bwrap") + + @staticmethod + def available() -> bool: + return sys.platform.startswith("linux") and shutil.which("bwrap") is not None + + async def health(self) -> dict[str, object]: + ok = self.available() + return { + "backend": self.name, + "available": ok, + "isolated": ok, + "isolation": "Linux namespace(mount/pid/ipc/uts/net)+ 只读根", + "limits_note": "内存与 CPU 由 rlimit 限制;如需 cgroups 级配额请配合 systemd-run", + "enforced": {"timeout": True, "cpu": True, "memory": True, "network": True, "fsize": True}, + "root": str(self._root), + **({} if ok else {"error": "未找到 bwrap,可执行 apt install bubblewrap 安装"}), + } + + def _session_dir(self, session_id: str) -> Path: + safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64] or "default" + path = self._root / safe + path.mkdir(parents=True, exist_ok=True) + return path + + @staticmethod + def _preexec(limits: SandboxLimits): # pragma: no cover - 子进程里执行 + def _apply() -> None: + resource.setrlimit(resource.RLIMIT_CPU, (limits.timeout, limits.timeout + 2)) + # Linux 上 RLIMIT_AS 是真正生效的(macOS 不是) + nbytes = limits.memory_mb * 1024 * 1024 + resource.setrlimit(resource.RLIMIT_AS, (nbytes, nbytes)) + resource.setrlimit(resource.RLIMIT_FSIZE, (256 * 1024 * 1024,) * 2) + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + os.setsid() + + return _apply + + def _argv(self, workdir: Path, limits: SandboxLimits, cmd: list[str]) -> list[str]: + argv = [ + self._bwrap or "bwrap", + "--unshare-all", # mount/pid/ipc/uts/cgroup/net 全部隔离 + *(["--share-net"] if limits.network else []), + "--die-with-parent", # 父进程没了,沙箱跟着走 + "--new-session", # 摘掉控制终端,防 TIOCSTI 注入 + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + "--bind", str(workdir), "/work", + "--chdir", "/work", + "--setenv", "HOME", "/work", + "--setenv", "TMPDIR", "/tmp", + "--setenv", "PATH", "/usr/bin:/bin", + "--setenv", "PYTHONDONTWRITEBYTECODE", "1", + ] + for path in _RO_BINDS: + if Path(path).exists(): + argv += ["--ro-bind", path, path] + return argv + ["--", *cmd] + + async def run( + self, + code: str, + *, + language: str = "python", + limits: SandboxLimits | None = None, + session_id: str | None = None, + env: dict[str, str] | None = None, + files: dict[str, str] | None = None, + ) -> ExecResult: + limits = limits or SandboxLimits() + language = (language or "python").lower() + if language not in _LANG: + return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}") + if not self.available(): + return ExecResult(ok=False, backend=self.name, error="bwrap 不可用") + + filename, cmd = _LANG[language] + workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}") + (workdir / filename).write_text(code) + for rel, content in (files or {}).items(): + target = (workdir / rel).resolve() + if not str(target).startswith(str(workdir.resolve())): + return ExecResult(ok=False, backend=self.name, error=f"非法文件路径:{rel}") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + + argv = self._argv(workdir, limits, cmd) + for key, value in (env or {}).items(): + argv[1:1] = ["--setenv", key, value] + + started = time.perf_counter() + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + preexec_fn=self._preexec(limits), + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=limits.timeout) + except asyncio.TimeoutError: + try: + os.killpg(os.getpgid(proc.pid), 9) + except (ProcessLookupError, PermissionError): + proc.kill() + await proc.wait() + return ExecResult( + ok=False, backend=self.name, timed_out=True, exit_code=124, + duration_ms=int((time.perf_counter() - started) * 1000), + error=f"执行超过 {limits.timeout}s 被终止", + ) + except Exception as e: # noqa: BLE001 + return ExecResult(ok=False, backend=self.name, error=f"{type(e).__name__}: {e}") + + stdout, t1 = truncate(out.decode(errors="replace"), limits.max_output) + stderr, t2 = truncate(err.decode(errors="replace"), limits.max_output) + code_ = proc.returncode or 0 + return ExecResult( + ok=code_ == 0, + exit_code=code_, + stdout=stdout, + stderr=stderr, + truncated=t1 or t2, + timed_out=code_ in (-24, 152), + backend=self.name, + duration_ms=int((time.perf_counter() - started) * 1000), + files_written=[p.name for p in workdir.iterdir() if p.is_file() and p.name != filename][:50], + ) + + async def cleanup(self, session_id: str) -> None: + await asyncio.to_thread(shutil.rmtree, self._session_dir(session_id), True) diff --git a/backend/app/sandbox/docker_sandbox.py b/backend/app/sandbox/docker_sandbox.py deleted file mode 100644 index 6a076d97..00000000 --- a/backend/app/sandbox/docker_sandbox.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import shlex -import time -import uuid -from typing import Any - -from app.core.config import settings -from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate - -_WORKDIR = "/workspace" - -_LANG_CMD = { - "python": ["python", "-u", "/workspace/.main.py"], - "bash": ["bash", "/workspace/.main.sh"], - "sh": ["sh", "/workspace/.main.sh"], - "node": ["node", "/workspace/.main.js"], - "javascript": ["node", "/workspace/.main.js"], -} -_LANG_FILE = { - "python": ".main.py", - "bash": ".main.sh", - "sh": ".main.sh", - "node": ".main.js", - "javascript": ".main.js", -} - - -# 单条 shell 命令里塞的 base64 分块大小。留足余量避开 ARG_MAX。 -_CHUNK = 48 * 1024 -_MAX_FILE_BYTES = 4 * 1024 * 1024 - - -def _write_commands(files: dict[str, str]) -> list[list[str]]: - """生成往容器里写文件的 shell 命令。 - - 这里不用 docker 的 put_archive:那个 API 会被 `read_only` 的容器直接拒掉 - (即便目标是可写的 tmpfs 挂载点)。改成 exec + base64 管道写入, - 就能同时保住只读根文件系统和往 /workspace 落文件这两件事。 - """ - commands: list[list[str]] = [] - for path, content in files.items(): - rel = path.lstrip("/") - if ".." in rel.split("/"): - raise ValueError(f"非法文件路径:{path}") - full = f"{_WORKDIR}/{rel}" - data = content.encode() - if len(data) > _MAX_FILE_BYTES: - raise ValueError(f"{path} 超过 {_MAX_FILE_BYTES // 1024 // 1024}MB 上限") - blob = base64.b64encode(data).decode() - quoted = shlex.quote(full) - commands.append(["sh", "-c", f"mkdir -p $(dirname {quoted})"]) - if not blob: - commands.append(["sh", "-c", f": > {quoted}"]) - continue - for i in range(0, len(blob), _CHUNK): - chunk = blob[i : i + _CHUNK] - redirect = ">" if i == 0 else ">>" - commands.append(["sh", "-c", f"printf %s {chunk} {redirect} {quoted}.b64"]) - commands.append(["sh", "-c", f"base64 -d < {quoted}.b64 > {quoted} && rm -f {quoted}.b64"]) - return commands - - -class DockerSandbox(Sandbox): - """容器隔离的代码执行。 - - 安全姿态:丢掉所有 capability、禁 setuid 提权、只读根文件系统 + 可写 tmpfs、 - 默认断网、非 root 用户、限制内存 / CPU / 进程数。 - 同一个 session_id 复用容器,这样多次执行之间文件是连续的。 - """ - - name = "docker" - - def __init__(self, image: str | None = None) -> None: - self.image = image or settings.sandbox_image - self._client: Any = None - self._sessions: dict[str, str] = {} # session_id -> container id - self._lock = asyncio.Lock() - self._image_ready = False - - # ---------------- 基础设施 ---------------- - - def _get_client(self) -> Any: - if self._client is None: - import docker - - self._client = docker.from_env() - return self._client - - async def health(self) -> dict[str, Any]: - try: - info = await asyncio.to_thread(lambda: self._get_client().version()) - has_image = await asyncio.to_thread(self._has_image) - return { - "backend": self.name, - "available": True, - "docker_version": info.get("Version"), - "image": self.image, - "image_pulled": has_image, - "sessions": len(self._sessions), - } - except Exception as e: # noqa: BLE001 - return {"backend": self.name, "available": False, "error": str(e)} - - def _has_image(self) -> bool: - import docker.errors - - try: - self._get_client().images.get(self.image) - return True - except docker.errors.ImageNotFound: - return False - except Exception: # noqa: BLE001 - return False - - async def ensure_image(self) -> None: - """首次执行时拉镜像。拉取可能要几十秒,所以单独暴露出来让 API 可以预热。""" - if self._image_ready: - return - if await asyncio.to_thread(self._has_image): - self._image_ready = True - return - await asyncio.to_thread(lambda: self._get_client().images.pull(self.image)) - self._image_ready = True - - def _create_container(self, limits: SandboxLimits) -> Any: - client = self._get_client() - return client.containers.run( - self.image, - command=["sleep", str(max(limits.timeout * 20, 600))], - detach=True, - working_dir=_WORKDIR, - network_disabled=not limits.network, - mem_limit=f"{limits.memory_mb}m", - memswap_limit=f"{limits.memory_mb}m", # 禁止用 swap 绕过内存限制 - nano_cpus=int(limits.cpus * 1e9), - pids_limit=128, - cap_drop=["ALL"], - security_opt=["no-new-privileges"], - read_only=True, - # mode=1777:tmpfs 默认属 root、755,非 root 的 nobody 会写不进去 - tmpfs={ - _WORKDIR: "rw,exec,size=128m,mode=1777", - "/tmp": "rw,exec,size=64m,mode=1777", - }, - user="nobody", - environment={"HOME": "/tmp", "PYTHONDONTWRITEBYTECODE": "1"}, - labels={"agentlab": "sandbox"}, - auto_remove=False, - ) - - async def _get_container(self, session_id: str | None, limits: SandboxLimits) -> Any: - client = self._get_client() - async with self._lock: - if session_id and session_id in self._sessions: - try: - container = await asyncio.to_thread( - client.containers.get, self._sessions[session_id] - ) - if container.status == "running": - return container - except Exception: # noqa: BLE001 - 容器没了就重建 - self._sessions.pop(session_id, None) - container = await asyncio.to_thread(self._create_container, limits) - if session_id: - self._sessions[session_id] = container.id - return container - - # ---------------- 执行 ---------------- - - async def run( - self, - code: str, - *, - language: str = "python", - limits: SandboxLimits | None = None, - session_id: str | None = None, - env: dict[str, str] | None = None, - files: dict[str, str] | None = None, - ) -> ExecResult: - limits = limits or SandboxLimits() - language = (language or "python").lower() - if language not in _LANG_CMD: - return ExecResult( - ok=False, backend=self.name, error=f"不支持的语言:{language}" - ) - - started = time.perf_counter() - ephemeral = session_id is None - sid = session_id or f"tmp-{uuid.uuid4().hex[:8]}" - container = None - try: - await self.ensure_image() - container = await self._get_container(sid, limits) - - payload = dict(files or {}) - payload[_LANG_FILE[language]] = code - - def _stage() -> tuple[int, bytes]: - for cmd in _write_commands(payload): - res = container.exec_run(cmd, workdir=_WORKDIR, user="nobody") - if res.exit_code != 0: - return res.exit_code, res.output or b"" - return 0, b"" - - stage_code, stage_out = await asyncio.to_thread(_stage) - if stage_code != 0: - return ExecResult( - ok=False, - backend=self.name, - exit_code=stage_code, - error=f"写入代码失败:{stage_out.decode(errors='replace')[:500]}", - duration_ms=int((time.perf_counter() - started) * 1000), - ) - - cmd = _LANG_CMD[language] - exec_env = {"HOME": "/tmp", **(env or {})} - - def _exec() -> tuple[int, bytes, bytes]: - res = container.exec_run( - cmd, - workdir=_WORKDIR, - environment=exec_env, - demux=True, - user="nobody", - ) - out, err = res.output if isinstance(res.output, tuple) else (res.output, b"") - return res.exit_code, out or b"", err or b"" - - try: - exit_code, out, err = await asyncio.wait_for( - asyncio.to_thread(_exec), timeout=limits.timeout - ) - except asyncio.TimeoutError: - # 超时就把容器整个干掉,防止里面还有进程在烧 CPU - await self._kill(sid) - return ExecResult( - ok=False, - backend=self.name, - timed_out=True, - exit_code=124, - duration_ms=int((time.perf_counter() - started) * 1000), - error=f"执行超过 {limits.timeout}s 被终止", - ) - - stdout, t1 = truncate(out.decode(errors="replace"), limits.max_output) - stderr, t2 = truncate(err.decode(errors="replace"), limits.max_output) - return ExecResult( - ok=exit_code == 0, - exit_code=exit_code, - stdout=stdout, - stderr=stderr, - truncated=t1 or t2, - backend=self.name, - duration_ms=int((time.perf_counter() - started) * 1000), - ) - except Exception as e: # noqa: BLE001 - return ExecResult( - ok=False, - backend=self.name, - error=f"{type(e).__name__}: {e}", - duration_ms=int((time.perf_counter() - started) * 1000), - ) - finally: - if ephemeral: - await self._kill(sid) - - async def _kill(self, session_id: str) -> None: - cid = self._sessions.pop(session_id, None) - if not cid: - return - client = self._get_client() - - def _remove() -> None: - try: - client.containers.get(cid).remove(force=True) - except Exception: # noqa: BLE001 - pass - - await asyncio.to_thread(_remove) - - async def cleanup(self, session_id: str) -> None: - await self._kill(session_id) - - async def close(self) -> None: - for sid in list(self._sessions): - await self._kill(sid) - - async def reap_orphans(self) -> int: - """清掉上次进程崩溃留下的沙箱容器。""" - - def _reap() -> int: - client = self._get_client() - containers = client.containers.list( - all=True, filters={"label": "agentlab=sandbox"} - ) - live = set(self._sessions.values()) - count = 0 - for c in containers: - if c.id in live: - continue - try: - c.remove(force=True) - count += 1 - except Exception: # noqa: BLE001 - pass - return count - - try: - return await asyncio.to_thread(_reap) - except Exception: # noqa: BLE001 - return 0 diff --git a/backend/app/sandbox/local_sandbox.py b/backend/app/sandbox/local_sandbox.py index 94d22131..083aaeb1 100644 --- a/backend/app/sandbox/local_sandbox.py +++ b/backend/app/sandbox/local_sandbox.py @@ -21,11 +21,11 @@ _LANG = { class LocalSandbox(Sandbox): - """子进程执行,Docker 不可用时的降级方案。 + """裸子进程执行,所有隔离后端都不可用时的最后兜底。 - 有 rlimit(CPU / 内存 / 文件大小 / 进程数)和独立工作目录,但**没有内核级隔离**: - 代码仍然跑在宿主机上,能读到你的文件系统。只适合本地自己玩, - 别拿它跑不信任的代码 —— 需要真隔离就把 Docker 起起来。 + 只有 rlimit 和一个独立工作目录,**没有任何访问控制**:代码能读你的整个 + 文件系统、能联网。正常情况下不会走到这里 —— macOS 有 Seatbelt、 + Linux 装个 bubblewrap 即可。真要用它跑不信任的代码,别。 """ name = "local" @@ -39,7 +39,9 @@ class LocalSandbox(Sandbox): "backend": self.name, "available": True, "isolated": False, - "warning": "本地子进程执行,没有内核级隔离,不要跑不信任的代码", + "warning": "裸子进程执行,无访问控制,不要跑不信任的代码", + "enforced": {"timeout": True, "cpu": True, + "memory": not sys.platform == "darwin", "network": False, "fsize": True}, "root": str(self._root), } @@ -53,6 +55,7 @@ class LocalSandbox(Sandbox): def _preexec(limits: SandboxLimits): # pragma: no cover - 只在子进程里跑 def _apply() -> None: resource.setrlimit(resource.RLIMIT_CPU, (limits.timeout, limits.timeout + 2)) + # 注意 macOS 不强制执行 RLIMIT_AS,这条在 Darwin 上是无效的 nbytes = limits.memory_mb * 1024 * 1024 for res_name in ("RLIMIT_AS", "RLIMIT_DATA"): res_id = getattr(resource, res_name, None) @@ -62,8 +65,9 @@ class LocalSandbox(Sandbox): except (ValueError, OSError): pass resource.setrlimit(resource.RLIMIT_FSIZE, (64 * 1024 * 1024,) * 2) - resource.setrlimit(resource.RLIMIT_NPROC, (64, 64)) resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + # 不设 RLIMIT_NPROC:macOS 上它按 uid 全局计数,设小了会让沙箱里 + # 任何 fork 都失败(bash 直接起不来),而不是只限制沙箱自己。 os.setsid() # 独立进程组,超时时能整组 kill return _apply diff --git a/backend/app/sandbox/manager.py b/backend/app/sandbox/manager.py index 0f2bc7b4..3fe9387f 100644 --- a/backend/app/sandbox/manager.py +++ b/backend/app/sandbox/manager.py @@ -1,12 +1,14 @@ from __future__ import annotations import asyncio +import sys from typing import Any from app.core.config import settings from app.sandbox.base import ExecResult, Sandbox, SandboxLimits -from app.sandbox.docker_sandbox import DockerSandbox +from app.sandbox.bubblewrap_sandbox import BubblewrapSandbox from app.sandbox.local_sandbox import LocalSandbox +from app.sandbox.seatbelt_sandbox import SeatbeltSandbox class DisabledSandbox(Sandbox): @@ -20,11 +22,24 @@ class DisabledSandbox(Sandbox): ) async def health(self) -> dict[str, Any]: - return {"backend": self.name, "available": False, "reason": "已关闭"} + return {"backend": self.name, "available": False, "isolated": False, "reason": "已关闭"} + + +# 按隔离强度从高到低排,auto 模式挑第一个能用的 +_BACKENDS: list[tuple[str, type[Sandbox]]] = [ + ("bubblewrap", BubblewrapSandbox), + ("seatbelt", SeatbeltSandbox), + ("local", LocalSandbox), +] class SandboxManager: - """按配置挑一个可用后端。auto = 有 Docker 用 Docker,没有就降级到本地子进程。""" + """挑选并持有代码执行后端。 + + 用操作系统自带的隔离原语,不依赖容器运行时:macOS 走 Seatbelt + (`sandbox-exec`,系统自带),Linux 走 bubblewrap。两者都是进程级启动, + 没有虚拟机开销,冷启动在几十毫秒量级。 + """ def __init__(self) -> None: self._backend: Sandbox | None = None @@ -37,28 +52,33 @@ class SandboxManager: async with self._lock: if self._backend is not None: return self._backend - self._backend = await self._resolve() + self._backend = self._resolve() return self._backend - async def _resolve(self) -> Sandbox: + def _resolve(self) -> Sandbox: choice = (settings.sandbox_backend or "auto").lower() - if choice == "off": - self._resolved_from = "配置关闭" - return DisabledSandbox() - if choice == "local": - self._resolved_from = "配置指定" - return LocalSandbox() - if choice == "docker": - self._resolved_from = "配置指定" - return DockerSandbox() - docker = DockerSandbox() - health = await docker.health() - if health.get("available"): - self._resolved_from = "自动探测到 Docker" - await docker.reap_orphans() - return docker - self._resolved_from = f"Docker 不可用({health.get('error', '未知原因')}),降级到本地" + if choice == "off": + self._resolved_from = "配置为关闭" + return DisabledSandbox() + + named = dict(_BACKENDS) + if choice in named: + impl = named[choice] + backend = impl() + available = getattr(impl, "available", lambda: True)() + self._resolved_from = ( + f"配置指定 {choice}" if available + else f"配置指定 {choice},但当前环境不可用" + ) + return backend + + for name, impl in _BACKENDS: + if getattr(impl, "available", lambda: True)(): + self._resolved_from = f"自动选择 {name}({_why(name)})" + return impl() + + self._resolved_from = "没有可用的隔离后端,降级到本地子进程" return LocalSandbox() async def run( @@ -87,6 +107,10 @@ class SandboxManager: info["selected_because"] = self._resolved_from info["configured"] = settings.sandbox_backend info["defaults"] = SandboxLimits().model_dump() + info["candidates"] = [ + {"name": name, "available": getattr(impl, "available", lambda: True)()} + for name, impl in _BACKENDS + ] return info async def cleanup(self, session_id: str) -> None: @@ -98,4 +122,12 @@ class SandboxManager: await self._backend.close() +def _why(name: str) -> str: + return { + "seatbelt": "macOS 自带 sandbox-exec", + "bubblewrap": "检测到 bwrap", + "local": "无可用隔离原语", + }.get(name, "") + + sandbox_manager = SandboxManager() diff --git a/backend/app/sandbox/seatbelt_sandbox.py b/backend/app/sandbox/seatbelt_sandbox.py new file mode 100644 index 00000000..605021d9 --- /dev/null +++ b/backend/app/sandbox/seatbelt_sandbox.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import asyncio +import os +import resource +import shutil +import sys +import time +from pathlib import Path + +from app.core.config import settings +from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate + +SANDBOX_EXEC = "/usr/bin/sandbox-exec" + +def _real(path: str | Path) -> str: + """Seatbelt 按真实路径匹配,符号链接必须先解开。 + + macOS 上 /tmp 其实是 /private/tmp,不解析的话策略会整条失效。 + """ + return str(Path(path).resolve()) + + +def sandbox_python() -> str: + """沙箱里用的 Python 解释器。 + + 刻意用 `sys.base_prefix` 下的那个,而不是后端自己的 venv:venv 会把 + FastAPI、SQLAlchemy、provider 的凭据处理代码等一整套依赖挂进 sys.path, + 沙箱代码不该看得见它们。base_prefix 是一个干净的标准库环境。 + """ + base = Path(sys.base_prefix) + major, minor = sys.version_info.major, sys.version_info.minor + for candidate in ( + base / "bin" / f"python{major}.{minor}", + base / "bin" / f"python{major}", + base / "bin" / "python", + ): + if candidate.exists(): + return _real(candidate) + return _real(sys.executable) + + +_LANG = { + "python": ("main.py", lambda: [sandbox_python(), "-I", "-u", "main.py"]), + "bash": ("main.sh", lambda: ["/bin/bash", "main.sh"]), + "sh": ("main.sh", lambda: ["/bin/sh", "main.sh"]), + "node": ("main.js", lambda: [_real(shutil.which("node") or "node"), "main.js"]), + "javascript": ("main.js", lambda: [_real(shutil.which("node") or "node"), "main.js"]), +} + + +def _interpreter_roots(language: str) -> list[str]: + """解释器自身需要能读到的目录。 + + 只放行解释器的安装根(只读),不放行 sys.path —— 后者会把后端的 + site-packages 一起开出去。uv 把 Python 装在 ~/.local/share/uv/python/ 下, + 正好落在要禁读的家目录里,所以这条放行是必需的。 + """ + roots: set[str] = { + "/usr", "/System", "/Library", "/bin", "/sbin", "/opt", + "/private/var/select", "/private/var/db", + } + if language == "python": + roots.add(_real(sys.base_prefix)) + elif language in ("node", "javascript"): + node = shutil.which("node") + if node: + roots.add(str(Path(_real(node)).parent.parent)) + return sorted(roots) + + +def build_policy(workdir: Path, *, language: str, network: bool) -> str: + """生成 Seatbelt 策略(SBPL)。 + + 用「默认允许 + 精确拒绝」而不是 `(deny default)`:后者要把解释器启动所需的 + 每一次 syscall 和路径都枚举出来,稍有遗漏进程就直接 SIGABRT,实测 Python + 根本起不来。这里换个思路 —— 默认放行,然后把真正要守的三件事收紧: + 写盘只限工作区、家目录不可读、默认断网。 + + 规则从上往下匹配,后面的覆盖前面的,所以 allow 必须写在对应 deny 之后。 + """ + work = _real(workdir) + home = _real(Path.home()) + lines = [ + "(version 1)", + "(allow default)", + "", + ";; ---- 网络 ----", + ] + if network: + lines.append("(allow network*)") + else: + lines.append("(deny network*)") + + lines += [ + "", + ";; ---- 写:只有工作区 ----", + "(deny file-write*)", + f'(allow file-write* (subpath "{work}"))', + '(allow file-write-data (literal "/dev/null") (literal "/dev/stdout")' + ' (literal "/dev/stderr") (literal "/dev/dtracehelper"))', + '(allow file-write* (subpath "/private/var/folders"))', # 临时目录,Python 要用 + "", + ";; ---- 读:家目录挡住,放行工作区和解释器 ----", + f'(deny file-read* (subpath "{home}"))', + f'(allow file-read* (subpath "{work}"))', + ] + for root in _interpreter_roots(language): + lines.append(f'(allow file-read* (subpath "{root}"))') + + lines += [ + "", + ";; ---- 其他 ----", + "(allow process-exec process-fork)", # 子进程同样受这份策略约束 + "(allow sysctl-read)", + "(allow mach-lookup)", + "(deny file-read* (subpath \"/Users/Shared\"))", + ] + return "\n".join(lines) + "\n" + + +class SeatbeltSandbox(Sandbox): + """基于 macOS 原生 Seatbelt(sandbox-exec)的沙箱。 + + 内核级的强制访问控制,不需要虚拟机,启动开销约等于起一个进程。 + 策略对子进程是继承的,所以沙箱里再 exec 出来的东西一样跑不掉。 + + 和容器方案的差别要说清楚:这里做的是**访问控制**而不是虚拟化 —— + 文件系统和网络守得住,但进程表是共享的(能看到宿主机进程列表), + 而且 macOS 不认 RLIMIT_AS,**内存用量限不住**。CPU 时间、文件大小、 + 墙钟超时这几项由 rlimit 和超时杀进程组来兜。 + """ + + name = "seatbelt" + + def __init__(self) -> None: + self._root = settings.workspace_dir / "seatbelt" + self._policies = settings.data_dir / "sandbox-policies" + + @staticmethod + def available() -> bool: + return sys.platform == "darwin" and Path(SANDBOX_EXEC).exists() + + async def health(self) -> dict[str, object]: + ok = self.available() + return { + "backend": self.name, + "available": ok, + "isolated": ok, + "isolation": "macOS Seatbelt:文件系统 + 网络(内核强制,子进程继承)", + "limits_note": "macOS 不支持 RLIMIT_AS,内存用量无法限制;CPU 时间与墙钟超时有效", + # 如实申报:界面按这个把限不住的项划掉,避免给人虚假的安全感 + "enforced": {"timeout": True, "cpu": True, "memory": False, "network": True, "fsize": True}, + "root": str(self._root), + **({} if ok else {"error": "当前系统不是 macOS 或缺少 /usr/bin/sandbox-exec"}), + } + + def _session_dir(self, session_id: str) -> Path: + safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64] or "default" + path = self._root / safe + path.mkdir(parents=True, exist_ok=True) + return path + + @staticmethod + def _preexec(limits: SandboxLimits): # pragma: no cover - 只在子进程里执行 + def _apply() -> None: + # CPU 时间:能挡住纯计算的死循环(sleep 不计入,墙钟超时另外兜) + resource.setrlimit(resource.RLIMIT_CPU, (limits.timeout, limits.timeout + 2)) + # 单文件大小,防止把磁盘写满 + resource.setrlimit(resource.RLIMIT_FSIZE, (256 * 1024 * 1024,) * 2) + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + # 注意:不设 RLIMIT_NPROC —— macOS 上它按 uid 全局计数, + # 设成小值会让沙箱里任何 fork 都失败(连 bash 都起不来)。 + # 也不设 RLIMIT_AS —— Darwin 不强制执行,设了是自欺欺人。 + os.setsid() # 独立进程组,超时能整组干掉 + + return _apply + + async def run( + self, + code: str, + *, + language: str = "python", + limits: SandboxLimits | None = None, + session_id: str | None = None, + env: dict[str, str] | None = None, + files: dict[str, str] | None = None, + ) -> ExecResult: + limits = limits or SandboxLimits() + language = (language or "python").lower() + if language not in _LANG: + return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}") + if not self.available(): + return ExecResult(ok=False, backend=self.name, error="sandbox-exec 不可用") + + filename, argv_of = _LANG[language] + workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}") + (workdir / filename).write_text(code) + + for rel, content in (files or {}).items(): + target = (workdir / rel).resolve() + if not str(target).startswith(str(workdir.resolve())): + return ExecResult(ok=False, backend=self.name, error=f"非法文件路径:{rel}") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + + self._policies.mkdir(parents=True, exist_ok=True) + policy_file = self._policies / f"{workdir.name}-{language}.sb" + policy_file.write_text(build_policy(workdir, language=language, network=limits.network)) + + argv = [SANDBOX_EXEC, "-f", str(policy_file), *argv_of()] + + # HOME 指向工作区:既挡住家目录,又让解释器的缓存有地方落 + child_env = { + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin", + "HOME": str(workdir), + "TMPDIR": str(workdir), + "LANG": "en_US.UTF-8", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1", + **(env or {}), + } + + started = time.perf_counter() + try: + proc = await asyncio.create_subprocess_exec( + *argv, + cwd=str(workdir), + env=child_env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + preexec_fn=self._preexec(limits), + ) + except Exception as e: # noqa: BLE001 + return ExecResult(ok=False, backend=self.name, error=f"{type(e).__name__}: {e}") + + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=limits.timeout) + except asyncio.TimeoutError: + self._kill(proc) + await proc.wait() + return ExecResult( + ok=False, + backend=self.name, + timed_out=True, + exit_code=124, + duration_ms=int((time.perf_counter() - started) * 1000), + error=f"执行超过 {limits.timeout}s 被终止", + ) + + stdout, t1 = truncate(out.decode(errors="replace"), limits.max_output) + stderr, t2 = truncate(err.decode(errors="replace"), limits.max_output) + code_ = proc.returncode or 0 + # CPU 时间用尽是 SIGXCPU(24),归到超时里更好理解 + timed_out = code_ in (-24, 152) + written = [ + p.name for p in workdir.iterdir() if p.is_file() and p.name != filename + ][:50] + + return ExecResult( + ok=code_ == 0, + exit_code=code_, + stdout=stdout, + stderr=stderr if not timed_out else (stderr or "CPU 时间超限,已终止"), + truncated=t1 or t2, + timed_out=timed_out, + backend=self.name, + duration_ms=int((time.perf_counter() - started) * 1000), + files_written=written, + ) + + @staticmethod + def _kill(proc: asyncio.subprocess.Process) -> None: + try: + os.killpg(os.getpgid(proc.pid), 9) + except (ProcessLookupError, PermissionError): + proc.kill() + + async def cleanup(self, session_id: str) -> None: + path = self._session_dir(session_id) + await asyncio.to_thread(shutil.rmtree, path, True) + for stale in self._policies.glob(f"{path.name}-*.sb"): + stale.unlink(missing_ok=True) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fd48a9f1..1b64dc29 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ "langchain-openai", "langchain-mcp-adapters", "mcp", - "docker>=7.1", "numpy>=2.0", "jsonschema>=4.23", "beautifulsoup4>=4.12", diff --git a/backend/uv.lock b/backend/uv.lock index ccb508cc..3854f238 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -16,7 +16,6 @@ dependencies = [ { name = "aiosqlite" }, { name = "beautifulsoup4" }, { name = "cryptography" }, - { name = "docker" }, { name = "fastapi" }, { name = "greenlet" }, { name = "httpx" }, @@ -41,7 +40,6 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, { name = "beautifulsoup4", specifier = ">=4.12" }, { name = "cryptography", specifier = ">=43.0" }, - { name = "docker", specifier = ">=7.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "greenlet", specifier = ">=3.1" }, { name = "httpx", specifier = ">=0.28" }, @@ -434,20 +432,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "docker" -version = "7.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, -] - [[package]] name = "docstring-parser" version = "0.18.0" diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 95b32641..dfe57084 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -409,8 +409,22 @@ function SystemTab() { } /> - {sandbox.image && } - {sandbox.docker_version && } + {sandbox.isolation && } + {sandbox.limits_note && } + {!!sandbox.candidates?.length && ( + + {sandbox.candidates.map((c: any) => ( + + {c.name}{c.available ? '' : ' · 不可用'} + + ))} + + } /> + )} {sandbox.warning && (
@@ -419,7 +433,12 @@ function SystemTab() { )} {sandbox.defaults && ( + + + + + } /> )}
@@ -439,6 +458,19 @@ function SystemTab() { ) } +/** 限额项:当前后端管不住的,划掉并注明,别给人虚假的安全感。 */ +function Limit({ on, text }: { on: boolean; text: string }) { + return on ? ( + + {text} + + ) : ( + + {text} · 不生效 + + ) +} + function Row({ label, value }: { label: string; value: any }) { return (