Files
Agentswarm/orchestrator/sandbox.py
T
Songhaoz666andClaude Opus 4.8 d487923646 benchmark: 落地决策层(τ/η/P)、质量(Q_quality)、通信遥测;关闭 #10 #23
四块互相交织的 benchmark 覆盖增量,统一提交:

1) 通信遥测(#23):orchestrator 路由 peer 消息时按 correlation_id 计请求/应答到
   SwarmRun.collaboration(内部状态,不进 Manager 事件流);collector 算 s_communication。
   治理计数由 run.approvals 派生(合规/总数)→ s_governance。

2) Q_quality 掩码归一(v2.1 裁定):metrics.quality_score 改为对 present 输入加权归一,
   非编码任务自动忽略 TestPassRate,全缺 → NaN(不伪造)。

3) 质量插桩 / Group B:新增 Pod 内代码测试沙箱(orchestrator/sandbox.py,环境清洗 +
   超时强杀 + 资源限额 + 路径越界校验,门控 ENABLE_QUALITY_EVAL)与 held-out fixture
   (benchmark/fixtures/);run 完成时用留出测试评分得 TestPassRate → Q_quality →
   collector 合成 reward。安全边界见 docs/integration/security-boundary.md §8.1。

4) 决策引擎 / Group A(#10,Option A score-at-pull):新增 orchestrator/decision_engine.py
   —— 信息素 τ(Redis 持久、(role,agent) 键控、冷启动 0.5、ρ 蒸发、夹紧、学习常开)+
   η 启发式评分 + ε-greedy 概率采样;每次 dispatch 产一条 DecisionTrace →
   SwarmRun.decisions;collector 算 tau/eta/p_decision。概率选择门控 ENABLE_ACO_DISPATCH
   (默认关,CI 用 ACO_SEED 固定)。

覆盖:单次 run 真实可算字段由 4 提升至最多 10/15(新增 communication/reward/tau/eta/
p_decision,外加 governance 有条件)。

测试:新增 test-sandbox / test-quality / test-decision-engine;扩充 collector/metrics 用例;
CI 纳入全部 benchmark 套件 + flag-on 的 ACO e2e。本地 11 项 gate 全绿。

诚实边界(未越界声称):
- Group A 为单边匹配(Option B 待 Group C);概率派发优于贪心未证;默认关闭。
- reward 的 CodeReview/UserAcceptance 未采集(掩码忽略);P_risk 为审批派生低估。
- s_gain/s_swarm/g_e/g_e_cost/benchmark 仍 NaN —— 需基线(#21/#13),本 PR 不动验收。

影响范围:Swarm(orchestrator + benchmark + docs + CI)。不改 Manager↔Swarm 事件契约
(遥测均为运行时内部状态);不影响 Client/计费/密钥/发布链路。新增 ENABLE_QUALITY_EVAL /
ENABLE_ACO_DISPATCH 两个开关,默认关闭。

Closes #10
Closes #23

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:51:32 +08:00

201 lines
7.6 KiB
Python

"""Secure-within-pod code sandbox for testing specialist agents' generated code.
SECURITY MODEL — read before changing anything here:
The OS-level isolation boundary is the **Kubernetes pod / container** this process runs in:
non-root user, read-only root filesystem, NetworkPolicy egress-deny, CPU/memory/pids limits,
and seccomp — all enforced at deploy time (see docs/integration/security-boundary.md and the
Manager/release deployment manifests; NOT editable from this repo). Per Owner ruling, running
generated test code inside that pod is acceptable.
This module adds **in-pod defense in depth** on top of the pod boundary:
- ephemeral per-run workdir (tempfile), always removed in `finally`
- wall-clock timeout with process-group kill
- POSIX resource limits (CPU time, address space, file size, subprocess count) where available
- environment scrub: no API keys / tokens / cloud creds / proxy vars leak into the child
- path-traversal guard on every written file (no abs paths, no `..` escape)
- stdout/stderr size caps; counts are read from a JSON file, never parsed from stdout
It is NOT a standalone security boundary. It MUST run only inside the isolated pod and is gated
by ENABLE_QUALITY_EVAL upstream (orchestrator/quality.py). Never enable code execution outside
that pod.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
try: # POSIX only; absent on Windows dev boxes (prod is a Linux pod)
import resource # type: ignore
except Exception: # pragma: no cover - platform dependent
resource = None # type: ignore
_RUNNER = Path(__file__).resolve().parent / "sandbox_runner.py"
# Env vars always allowed through to the child (everything else is dropped).
_ENV_ALLOWLIST = {"PATH", "SYSTEMROOT", "SystemRoot", "WINDIR", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "COMSPEC", "PATHEXT"}
DEFAULT_TIMEOUT_SECONDS = float(os.getenv("SANDBOX_TIMEOUT_SECONDS", "30"))
DEFAULT_CPU_SECONDS = int(os.getenv("SANDBOX_CPU_SECONDS", "20"))
DEFAULT_MEM_BYTES = int(os.getenv("SANDBOX_MEM_BYTES", str(512 * 1024 * 1024)))
DEFAULT_FSIZE_BYTES = int(os.getenv("SANDBOX_FSIZE_BYTES", str(32 * 1024 * 1024)))
DEFAULT_NPROC = int(os.getenv("SANDBOX_NPROC", "64"))
_OUTPUT_CAP = 16 * 1024
@dataclass
class SandboxFile:
path: str
content: str
@dataclass
class SandboxResult:
total: int = 0
passed: int = 0
failed: int = 0
errored: int = 0
timed_out: bool = False
exit_code: Optional[int] = None
stdout: str = ""
stderr: str = ""
error: Optional[str] = None
details: List[dict] = field(default_factory=list)
@property
def pass_rate(self) -> Optional[float]:
"""passed / total * 100, or None when nothing ran (caller decides NaN/coverage)."""
if self.total <= 0:
return None
return 100.0 * self.passed / self.total
def _safe_join(root: Path, rel: str) -> Path:
# Reject absolute paths and any `..` escape; resolve strictly under root.
candidate = (root / rel).resolve()
if not str(candidate).startswith(str(root.resolve())):
raise ValueError(f"path escapes sandbox: {rel!r}")
return candidate
def _write_files(root: Path, files: List[SandboxFile]) -> None:
for f in files:
if not f.path or os.path.isabs(f.path):
raise ValueError(f"unsafe file path: {f.path!r}")
dest = _safe_join(root, f.path)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(f.content, encoding="utf-8")
def _child_env(workdir: Path) -> dict:
env = {k: v for k, v in os.environ.items() if k in _ENV_ALLOWLIST}
# Confine writes and bytecode; isolate HOME/TMP to the ephemeral dir.
env["HOME"] = str(workdir)
env["TMPDIR"] = str(workdir)
env["TEMP"] = str(workdir)
env["TMP"] = str(workdir)
env["PYTHONDONTWRITEBYTECODE"] = "1"
env["PYTHONNOUSERSITE"] = "1"
env["PYTHONPATH"] = ""
return env
def _preexec(): # pragma: no cover - POSIX only, runs in the child before exec
if resource is not None:
resource.setrlimit(resource.RLIMIT_CPU, (DEFAULT_CPU_SECONDS, DEFAULT_CPU_SECONDS))
try:
resource.setrlimit(resource.RLIMIT_AS, (DEFAULT_MEM_BYTES, DEFAULT_MEM_BYTES))
except (ValueError, OSError):
pass
resource.setrlimit(resource.RLIMIT_FSIZE, (DEFAULT_FSIZE_BYTES, DEFAULT_FSIZE_BYTES))
try:
resource.setrlimit(resource.RLIMIT_NPROC, (DEFAULT_NPROC, DEFAULT_NPROC))
except (ValueError, OSError):
pass
os.setsid() # own process group, so a timeout can kill the whole tree
def run_tests(
source_files: List[SandboxFile],
test_files: List[SandboxFile],
*,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> SandboxResult:
"""Write source + test files into an ephemeral workdir and run the tests in a child process.
Returns a SandboxResult with pass/fail counts (read from the runner's JSON output). The
workdir is always removed. Blocking/CPU-bound — callers should offload via asyncio.to_thread.
"""
workdir = Path(tempfile.mkdtemp(prefix="swarm-sbx-"))
is_posix = os.name == "posix"
try:
_write_files(workdir, list(source_files) + list(test_files))
shutil.copyfile(_RUNNER, workdir / "_runner.py")
popen_kwargs = dict(
cwd=str(workdir),
env=_child_env(workdir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if is_posix:
popen_kwargs["preexec_fn"] = _preexec
else: # Windows dev: new process group so we can signal the tree
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
proc = subprocess.Popen([sys.executable, "-I", "_runner.py"], **popen_kwargs)
result = SandboxResult()
try:
out, err = proc.communicate(timeout=timeout_seconds)
result.exit_code = proc.returncode
result.stdout = (out or "")[:_OUTPUT_CAP]
result.stderr = (err or "")[:_OUTPUT_CAP]
except subprocess.TimeoutExpired:
result.timed_out = True
_kill(proc, is_posix)
out, err = proc.communicate()
result.stdout = (out or "")[:_OUTPUT_CAP]
result.stderr = (err or "")[:_OUTPUT_CAP]
result.error = f"timeout after {timeout_seconds}s"
return result
report = workdir / "_result.json"
if report.exists():
try:
data = json.loads(report.read_text(encoding="utf-8"))
result.total = int(data.get("total", 0))
result.passed = int(data.get("passed", 0))
result.failed = int(data.get("failed", 0))
result.errored = int(data.get("errored", 0))
result.details = data.get("details", []) or []
if data.get("fatal"):
result.error = str(data["fatal"])
except Exception as exc:
result.error = f"unparseable result: {exc!r}"
else:
result.error = "no result produced by sandbox runner"
return result
finally:
shutil.rmtree(workdir, ignore_errors=True)
def _kill(proc: "subprocess.Popen", is_posix: bool) -> None: # pragma: no cover - timing dependent
try:
if is_posix:
import signal
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
else:
proc.kill()
except Exception:
try:
proc.kill()
except Exception:
pass