修两条 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:
ThearchyHelios
2026-09-14 15:22:58 +08:00
parent 76837d4ef0
commit a16cdf3ab1
9 changed files with 263 additions and 75 deletions
+4 -1
View File
@@ -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")
+30
View File
@@ -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"
+16
View File
@@ -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)
+21 -12
View File
@@ -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:
+106
View File
@@ -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(标准库仍可用,第三方包不可见)"
),
}
+21 -12
View File
@@ -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,
+35 -18
View File
@@ -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 = []
+26 -30
View File
@@ -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(
+4 -2
View File
@@ -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: