mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:51:59 +00:00
dd57c12016
跑一段不可信代码不需要一整套容器运行时。macOS 用 Seatbelt (sandbox-exec,系统自带),Linux 用 bubblewrap;冷启动从 ~250ms 降到 ~25ms,且不再需要装任何东西。 守住三件事:默认断网、家目录不可读、只有工作区可写。策略由内核 强制且对子进程继承——沙箱内 subprocess 出来的 cat 读家目录同样是 Operation not permitted。解释器改用 sys.base_prefix 下那个干净的 Python,沙箱代码因此 import 不到后端的 FastAPI、SQLAlchemy 和凭据 处理代码。 如实标注能力边界,不给虚假的安全感: - macOS 不强制 RLIMIT_AS,内存限不住(实测设 256MB 仍可分配 900MB), health 接口申报 enforced 字段,界面把限不住的项划掉 - Seatbelt 是访问控制而非虚拟化:进程表共享,以当前用户身份运行 - bubblewrap 侧多隔离 PID/IPC/挂载视图,内存限额真实生效 顺带修两个 bug: - LocalSandbox 设 RLIMIT_NPROC=64 会误伤——macOS 上它按 uid 全局 计数,用户已有上千进程时沙箱内任何 fork 都失败,连 bash 都起不来 - 模型所属 provider 被停用时,原先静默换一家继续用原模型名,必然 404 且报错指不到原因;改为直接说清是哪个 provider 被停用了 写 SBPL 的坑记在注释里:(deny default) 会让 Python 直接 SIGABRT, 改用「默认允许 + 精确拒绝」。 7 个模板全部回归通过。
134 lines
4.1 KiB
Python
134 lines
4.1 KiB
Python
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.bubblewrap_sandbox import BubblewrapSandbox
|
||
from app.sandbox.local_sandbox import LocalSandbox
|
||
from app.sandbox.seatbelt_sandbox import SeatbeltSandbox
|
||
|
||
|
||
class DisabledSandbox(Sandbox):
|
||
name = "off"
|
||
|
||
async def run(self, code: str, **kwargs: Any) -> ExecResult: # type: ignore[override]
|
||
return ExecResult(
|
||
ok=False,
|
||
backend=self.name,
|
||
error="沙箱已在设置中关闭(AGENTLAB_SANDBOX_BACKEND=off)",
|
||
)
|
||
|
||
async def health(self) -> dict[str, Any]:
|
||
return {"backend": self.name, "available": False, "isolated": False, "reason": "已关闭"}
|
||
|
||
|
||
# 按隔离强度从高到低排,auto 模式挑第一个能用的
|
||
_BACKENDS: list[tuple[str, type[Sandbox]]] = [
|
||
("bubblewrap", BubblewrapSandbox),
|
||
("seatbelt", SeatbeltSandbox),
|
||
("local", LocalSandbox),
|
||
]
|
||
|
||
|
||
class SandboxManager:
|
||
"""挑选并持有代码执行后端。
|
||
|
||
用操作系统自带的隔离原语,不依赖容器运行时:macOS 走 Seatbelt
|
||
(`sandbox-exec`,系统自带),Linux 走 bubblewrap。两者都是进程级启动,
|
||
没有虚拟机开销,冷启动在几十毫秒量级。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._backend: Sandbox | None = None
|
||
self._lock = asyncio.Lock()
|
||
self._resolved_from: str = ""
|
||
|
||
async def get(self) -> Sandbox:
|
||
if self._backend is not None:
|
||
return self._backend
|
||
async with self._lock:
|
||
if self._backend is not None:
|
||
return self._backend
|
||
self._backend = self._resolve()
|
||
return self._backend
|
||
|
||
def _resolve(self) -> Sandbox:
|
||
choice = (settings.sandbox_backend or "auto").lower()
|
||
|
||
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(
|
||
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:
|
||
backend = await self.get()
|
||
return await backend.run(
|
||
code,
|
||
language=language,
|
||
limits=limits,
|
||
session_id=session_id,
|
||
env=env,
|
||
files=files,
|
||
)
|
||
|
||
async def health(self) -> dict[str, Any]:
|
||
backend = await self.get()
|
||
info = await backend.health()
|
||
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:
|
||
backend = await self.get()
|
||
await backend.cleanup(session_id)
|
||
|
||
async def close(self) -> None:
|
||
if self._backend:
|
||
await self._backend.close()
|
||
|
||
|
||
def _why(name: str) -> str:
|
||
return {
|
||
"seatbelt": "macOS 自带 sandbox-exec",
|
||
"bubblewrap": "检测到 bwrap",
|
||
"local": "无可用隔离原语",
|
||
}.get(name, "")
|
||
|
||
|
||
sandbox_manager = SandboxManager()
|