Files
AgentLab/backend/app/sandbox/bubblewrap_sandbox.py
T
ThearchyHelios ba87a93ef7 bubblewrap 沙箱不再继承后端的环境变量(复核 R-a5):bwrap 参数最前面加 --clearenv,再按缺省(HOME、TMPDIR、PATH、LANG、PYTHONDONTWRITEBYTECODE)和调用方给的逐条 --setenv;bwrap 进程本身也只拿 PATH
以前被执行的代码看得到后端进程的全部环境变量:模型的 API Key,现在又多一条带密码的 AGENTLAB_DATABASE_URL。local、seatbelt 一直只给最小的一套。调用方给的变量以前插在最前面、会被缺省的盖掉,现在放在最后、能盖过缺省的,同另外两种沙箱。测试(本机没有 bwrap,测参数和进程环境的组装):--clearenv 在所有 --setenv 前面,AGENTLAB_* / *_API_KEY 的名字和值都不出现,bwrap 进程的环境只有 PATH
2026-10-19 12:07:31 +08:00

242 lines
10 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.
from __future__ import annotations
import asyncio
import logging
import os
import resource
import shutil
import subprocess
import sys
import time
from functools import lru_cache
from pathlib import Path
from app.core import atomic
from app.core.config import settings, sanitize_session
from app.core.errors import describe_exception, raw
from app.sandbox import interpreter
from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate
logger = logging.getLogger(__name__)
# 入口脚本后缀 + 执行命令前缀(沙箱内路径在运行时拼)。文件名不能写死:
# 同会话并发执行都写 main.py 会互相覆盖。
_LANG = {
"python": (".py", None), # 运行时用 interpreter.python_argv()
"bash": (".sh", ["/bin/bash"]),
"sh": (".sh", ["/bin/sh"]),
"node": (".js", ["node"]),
"javascript": (".js", ["node"]),
}
# 只读挂进沙箱的系统目录,存在才挂
_RO_BINDS = ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives", "/etc/ssl"]
def _on_linux() -> bool:
return sys.platform.startswith("linux")
@lru_cache(maxsize=4)
def _probe(bwrap: str) -> str:
"""真起一次 bwrap,返回失败原因;空串表示能用。
装了 bwrap 不等于建得了隔离环境:Docker 默认的 seccomp 配置不许非特权进程建
user namespace,Ubuntu 24.04 的 AppArmor 默认也限制它。以前 available() 只看
二进制在不在,于是容器里 auto 选中 bubblewrap,每次执行都报 "No permissions to
create a new namespace"——而本地子进程明明能用。
探测带上执行时同样要用的命名空间、/proc 和 /dev,任何一样建不起来都算不可用。
结果缓存:内核和容器权限在进程生命周期里不会变。
"""
argv = [
bwrap, "--unshare-all", "--die-with-parent", "--new-session",
"--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev",
"--", shutil.which("true") or "/bin/true",
]
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError) as e:
return raw(e)
if proc.returncode == 0:
return ""
lines = [line.strip() for line in (proc.stderr or "").splitlines() if line.strip()]
return lines[-1] if lines else f"退出码 {proc.returncode}"
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:
if not _on_linux():
return False
bwrap = shutil.which("bwrap")
return bwrap is not None and not _probe(bwrap)
@staticmethod
def probe_failure() -> str:
"""装了 bwrap 却建不起隔离环境时的原因;没装或者能用都返回空串。"""
bwrap = shutil.which("bwrap") if _on_linux() else None
return _probe(bwrap) if bwrap else ""
@classmethod
def unavailable_reason(cls) -> str:
failure = cls.probe_failure()
if failure:
return f"已安装 bwrap,但无法创建隔离环境(容器默认权限或系统策略不允许):{failure}"
return "未找到 bwrap,可执行 apt install bubblewrap 安装"
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),
"interpreter": interpreter.describe(),
**({} if ok else {"error": self.unavailable_reason()}),
}
def _session_dir(self, session_id: str) -> Path:
# 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份:
# 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。
path = self._root / sanitize_session(session_id)
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],
env: dict[str, str] | None = None) -> list[str]:
argv = [
self._bwrap or "bwrap",
# 先清空环境变量,再一条条设:bwrap 按参数的先后处理,--clearenv 要在所有 --setenv 前面。以前不清,被执行的
# 代码看得到后端进程的全部环境变量(模型的 API Key、带密码的 AGENTLAB_DATABASE_URL);local、seatbelt 一直只给
# 最小的一套
"--clearenv",
"--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", "LANG", "C.UTF-8",
"--setenv", "PYTHONDONTWRITEBYTECODE", "1",
]
for key, value in (env or {}).items():
argv += ["--setenv", key, value]
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=self.unavailable_reason())
ext, cmd_prefix = _LANG[language]
# python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器
cmd_prefix = cmd_prefix if cmd_prefix is not None else interpreter.python_argv()
entry = entry_name(ext)
cmd = [*cmd_prefix, f"/work/{entry}"]
workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}")
atomic.write_text(workdir / entry, 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)
atomic.write_text(target, content)
argv = self._argv(workdir, limits, cmd, env)
started = time.perf_counter()
try:
proc = await asyncio.create_subprocess_exec(
*argv,
# bwrap 自己也只拿最小的环境:沙箱里的进程靠上面的 --setenv,不从这里继承
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin")},
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} 秒,已终止",
)
except Exception as e: # noqa: BLE001
# 起进程和收输出在同一个 try 里。首行只说原因,类名和 errno 这些留给日志
logger.warning("%s 沙箱进程出错:%s", self.name, raw(e))
return ExecResult(ok=False, backend=self.name, error=f"沙箱进程出错:{describe_exception(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 not p.name.startswith(ENTRY_PREFIX)][:50],
)
async def cleanup(self, session_id: str) -> None:
await asyncio.to_thread(shutil.rmtree, self._session_dir(session_id), True)