mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:12:01 +00:00
修两条 critical:文件工具可读任意路径、并发代码节点互相覆盖
## 文件工具可读任意文件(含加密主密钥)
sandbox_session 从请求体直接拼进工作区路径,没有任何净化。传 ".." 就能把
根目录挪出工作区,之后 _resolve 的越界检查是在这个被挪走的根下做的,等于失效:
curl -X POST /api/tools/file_read/run -d '{"sandbox_session":"..","args":{"path":".secret_key"}}'
→ {"ok":true,"content":"8UygDbx9..."} # 加密所有 provider Key 的 Fernet 主密钥
根因是同一段净化逻辑被抄了五遍:四个沙箱后端各一份,文件工具那份漏抄了。
所以提到 config.sanitize_session 统一一份,五处共用;RunToolIn.sandbox_session
再加 pattern 做纵深防御——畸形 session 直接 422,而不是悄悄净化成 default。
## 并发代码节点互相覆盖入口脚本
同一 run 的节点共用一个沙箱会话,而入口脚本写死 main.py。README 主打的
"连线即并行"会让并排的代码节点在同一 superstep 并发执行,都写 main.py 就
互相覆盖——后写的赢,每个节点却都拿到 ok=True 和"自己的"返回值。
8 路并发同 session:7/8 执行了别人的代码,error 全为 None
改成每次执行生成唯一入口脚本(__entry_<uuid>.py),跑完即删。共享工作区语义
保留,跨节点传文件照常。四个后端同构改造,实测 8/16 路并发均 0 串味。
## 顺带修掉两个自己埋的坑
- conda 迁移把沙箱解释器隔离弄丢了:conda 环境 sys.prefix == sys.base_prefix,
于是"干净解释器"挑出来的就是项目环境自己,沙箱代码能 import cryptography
直接解密落库的 Key。改成实际探测候选解释器是否看得见敏感包,都不干净就
退回 -S 关掉 site-packages。README 那句承诺现在重新成立了。
- -I 在 3.11+ 隐含 -P,脚本目录不进 sys.path,代码节点的 files 参数(多文件)
在 seatbelt/bubblewrap 下一直是坏的,import helper 直接 ModuleNotFoundError。
换成 -s -E,隔离意图不变,同目录 import 恢复。
- microvm 的 files_written 取 FsEntry.name(不存在),塞进去的是对象 repr。
改取 path 的尾段。
沙箱回归 16 项通过(两档各 8 项)。
This commit is contained in:
@@ -58,7 +58,10 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[
|
||||
|
||||
class RunToolIn(BaseModel):
|
||||
args: dict[str, Any] = Field(default_factory=dict)
|
||||
sandbox_session: str = "playground"
|
||||
# 这个字段会被拼进工作目录路径。净化在 sanitize_session 里兜底,这里
|
||||
# 再加一道模式校验做纵深防御——畸形的 session 直接 422 拒掉,
|
||||
# 而不是悄悄被净化成 "default" 让调用方以为自己写对了。
|
||||
sandbox_session: str = Field(default="playground", pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
@router.post("/{tool_name:path}/run")
|
||||
|
||||
@@ -8,6 +8,21 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
PROJECT_DIR = BACKEND_DIR.parent
|
||||
|
||||
# session_id 拼进路径前必须过这一关。允许的字符刻意收得很窄:字母数字加
|
||||
# 连字符下划线——点号也不放行,免得出现 ".." 这种能往上走的名字。
|
||||
_SESSION_MAX = 64
|
||||
|
||||
|
||||
def sanitize_session(session_id: str | None) -> str:
|
||||
"""把任意字符串收敛成一个能安全当目录名的 token。
|
||||
|
||||
以前这段逻辑在四个沙箱后端里各抄了一遍,而文件工具那条路径漏抄了,
|
||||
结果 sandbox_session 传 ".." 就能读到工作区外的文件(连加密主密钥都
|
||||
读得到)。所以统一收到这里,只此一份。
|
||||
"""
|
||||
safe = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")
|
||||
return safe[:_SESSION_MAX] or "default"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""全局配置。所有项都可以用 AGENTLAB_ 前缀的环境变量覆盖。"""
|
||||
@@ -77,6 +92,21 @@ class Settings(BaseSettings):
|
||||
"""沙箱与文件工具的可写根目录,一切文件访问都被限制在这里面。"""
|
||||
return self.data_dir / "workspace"
|
||||
|
||||
def session_dir(self, session_id: str | None, *, sub: str = "") -> Path:
|
||||
"""会话工作目录。session_id 一律先净化再拼路径。
|
||||
|
||||
这个函数存在的理由:session_id 有些调用点是外部可控的(工具 API 的
|
||||
请求体就能指定),拿它直接拼路径就等于把工作区根目录交给调用方摆布,
|
||||
后面再怎么做越界检查都是在被挪走的根下面做的,形同虚设。
|
||||
"""
|
||||
safe = sanitize_session(session_id)
|
||||
root = (self.workspace_dir / (sub or "") / safe).resolve()
|
||||
base = self.workspace_dir.resolve()
|
||||
# 净化之后理应不可能跑出去,这里再断言一次:这条路径值得多一道保险
|
||||
if root != base and base not in root.parents:
|
||||
raise ValueError(f"非法的 session 目录:{session_id!r}")
|
||||
return root
|
||||
|
||||
@property
|
||||
def uploads_dir(self) -> Path:
|
||||
return self.data_dir / "uploads"
|
||||
|
||||
@@ -2,11 +2,27 @@ from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 入口脚本的前缀。列"这次执行产出了哪些文件"时要把它们排掉——
|
||||
# 它们是执行机制自己的临时文件,不是用户代码的产出。
|
||||
ENTRY_PREFIX = "__entry_"
|
||||
|
||||
|
||||
def entry_name(ext: str) -> str:
|
||||
"""给这一次执行生成独立的入口脚本名。
|
||||
|
||||
同一个会话共用一个工作区(跨节点传文件要靠它),所以入口脚本**不能**
|
||||
用固定的 main.py:一张图里并排的两个代码节点会在同一 superstep 并发执行,
|
||||
都写 main.py 就会互相覆盖——后写的赢,而每个节点都以为跑的是自己的代码,
|
||||
还都返回 ok。这是静默的结果污染,比报错难查得多。
|
||||
"""
|
||||
return f"{ENTRY_PREFIX}{uuid4().hex[:12]}{ext}"
|
||||
|
||||
|
||||
class SandboxLimits(BaseModel):
|
||||
timeout: int = Field(default_factory=lambda: settings.sandbox_timeout)
|
||||
|
||||
@@ -8,15 +8,18 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate
|
||||
from app.core.config import settings, sanitize_session
|
||||
from app.sandbox import interpreter
|
||||
from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate
|
||||
|
||||
# 入口脚本后缀 + 执行命令前缀(沙箱内路径在运行时拼)。文件名不能写死:
|
||||
# 同会话并发执行都写 main.py 会互相覆盖。
|
||||
_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"]),
|
||||
"python": (".py", None), # 运行时用 interpreter.python_argv()
|
||||
"bash": (".sh", ["/bin/bash"]),
|
||||
"sh": (".sh", ["/bin/sh"]),
|
||||
"node": (".js", ["node"]),
|
||||
"javascript": (".js", ["node"]),
|
||||
}
|
||||
|
||||
# 只读挂进沙箱的系统目录,存在才挂
|
||||
@@ -51,12 +54,14 @@ class BubblewrapSandbox(Sandbox):
|
||||
"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": "未找到 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
|
||||
# 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份:
|
||||
# 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。
|
||||
path = self._root / sanitize_session(session_id)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
@@ -112,9 +117,13 @@ class BubblewrapSandbox(Sandbox):
|
||||
if not self.available():
|
||||
return ExecResult(ok=False, backend=self.name, error="bwrap 不可用")
|
||||
|
||||
filename, cmd = _LANG[language]
|
||||
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)}")
|
||||
(workdir / filename).write_text(code)
|
||||
(workdir / entry).write_text(code)
|
||||
for rel, content in (files or {}).items():
|
||||
target = (workdir / rel).resolve()
|
||||
if not str(target).startswith(str(workdir.resolve())):
|
||||
@@ -161,7 +170,7 @@ class BubblewrapSandbox(Sandbox):
|
||||
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],
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""给沙箱挑一个看不见后端依赖的 Python 解释器。
|
||||
|
||||
原先的做法是「用 `sys.base_prefix` 下的那个」,理由是 venv 会把 FastAPI、
|
||||
SQLAlchemy、凭据处理那一套挂进 sys.path,而 base_prefix 是干净的标准库环境。
|
||||
|
||||
这个假设在 venv 下成立,在 conda 下不成立:conda 环境没有 venv 那层分离,
|
||||
`sys.prefix == sys.base_prefix`,于是"干净解释器"挑出来的就是项目环境本身,
|
||||
沙箱代码能直接 `import cryptography` 去解密落库的 API Key。
|
||||
|
||||
所以这里不再假设,而是**实际验证**:挨个候选跑一下,看敏感包是否可见。
|
||||
都不干净时退回当前解释器并加 `-S` 关掉 site-packages —— 标准库照常可用,
|
||||
第三方包一个也看不见。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
# 沙箱里绝不该出现的包。看得见任何一个,这个解释器就不算干净。
|
||||
# anthropic/openai 带着 provider 客户端,cryptography 能解密落库的 Key,
|
||||
# sqlalchemy 能直接连库,fastapi 则说明整个后端环境都在。
|
||||
_SENSITIVE = ("fastapi", "sqlalchemy", "cryptography", "anthropic", "openai")
|
||||
|
||||
_PROBE = (
|
||||
"import importlib.util as u;"
|
||||
f"print(int(any(u.find_spec(m) for m in {_SENSITIVE!r})))"
|
||||
)
|
||||
|
||||
|
||||
def _is_clean(exe: str) -> bool:
|
||||
"""跑一下这个解释器,看敏感包是否可见。探测失败就当它不干净。"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[exe, "-c", _PROBE],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return out.returncode == 0 and out.stdout.strip() == "0"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def _candidates() -> list[str]:
|
||||
"""按优先级列出候选,重复的去掉。
|
||||
|
||||
先试 base_prefix(venv 下这就是对的),再试系统自带的 python3
|
||||
(macOS/Linux 都有,且不会装项目依赖),最后是 Homebrew 的。
|
||||
"""
|
||||
base = Path(sys.base_prefix) / "bin"
|
||||
major, minor = sys.version_info.major, sys.version_info.minor
|
||||
raw = [
|
||||
base / f"python{major}.{minor}",
|
||||
base / f"python{major}",
|
||||
base / "python",
|
||||
Path("/usr/bin/python3"),
|
||||
Path("/usr/local/bin/python3"),
|
||||
Path("/opt/homebrew/bin/python3"),
|
||||
]
|
||||
seen, out = set(), []
|
||||
for p in raw:
|
||||
try:
|
||||
real = str(p.resolve())
|
||||
except OSError:
|
||||
continue
|
||||
if real in seen or not p.exists():
|
||||
continue
|
||||
seen.add(real)
|
||||
out.append(real)
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def resolve() -> tuple[str, bool]:
|
||||
"""返回 (解释器路径, 是否干净)。探测有子进程开销,所以缓存。"""
|
||||
for exe in _candidates():
|
||||
if _is_clean(exe):
|
||||
return exe, True
|
||||
return str(Path(sys.executable).resolve()), False
|
||||
|
||||
|
||||
def python_argv() -> list[str]:
|
||||
"""沙箱执行 Python 时的命令前缀(不含脚本名)。
|
||||
|
||||
刻意**不用** `-I`:它在 3.11+ 隐含 `-P`,脚本所在目录不会进 sys.path,
|
||||
于是代码节点的 files 参数(多文件)整个失效 —— `import helper` 报
|
||||
ModuleNotFoundError。用 `-s -E` 拿到同样的隔离意图(不加 user site、
|
||||
忽略 PYTHONPATH 之类的环境变量),又不影响同目录 import。
|
||||
"""
|
||||
exe, clean = resolve()
|
||||
flags = ["-s", "-E", "-u"] if clean else ["-S", "-s", "-E", "-u"]
|
||||
return [exe, *flags]
|
||||
|
||||
|
||||
def describe() -> dict[str, object]:
|
||||
"""给 health 用:说清楚沙箱到底在用哪个解释器、干不干净。"""
|
||||
exe, clean = resolve()
|
||||
return {
|
||||
"python": exe,
|
||||
"clean": clean,
|
||||
"note": (
|
||||
"解释器本身看不到后端依赖"
|
||||
if clean else
|
||||
"没找到干净的解释器,改用 -S 关掉 site-packages(标准库仍可用,第三方包不可见)"
|
||||
),
|
||||
}
|
||||
@@ -8,15 +8,18 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate
|
||||
from app.core.config import settings, sanitize_session
|
||||
from app.sandbox import interpreter
|
||||
from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate
|
||||
|
||||
# 入口脚本后缀 + 执行命令。文件名运行时生成(entry_name),不能写死:
|
||||
# 同会话并发执行都写 main.py 会互相覆盖。
|
||||
_LANG = {
|
||||
"python": ("main.py", [sys.executable, "-u", "main.py"]),
|
||||
"bash": ("main.sh", ["bash", "main.sh"]),
|
||||
"sh": ("main.sh", ["sh", "main.sh"]),
|
||||
"node": ("main.js", ["node", "main.js"]),
|
||||
"javascript": ("main.js", ["node", "main.js"]),
|
||||
"python": (".py", None), # 运行时用 interpreter.python_argv()
|
||||
"bash": (".sh", ["bash"]),
|
||||
"sh": (".sh", ["sh"]),
|
||||
"node": (".js", ["node"]),
|
||||
"javascript": (".js", ["node"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +46,13 @@ class LocalSandbox(Sandbox):
|
||||
"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:
|
||||
safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64] or "default"
|
||||
path = self._root / safe
|
||||
# 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份:
|
||||
# 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。
|
||||
path = self._root / sanitize_session(session_id)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
@@ -87,14 +92,18 @@ class LocalSandbox(Sandbox):
|
||||
if language not in _LANG:
|
||||
return ExecResult(ok=False, backend=self.name, error=f"不支持的语言:{language}")
|
||||
|
||||
filename, argv = _LANG[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)}")
|
||||
(workdir / filename).write_text(code)
|
||||
(workdir / entry).write_text(code)
|
||||
for rel, content in (files or {}).items():
|
||||
target = (workdir / rel).resolve()
|
||||
if not str(target).startswith(str(workdir.resolve())):
|
||||
@@ -146,7 +155,7 @@ class LocalSandbox(Sandbox):
|
||||
written = [
|
||||
p.name
|
||||
for p in workdir.iterdir()
|
||||
if p.is_file() and p.name != filename
|
||||
if p.is_file() and not p.name.startswith(ENTRY_PREFIX)
|
||||
][:50]
|
||||
return ExecResult(
|
||||
ok=proc.returncode == 0,
|
||||
|
||||
@@ -6,18 +6,26 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate
|
||||
from app.core.config import sanitize_session, settings
|
||||
from app.sandbox.base import (
|
||||
ENTRY_PREFIX,
|
||||
ExecResult,
|
||||
Sandbox,
|
||||
SandboxLimits,
|
||||
entry_name,
|
||||
truncate,
|
||||
)
|
||||
|
||||
_WORKDIR = "/workspace"
|
||||
|
||||
# 各语言在 VM 里的落地文件与执行命令
|
||||
# 各语言的入口脚本后缀与执行命令。文件名在运行时生成(见 entry_name),
|
||||
# 不能写死 —— 同会话并发执行会互相覆盖。
|
||||
_LANG = {
|
||||
"python": ("main.py", ["python", "-u", "main.py"]),
|
||||
"bash": ("main.sh", ["/bin/bash", "main.sh"]),
|
||||
"sh": ("main.sh", ["/bin/sh", "main.sh"]),
|
||||
"node": ("main.js", ["node", "main.js"]),
|
||||
"javascript": ("main.js", ["node", "main.js"]),
|
||||
"python": (".py", ["python", "-u"]),
|
||||
"bash": (".sh", ["/bin/bash"]),
|
||||
"sh": (".sh", ["/bin/sh"]),
|
||||
"node": (".js", ["node"]),
|
||||
"javascript": (".js", ["node"]),
|
||||
}
|
||||
|
||||
_MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
@@ -141,9 +149,10 @@ class MicroVMSandbox(Sandbox):
|
||||
# ---------------- 会话(长驻 VM)----------------
|
||||
|
||||
def _vm_name(self, session_id: str) -> str:
|
||||
# SDK 要求:首字符必须是字母数字,其余只能是字母数字/点/横线/下划线
|
||||
safe = "".join(c for c in session_id if c.isalnum() or c in "-_.")[:96]
|
||||
return f"agentlab-{safe or 'default'}"
|
||||
# SDK 要求首字符是字母数字,其余只能是字母数字/点/横线/下划线。
|
||||
# 这里直接用共用的净化规则(比 SDK 的要求更严,不放行点号),
|
||||
# 让 VM 名和别处的会话目录名保持一致。
|
||||
return f"agentlab-{sanitize_session(session_id)}"
|
||||
|
||||
async def _lease(self, session_id: str, limits: SandboxLimits) -> _Lease:
|
||||
"""拿到该会话的 VM。限额变了就重建,否则复用(热复用约 7ms)。"""
|
||||
@@ -223,7 +232,8 @@ class MicroVMSandbox(Sandbox):
|
||||
error="microVM 运行时未就绪:pip install 'agentlab-backend[microvm]' 后执行一次 install",
|
||||
)
|
||||
|
||||
filename, argv = _LANG[language]
|
||||
ext, cmd = _LANG[language]
|
||||
entry = entry_name(ext)
|
||||
sid = session_id or f"tmp{int(time.time() * 1000)}"
|
||||
ephemeral = session_id is None
|
||||
started = time.perf_counter()
|
||||
@@ -247,7 +257,7 @@ class MicroVMSandbox(Sandbox):
|
||||
|
||||
try:
|
||||
payload = dict(files or {})
|
||||
payload[filename] = code
|
||||
payload[entry] = code
|
||||
for rel, content in payload.items():
|
||||
rel = rel.lstrip("/")
|
||||
if ".." in rel.split("/"):
|
||||
@@ -265,7 +275,7 @@ class MicroVMSandbox(Sandbox):
|
||||
|
||||
# cwd / env / timeout 都是 exec 的原生参数,不用再拿 shell 拼
|
||||
out = await lease.vm.exec(
|
||||
argv[0], list(argv[1:]),
|
||||
cmd[0], [*cmd[1:], entry],
|
||||
cwd=_WORKDIR,
|
||||
env=({k: str(v) for k, v in env.items()} if env else None),
|
||||
timeout=limits.timeout,
|
||||
@@ -287,6 +297,13 @@ class MicroVMSandbox(Sandbox):
|
||||
finally:
|
||||
if ephemeral:
|
||||
await self._destroy(sid)
|
||||
elif sid in self._leases:
|
||||
# 入口脚本是这次执行的私有临时文件,跑完就收掉,
|
||||
# 免得会话工作区被历年的 __entry_xxx 堆满
|
||||
try:
|
||||
await lease.vm.fs.remove(f"{_WORKDIR}/{entry}")
|
||||
except Exception: # noqa: BLE001 - 删不掉不影响执行结果
|
||||
pass
|
||||
|
||||
stdout, t1 = truncate(str(getattr(out, "stdout_text", "") or ""), limits.max_output)
|
||||
stderr, t2 = truncate(str(getattr(out, "stderr_text", "") or ""), limits.max_output)
|
||||
@@ -304,10 +321,10 @@ class MicroVMSandbox(Sandbox):
|
||||
if not ephemeral:
|
||||
try:
|
||||
entries = await lease.vm.fs.list(_WORKDIR)
|
||||
written = [
|
||||
getattr(e, "name", str(e)) for e in entries
|
||||
if getattr(e, "name", "") != filename
|
||||
][:50]
|
||||
# FsEntry 给的是 path(全路径),不是 name —— 取尾段当文件名。
|
||||
# 早先按 name 取,结果 files_written 里塞的是对象 repr。
|
||||
names = [str(getattr(e, "path", "") or "").rsplit("/", 1)[-1] for e in entries]
|
||||
written = [n for n in names if n and not n.startswith(ENTRY_PREFIX)][:50]
|
||||
except Exception: # noqa: BLE001 - 列目录失败不影响执行结果
|
||||
written = []
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.sandbox.base import ExecResult, Sandbox, SandboxLimits, truncate
|
||||
from app.core.config import sanitize_session, settings
|
||||
from app.sandbox import interpreter
|
||||
from app.sandbox.base import ENTRY_PREFIX, ExecResult, Sandbox, SandboxLimits, entry_name, truncate
|
||||
|
||||
SANDBOX_EXEC = "/usr/bin/sandbox-exec"
|
||||
|
||||
@@ -22,30 +23,18 @@ def _real(path: str | Path) -> str:
|
||||
|
||||
|
||||
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)
|
||||
"""沙箱里用的 Python 解释器路径。挑选逻辑见 app.sandbox.interpreter。"""
|
||||
return interpreter.resolve()[0]
|
||||
|
||||
|
||||
# 入口脚本后缀 + 执行命令前缀(解释器路径要延迟求值,所以是 lambda)。
|
||||
# 文件名运行时生成:同会话并发执行都写 main.py 会互相覆盖。
|
||||
_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"]),
|
||||
"python": (".py", interpreter.python_argv),
|
||||
"bash": (".sh", lambda: ["/bin/bash"]),
|
||||
"sh": (".sh", lambda: ["/bin/sh"]),
|
||||
"node": (".js", lambda: [_real(shutil.which("node") or "node")]),
|
||||
"javascript": (".js", lambda: [_real(shutil.which("node") or "node")]),
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +50,10 @@ def _interpreter_roots(language: str) -> list[str]:
|
||||
"/private/var/select", "/private/var/db",
|
||||
}
|
||||
if language == "python":
|
||||
roots.add(_real(sys.base_prefix))
|
||||
# 放行**实际选中**的那个解释器的安装根,而不是 sys.base_prefix ——
|
||||
# conda 环境下 base_prefix 就是项目环境自己,挑出来的往往是别的解释器
|
||||
exe = Path(interpreter.resolve()[0])
|
||||
roots.add(_real(exe.parent.parent))
|
||||
elif language in ("node", "javascript"):
|
||||
node = shutil.which("node")
|
||||
if node:
|
||||
@@ -152,12 +144,14 @@ class SeatbeltSandbox(Sandbox):
|
||||
# 如实申报:界面按这个把限不住的项划掉,避免给人虚假的安全感
|
||||
"enforced": {"timeout": True, "cpu": True, "memory": False, "network": True, "fsize": True},
|
||||
"root": str(self._root),
|
||||
"interpreter": interpreter.describe(),
|
||||
**({} 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
|
||||
# 净化规则统一在 config.sanitize_session 里,四个后端和文件工具共用一份:
|
||||
# 这段逻辑以前各抄各的,文件工具那份漏抄了,就成了越界读取的口子。
|
||||
path = self._root / sanitize_session(session_id)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
@@ -193,9 +187,10 @@ class SeatbeltSandbox(Sandbox):
|
||||
if not self.available():
|
||||
return ExecResult(ok=False, backend=self.name, error="sandbox-exec 不可用")
|
||||
|
||||
filename, argv_of = _LANG[language]
|
||||
ext, cmd_of = _LANG[language]
|
||||
entry = entry_name(ext)
|
||||
workdir = self._session_dir(session_id or f"tmp-{int(time.time() * 1000)}")
|
||||
(workdir / filename).write_text(code)
|
||||
(workdir / entry).write_text(code)
|
||||
|
||||
for rel, content in (files or {}).items():
|
||||
target = (workdir / rel).resolve()
|
||||
@@ -208,7 +203,7 @@ class SeatbeltSandbox(Sandbox):
|
||||
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()]
|
||||
argv = [SANDBOX_EXEC, "-f", str(policy_file), *cmd_of(), entry]
|
||||
|
||||
# HOME 指向工作区:既挡住家目录,又让解释器的缓存有地方落
|
||||
child_env = {
|
||||
@@ -254,7 +249,8 @@ class SeatbeltSandbox(Sandbox):
|
||||
# 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
|
||||
p.name for p in workdir.iterdir()
|
||||
if p.is_file() and not p.name.startswith(ENTRY_PREFIX)
|
||||
][:50]
|
||||
|
||||
return ExecResult(
|
||||
|
||||
@@ -14,9 +14,11 @@ _MAX_READ = 200_000
|
||||
def _resolve(rel: str, ctx: ToolContext) -> Path:
|
||||
"""把相对路径锁死在这次运行自己的工作目录里。
|
||||
|
||||
先 resolve 再比较,所以 `../../etc/passwd` 和软链接都逃不出去。
|
||||
两道关都要过:session_dir() 先净化 session_id 再定根目录,然后 rel
|
||||
先 resolve 再比对根。少了第一道,`../../etc/passwd` 里的越界检查就是
|
||||
在一个被挪到 /etc 的"根"下面做的,检查照样通过——这正是之前的漏洞。
|
||||
"""
|
||||
root = (settings.workspace_dir / (ctx.sandbox_session or "default")).resolve()
|
||||
root = settings.session_dir(ctx.sandbox_session)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
target = (root / rel.lstrip("/")).resolve()
|
||||
if target != root and root not in target.parents:
|
||||
|
||||
Reference in New Issue
Block a user