Files
AgentLab/backend/app/core/config.py
T
ThearchyHelios a16cdf3ab1 修两条 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 项)。
2026-09-14 15:22:58 +08:00

127 lines
4.5 KiB
Python

from __future__ import annotations
from functools import lru_cache
from pathlib import Path
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_ 前缀的环境变量覆盖。"""
model_config = SettingsConfigDict(
env_prefix="AGENTLAB_",
env_file=(PROJECT_DIR / ".env", BACKEND_DIR / ".env"),
extra="ignore",
)
# --- 服务 ---
host: str = "127.0.0.1"
port: int = 8000
cors_origins: list[str] = ["http://localhost:5273", "http://127.0.0.1:5273"]
# --- 存储 ---
data_dir: Path = PROJECT_DIR / "data"
# --- 密钥加密 ---
# 用于加密落库的 provider api key。留空则自动生成并存到 data_dir/.secret_key。
secret_key: str = ""
# --- 沙箱 ---
sandbox_backend: str = "auto" # auto | microvm | seatbelt | bubblewrap | local | off
sandbox_timeout: int = 30
sandbox_memory_mb: int = 512
sandbox_cpus: float = 1.0
sandbox_network: bool = False
sandbox_max_output: int = 20_000
# --- microVM(硬件级隔离,独立内核)---
# 跑代码用的 OCI 镜像。首次使用会拉取并缓存到 ~/.microsandbox。
microvm_image: str = "python:3.12-slim"
# microVM 是长驻的:同一 session 的多次执行复用同一台,省掉重复冷启动。
# 空闲超过这个秒数就回收,避免闲置 VM 一直占着内存。
microvm_idle_seconds: int = 300
# 单次启动(含首次拉镜像)的等待上限
microvm_boot_timeout: int = 300
# --- 执行引擎护栏 ---
max_run_seconds: int = 600
max_graph_steps: int = 200
max_agent_steps: int = 25
max_concurrent_runs: int = 20
# --- 工具 ---
http_tool_timeout: int = 20
http_tool_max_bytes: int = 2_000_000
# 逗号分隔的域名白名单;为空表示不限制(仅拦截内网地址)
http_tool_allowlist: list[str] = []
@property
def db_path(self) -> Path:
return self.data_dir / "agentlab.db"
@property
def db_url(self) -> str:
return f"sqlite+aiosqlite:///{self.db_path}"
@property
def checkpoint_path(self) -> Path:
"""LangGraph checkpointer 用独立的库文件,避免和业务表抢写锁。"""
return self.data_dir / "checkpoints.db"
@property
def workspace_dir(self) -> Path:
"""沙箱与文件工具的可写根目录,一切文件访问都被限制在这里面。"""
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"
def ensure_dirs(self) -> None:
for d in (self.data_dir, self.workspace_dir, self.uploads_dir):
d.mkdir(parents=True, exist_ok=True)
@lru_cache
def get_settings() -> Settings:
s = Settings()
s.ensure_dirs()
return s
settings = get_settings()