SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
5.6 KB · 164 lines python
Raw Blame History
1"""Isolated Python execution with hard limits.23Isolation layers (best available on the host):4  * macOS: `sandbox-exec` seatbelt profile — no network, writes only inside the run dir.5  * Linux (container): expected to run inside a hardened pod (NetworkPolicy deny-all,6    read-only rootfs); we still apply rlimits.7  * Always: `python -I -B`, rlimits (CPU, RAM, files, processes), wall-clock timeout,8    output truncation, auto-save of open matplotlib figures.9"""1011from __future__ import annotations1213import base6414import os15import platform16import resource17import shutil18import subprocess19import sys20import tempfile21import time22import uuid23from pathlib import Path2425MAX_OUTPUT = 50 * 102426MAX_FILES = 1027MAX_FILES_BYTES = 20 * 1024 * 102428MEM_LIMIT = 512 * 1024 * 102429PYTHON = os.environ.get("SANDBOX_PYTHON", sys.executable)3031PRELUDE = '''32import os as _os, sys as _sys33_os.chdir(_os.environ["SANDBOX_WORKDIR"])34_sys.path.insert(0, _os.getcwd())35try:36    import matplotlib as _mpl37    _mpl.use("Agg")38    import matplotlib.pyplot as _plt39    _plt.rcParams["figure.dpi"] = 13040    _plt.rcParams["axes.grid"] = True41    _plt.rcParams["grid.alpha"] = 0.342    _plt.rcParams["axes.spines.top"] = False43    _plt.rcParams["axes.spines.right"] = False44except Exception:45    _plt = None46import atexit as _atexit47def _save_figs():48    if _plt is None:49        return50    for i, num in enumerate(_plt.get_fignums(), 1):51        try:52            _plt.figure(num).savefig(f"outputs/figure_{i}.png", bbox_inches="tight")53        except Exception as e:54            print("figure save failed:", e, file=_sys.stderr)55_atexit.register(_save_figs)56'''575859def _seatbelt_profile(workdir: str) -> str:60    return f"""(version 1)61(deny default)62(allow process-exec)63(allow process-fork)64(allow sysctl-read)65(allow mach-lookup)66(allow file-read*)67(deny file-read* (subpath "/Users") (with no-log))68(allow file-read* (subpath "{workdir}"))69(allow file-read* (subpath "{os.path.dirname(os.path.realpath(PYTHON))}"))70(allow file-read* (subpath "{sys.prefix}"))71(allow file-read* (subpath "{sys.base_prefix}"))72(allow file-read* (subpath "{os.path.expanduser('~')}/.local/share/uv"))73(allow file-read* (subpath "/opt/homebrew"))74(allow file-read* (subpath "/private/tmp"))75(allow file-write* (subpath "{workdir}"))76(allow file-write* (subpath "/private/var/folders"))77(allow file-write* (subpath "/private/tmp"))78(deny network*)79"""808182def _limits() -> None:83    # Runs in the child before exec.84    try:85        resource.setrlimit(resource.RLIMIT_CPU, (25, 30))86        resource.setrlimit(resource.RLIMIT_FSIZE, (MAX_FILES_BYTES, MAX_FILES_BYTES))87        resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256))88        if platform.system() != "Darwin":  # RLIMIT_AS breaks numpy on macOS89            resource.setrlimit(resource.RLIMIT_AS, (MEM_LIMIT, MEM_LIMIT))90        resource.setrlimit(resource.RLIMIT_NPROC, (32, 32))91    except (ValueError, OSError):92        pass939495def run(code: str, files_in: list[dict], timeout_s: int = 30) -> dict:96    run_id = uuid.uuid4().hex97    base = Path(tempfile.gettempdir()) / "uqo-sandbox" / run_id98    (base / "inputs").mkdir(parents=True)99    (base / "outputs").mkdir()100    for f in files_in[:MAX_FILES]:101        name = os.path.basename(str(f.get("name", "input")))102        data = base64.b64decode(f.get("content_b64", "") or "")103        (base / "inputs" / name).write_bytes(data[:MAX_FILES_BYTES])104    main = base / "main.py"105    main.write_text(PRELUDE + "\n" + code, encoding="utf-8")106107    env = {108        "PATH": "/usr/bin:/bin",109        "HOME": str(base),110        "TMPDIR": str(base),111        "SANDBOX_WORKDIR": str(base),112        "MPLCONFIGDIR": str(base / ".mpl"),113        "PYTHONHASHSEED": "0",114        "LANG": "fr_CA.UTF-8",115        "LC_ALL": "fr_CA.UTF-8",116        "OMP_NUM_THREADS": "2",117        "OPENBLAS_NUM_THREADS": "2",118    }119    cmd = [PYTHON, "-I", "-B", str(main)]120    if platform.system() == "Darwin" and shutil.which("sandbox-exec"):121        profile = base / "profile.sb"122        profile.write_text(_seatbelt_profile(str(base)))123        cmd = ["/usr/bin/sandbox-exec", "-f", str(profile)] + cmd124125    t0 = time.perf_counter()126    truncated = False127    try:128        proc = subprocess.run(129            cmd, cwd=base, env=env, capture_output=True, timeout=timeout_s,130            preexec_fn=_limits, check=False,131        )132        stdout, stderr, code_ = proc.stdout, proc.stderr, proc.returncode133    except subprocess.TimeoutExpired as exc:134        stdout = exc.stdout or b""135        stderr = (exc.stderr or b"") + f"\nTemps d'exécution dépassé ({timeout_s} s).".encode()136        code_ = 124137    duration = int((time.perf_counter() - t0) * 1000)138139    def clip(b: bytes) -> str:140        nonlocal truncated141        s = b.decode("utf-8", errors="replace")142        if len(s) > MAX_OUTPUT:143            truncated = True144            return s[:MAX_OUTPUT] + "\n… (tronqué)"145        return s146147    files_out: list[dict] = []148    total = 0149    candidates = sorted(list((base / "outputs").iterdir()) +150                        [p for p in base.iterdir() if p.is_file() and p.name not in151                         {"main.py", "profile.sb"}])152    for p in candidates:153        if not p.is_file() or p.name.startswith("."):154            continue155        size = p.stat().st_size156        if size == 0 or total + size > MAX_FILES_BYTES or len(files_out) >= MAX_FILES:157            continue158        total += size159        files_out.append({"name": p.name, "size": size,160                          "content_b64": base64.b64encode(p.read_bytes()).decode()})161    shutil.rmtree(base, ignore_errors=True)162    return {"stdout": clip(stdout), "stderr": clip(stderr), "exit_code": code_,163            "duration_ms": duration, "files_out": files_out, "truncated": truncated}164