"""Evidence-producing import and wall-clock runtime validation in an OS sandbox.""" from __future__ import annotations import hashlib import json import math import os from pathlib import Path import platform import re import shutil import signal import subprocess import time from .project import ProjectError, project_sha256, write_project ERROR_PATTERN = re.compile(r"(?im)(?:\b(?:SCRIPT ERROR|Parse Error|Parser Error|Compile Error|ERROR):|Failed to load script|Failed loading resource|Failed to instantiate|Couldn't load|Segmentation fault|CRASH HANDLER)") def sandbox_command(command: list[str], project: Path) -> tuple[list[str], str]: """Do not read the user's home/Drive or permit network access in the child.""" project = project.resolve() binary = Path(command[0]).resolve() home = project / ".sandbox-home" home.mkdir(exist_ok=True) (home / "tmp").mkdir(exist_ok=True) if platform.system() == "Darwin": raise RuntimeError("macOS validation is disabled: restrictive sandbox crashes the Godot dynamic loader on tested macOS 26.3.1. Use Linux bubblewrap; no local Godot process was launched.") if platform.system() == "Linux": bwrap = shutil.which("bwrap") if not bwrap: raise RuntimeError("OS sandbox unavailable: install bubblewrap; evaluation fails closed") wrapped = [bwrap, "--unshare-all", "--die-with-parent", "--new-session", "--cap-drop", "ALL", "--tmpfs", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"] for item in ("/usr", "/lib", "/lib64", "/bin", "/sbin"): if Path(item).exists(): wrapped += ["--ro-bind", item, item] for item in ("/etc/ld.so.cache", "/etc/fonts"): if Path(item).exists(): wrapped += ["--ro-bind", item, item] wrapped += ["--ro-bind", str(binary.parent), str(binary.parent), "--bind", str(project), str(project), "--chdir", str(project), "--", *command] return wrapped, "Linux bubblewrap: unshared namespaces, no network, restricted mounts" raise RuntimeError("Unsupported OS sandbox; no untrusted code was executed") def with_resource_limits(command: list[str], *, file_bytes: int = 32 * 1024 * 1024) -> list[str]: """External prlimit is safe when validation is called from Python threads.""" limiter = shutil.which("prlimit") if platform.system() != "Linux" or not limiter: raise RuntimeError("Linux prlimit is required for bounded generated-code execution") return [limiter, f"--fsize={file_bytes}:{file_bytes}", "--nofile=256:256", "--core=0:0", "--as=4294967296:4294967296", "--", *command] def run_sandboxed(command: list[str], project: Path, log_path: Path, seconds: float, *, expected_running: bool = False) -> dict: wrapped, sandbox = sandbox_command(command, project) home = project / ".sandbox-home" env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "HOME": str(home), "TMPDIR": str(home / "tmp"), "XDG_CONFIG_HOME": str(home / "config"), "XDG_DATA_HOME": str(home / "data"), "XDG_CACHE_HOME": str(home / "cache"), "LANG": "C.UTF-8", "GODOT_SILENCE_ROOT_WARNING": "1"} start = time.monotonic() terminated_by_harness = False with log_path.open("wb") as log: process = subprocess.Popen(with_resource_limits(wrapped), cwd=project, stdout=log, stderr=subprocess.STDOUT, env=env, start_new_session=True) startup_seconds = None deadline = start + seconds while process.poll() is None and time.monotonic() < deadline: if startup_seconds is None: with log_path.open("rb") as probe: if b"Godot Engine v4." in probe.read(4096): startup_seconds = time.monotonic() - start if expected_running: deadline = start + startup_seconds + seconds time.sleep(min(0.1, max(0, deadline - time.monotonic()))) if process.poll() is None: terminated_by_harness = True os.killpg(process.pid, signal.SIGTERM) try: returncode = process.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) returncode = process.wait(timeout=5) else: returncode = process.returncode elapsed = time.monotonic() - start text = log_path.read_text(errors="replace") errors = [line for line in text.splitlines() if ERROR_PATTERN.search(line)] sandbox_failed = bool(re.search(r"(?i)sandbox-exec:|bwrap:|operation not permitted|deny\(.*\)", text)) runtime_started = "Godot Engine v4." in text and not sandbox_failed return {"command": command, "sandbox": sandbox, "returncode": returncode, "wall_seconds": round(elapsed, 4), "required_seconds": seconds, "terminated_by_harness": terminated_by_harness, "sandbox_failed": sandbox_failed, "runtime_started": runtime_started, "startup_seconds": startup_seconds, "errors": errors[:100], "log": str(log_path), "log_sha256": hashlib.sha256(log_path.read_bytes()).hexdigest(), "success": bool(runtime_started and not errors and ((terminated_by_harness and elapsed >= seconds) if expected_running else (not terminated_by_harness and returncode == 0)))} def validate_project(files: dict, work_dir: Path, godot_binary: str, run_seconds: float = 60) -> dict: """Import, then require a complete wall-clock window; preserve all diagnostics. run_seconds < 60 is allowed only for smoke tests and never qualifies as the published clean_run_60s metric. Existing evidence directories are not reused. """ if not math.isfinite(run_seconds) or run_seconds <= 0: raise ValueError("run_seconds must be positive and finite") validation_start = time.monotonic() work_dir = Path(work_dir).absolute() for ancestor in (work_dir, *work_dir.parents): if ancestor.is_symlink(): raise ProjectError(f"Symlink evidence directory rejected: {ancestor}") work_dir.mkdir(parents=True, exist_ok=True) evidence = work_dir / "validation.json" if evidence.exists(): raise FileExistsError(f"Refusing to overwrite evidence: {evidence}") result = {"compile_success": False, "clean_run": False, "clean_run_60s": False, "run_seconds": run_seconds, "project_sha256": None, "error": None} try: project = write_project(files, work_dir / "project") result["project_sha256"] = project_sha256(files) binary = str(Path(godot_binary).expanduser().resolve(strict=True)) result["godot_binary_sha256"] = hashlib.sha256(Path(binary).read_bytes()).hexdigest() result["preimport_seconds"] = time.monotonic() - validation_start imported = run_sandboxed([binary, "--headless", "--path", str(project), "--editor", "--import"], project, work_dir / "import.log", 90) result["import"] = imported result["compile_success"] = imported["success"] if imported["success"]: runtime = run_sandboxed([binary, "--headless", "--path", str(project), "--audio-driver", "Dummy", "--max-fps", "60"], project, work_dir / "runtime.log", run_seconds, expected_running=True) result["runtime"] = runtime result["clean_run"] = runtime["success"] result["clean_run_60s"] = bool(runtime["success"] and run_seconds >= 60) except (ProjectError, OSError, RuntimeError, subprocess.SubprocessError) as exc: result["error"] = f"{type(exc).__name__}: {exc}" with evidence.open("x", encoding="utf-8") as handle: json.dump(result, handle, indent=2) handle.write("\n") return result