四块互相交织的 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>
201 lines
7.6 KiB
Python
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
|