Files
AgentLab/backend/app/sandbox/local_sandbox.py
T
ThearchyHelios dead51ae56 数据目录里的文件一律先写临时文件再改名:新增 core/atomic(同目录、带随机后缀的临时文件,写完 os.replace;只该生成一次的用 os.link 建,已有就不动)
- 工件(内容寻址):以前共用 <id>.tmp,两个进程同时写同一份内容时后到的会截断先到的半成品、先到的改名又让后到的改名失败;现在各写各的临时文件
- 加密密钥 .secret_key、断言签名私钥:几个进程同时第一次启动时只建出一把,后到的读先到的那一把(以前各生成一把,各自加密、签名的东西对方认不得),文件从建出来起就是 0600
- 附件抽出文字的缓存:临时文件名带随机后缀
- 文件工具(file_write)的覆盖和追加、三种系统沙箱写进工作目录的代码和输入文件、seatbelt 的策略文件:先写临时文件再改名
- 上传表格的版本库、原件存档、记录表本来就是这样写的,不动
2026-10-19 10:01:20 +08:00

181 lines
7.2 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 sys
import time
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__)
# 入口脚本后缀 + 执行命令。文件名运行时生成(entry_name),不能写死:
# 同会话并发执行都写 main.py 会互相覆盖。
_LANG = {
"python": (".py", None), # 运行时用 interpreter.python_argv()
"bash": (".sh", ["bash"]),
"sh": (".sh", ["sh"]),
"node": (".js", ["node"]),
"javascript": (".js", ["node"]),
}
class LocalSandbox(Sandbox):
"""裸子进程执行,所有隔离后端都不可用时的最后兜底。
只有 rlimit 和一个独立工作目录,**没有任何访问控制**:代码能读你的整个
文件系统、能联网。正常情况下不会走到这里 —— macOS 有 Seatbelt、
Linux 装个 bubblewrap 即可。真要用它跑不信任的代码,别。
"""
name = "local"
def __init__(self) -> None:
self._root = settings.workspace_dir / "local"
self._root.mkdir(parents=True, exist_ok=True)
async def health(self) -> dict[str, object]:
return {
"backend": self.name,
"available": True,
"isolated": False,
"warning": "以本地子进程执行,无访问控制,请勿运行不可信的代码",
"enforced": {"timeout": True, "cpu": True,
"memory": not sys.platform == "darwin", "network": False, "fsize": True},
"root": str(self._root),
"interpreter": interpreter.describe(),
}
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))
# 注意 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)
if res_id is not None:
try:
resource.setrlimit(res_id, (nbytes, nbytes))
except (ValueError, OSError):
pass
resource.setrlimit(resource.RLIMIT_FSIZE, (64 * 1024 * 1024,) * 2)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
# 不设 RLIMIT_NPROC:macOS 上它按 uid 全局计数,设小了会让沙箱里
# 任何 fork 都失败(bash 直接起不来),而不是只限制沙箱自己。
os.setsid() # 独立进程组,超时时能整组 kill
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}")
ext, cmd = _LANG[language]
# python 的命令前缀运行时才定:要挑一个看不见后端依赖的解释器
cmd = cmd if cmd is not None else interpreter.python_argv()
entry = entry_name(ext)
argv = [*cmd, entry]
if shutil.which(argv[0]) is None and argv[0] != sys.executable:
return ExecResult(
ok=False, backend=self.name, error=f"本机找不到 {argv[0]}"
)
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)
# 只透传最小环境变量,避免把宿主机的 API key 泄漏给被执行的代码
child_env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": str(workdir),
"LANG": "en_US.UTF-8",
"PYTHONDONTWRITEBYTECODE": "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
# 首行只说原因,类名和 errno 这些留给日志
logger.warning("%s 沙箱起进程失败:%s", self.name, raw(e))
return ExecResult(ok=False, backend=self.name, error=f"无法启动进程:{describe_exception(e)}")
try:
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} 秒,已终止",
)
stdout, t1 = truncate(out.decode(errors="replace"), limits.max_output)
stderr, t2 = truncate(err.decode(errors="replace"), limits.max_output)
written = [
p.name
for p in workdir.iterdir()
if p.is_file() and not p.name.startswith(ENTRY_PREFIX)
][:50]
return ExecResult(
ok=proc.returncode == 0,
exit_code=proc.returncode or 0,
stdout=stdout,
stderr=stderr,
truncated=t1 or t2,
backend=self.name,
duration_ms=int((time.perf_counter() - started) * 1000),
files_written=written,
)
async def cleanup(self, session_id: str) -> None:
path = self._session_dir(session_id)
await asyncio.to_thread(shutil.rmtree, path, True)