"""Client for the sandbox-runner service (POST /run). Never executes code in-process.""" from __future__ import annotations import re from dataclasses import dataclass, field from typing import Any import httpx from app.core.config import Settings # Defence in depth — the real isolation lives in sandbox-runner. HOSTILE_PATTERNS = [ r"\bsubprocess\b", r"\bsocket\b", r"os\.system", r"os\.popen", r"\bctypes\b", r"__import__\s*\(\s*['\"]os['\"]", r"\bshutil\.rmtree\b", r"\bpty\b", r"\bmultiprocessing\b", r"\burllib\b", r"\brequests\b", r"\bhttpx\b", r"\bsignal\.", r"os\.fork", r"os\.exec", r"\bimportlib\b", r"open\(\s*['\"]/(etc|Users|home|var|proc)", r"\beval\s*\(", r"\bexec\s*\(", ] @dataclass class RunResult: stdout: str = "" stderr: str = "" exit_code: int = 0 duration_ms: int = 0 files_out: list[dict[str, Any]] = field(default_factory=list) truncated: bool = False error: str | None = None def prefilter(code: str) -> str | None: for pat in HOSTILE_PATTERNS: if re.search(pat, code): return f"Code refusé : motif non autorisé « {pat.strip(chr(92) + 'b')} »." return None class SandboxClient: def __init__(self, settings: Settings) -> None: self.s = settings async def run(self, code: str, files_in: list[dict[str, str]] | None = None, timeout_s: int | None = None) -> RunResult: refusal = prefilter(code) if refusal: return RunResult(stderr=refusal, exit_code=126, error=refusal) timeout = timeout_s or self.s.SANDBOX_TIMEOUT_S headers = {} if self.s.SANDBOX_TOKEN.get_secret_value(): headers["Authorization"] = f"Bearer {self.s.SANDBOX_TOKEN.get_secret_value()}" try: async with httpx.AsyncClient(timeout=timeout + 15) as c: r = await c.post(f"{self.s.SANDBOX_URL}/run", headers=headers, json={"code": code, "files_in": files_in or [], "timeout_s": timeout}) except httpx.HTTPError as exc: return RunResult(error=f"Sandbox injoignable : {exc}", exit_code=127) if r.status_code >= 400: return RunResult(error=f"Sandbox HTTP {r.status_code}: {r.text[:300]}", exit_code=127) d = r.json() return RunResult( stdout=d.get("stdout", ""), stderr=d.get("stderr", ""), exit_code=int(d.get("exit_code", 0)), duration_ms=int(d.get("duration_ms", 0)), files_out=d.get("files_out", []), truncated=bool(d.get("truncated", False)), )