回应 Fasthei 的 Request changes 两个阻塞项:
1) 安全 / fail-closed 沙箱隔离(原仅靠 ENABLE_QUALITY_EVAL + 运维约定):
- 新增第二道显式确认 HEICODE_SANDBOX_ISOLATED(断言运行在隔离 Pod 内)。
- sandbox.run_tests() 与 quality.evaluate_run_quality() 执行任何代码前调用
assert_isolated(),未确认即抛 SandboxIsolationError——不写文件、不起子进程。
- 启动期 assert_quality_eval_safe():ENABLE_QUALITY_EVAL 开但隔离未确认 → 拒绝启动
(平台级硬失败,非运维口头约定)。
- 文档(security-boundary §8.1/§9、CLAUDE.md)与测试同步:test-sandbox/test-quality
先断言未确认时硬失败,再显式确认后继续。
2) #10 DecisionTrace 可回放(原仅存被选中任务的标量):
- Decision 现记录完整重放上下文:整个候选集(每候选 tau/eta/weight/p_norm/dependents)、
alpha/beta/epsilon、seed、free_slots、total_weight、explore_draw、select_pick、
select_index、explored 分支。
- 新增 DecisionEngine.replay_decision(trace):仅凭一条 trace(无 RNG/活体状态)复现被选任务;
test-decision-engine 断言「重放==实选」跨 50 次决策(探索+利用)成立。
- decision-engine.md §3.3 更新为可回放 DecisionTrace。
附:新增 docs/TESTING.md(reviewer 速查:依赖安装 + 每套测试命令,复审者此前因缺 fakeredis
未能跑到断言)。本地 11 项 gate 全绿。
影响范围:Swarm(orchestrator + 测试 + 文档)。不改 Manager↔Swarm 契约;新增开关
HEICODE_SANDBOX_ISOLATED(默认未设=拒绝执行)。仍非验收:gain/Benchmark_Agent 仍 NaN。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
9.2 KiB
Python
233 lines
9.2 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
|
|
|
|
# FAIL-CLOSED isolation gate. Executing model-generated code is refused UNLESS the operator has
|
|
# explicitly asserted, at the process level, that this runtime is the isolated pod described in
|
|
# the security model above. This is a SECOND, mandatory confirmation independent of any feature
|
|
# flag (e.g. ENABLE_QUALITY_EVAL) — code execution is not a default-on capability that can be
|
|
# turned on by an env toggle alone. Set ONLY inside the isolated pod (its manifest) or in an
|
|
# ephemeral CI/test runner. Absent → run_tests() raises SandboxIsolationError (no code runs).
|
|
SANDBOX_ISOLATION_ENV = "HEICODE_SANDBOX_ISOLATED"
|
|
|
|
|
|
class SandboxIsolationError(RuntimeError):
|
|
"""Raised when code execution is attempted without an explicit isolation confirmation."""
|
|
|
|
|
|
def isolation_confirmed() -> bool:
|
|
return os.getenv(SANDBOX_ISOLATION_ENV, "").lower() in {"1", "true", "yes"}
|
|
|
|
|
|
def assert_isolated() -> None:
|
|
"""Fail-closed guard — call before any code execution. Raises if isolation isn't confirmed."""
|
|
if not isolation_confirmed():
|
|
raise SandboxIsolationError(
|
|
f"refusing to execute generated code: {SANDBOX_ISOLATION_ENV} is not set. "
|
|
"This capability must run ONLY inside the isolated pod (see "
|
|
"docs/integration/security-boundary.md §8.1). Set "
|
|
f"{SANDBOX_ISOLATION_ENV}=1 in that pod's manifest / CI runner to confirm isolation."
|
|
)
|
|
|
|
|
|
@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.
|
|
|
|
FAIL-CLOSED: raises SandboxIsolationError before doing anything if the runtime has not
|
|
explicitly confirmed pod isolation (HEICODE_SANDBOX_ISOLATED). No file is written, no child
|
|
is spawned.
|
|
"""
|
|
assert_isolated()
|
|
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
|