Spaces:
Running on Zero
Running on Zero
File size: 15,731 Bytes
531ad36 648c076 531ad36 648c076 e7e449f 531ad36 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 | """Isolated execution of untrusted Python and Rust.
The app compiles and runs code that arbitrary visitors typed into a web page.
Running that in-process would hand every visitor the host's environment - which
on a deployed instance includes the API keys. Everything here exists to keep
those two worlds apart.
Guarantees, strongest first:
1. Untrusted code runs in a *separate process* with an explicitly rebuilt
environment. No API key, token, or secret is inherited - the child gets a
hand-written allowlist of variables (PATH and the few the OS needs to
start a process at all), never a copy of os.environ.
2. Every run is wall-clock bounded and the whole process tree is killed on
timeout, so an infinite loop cannot pin a worker forever.
3. Work happens in a throwaway temp directory that is deleted afterwards, so
nothing can accumulate on disk or read the repository.
4. On Linux (how this deploys) POSIX rlimits additionally cap CPU seconds,
address space, file size, and forked processes.
Item 4 is unavailable on Windows, so local dev is protected by 1-3 only. That
is fine for running your own code; the deployed container is where hostile
input actually arrives, and there the full set applies.
What this is NOT: a security boundary strong enough to run genuinely malicious
code with no further thought. A determined attacker inside the container is
still inside the container. The container is therefore the real boundary - the
process-level work here reduces blast radius, it does not replace isolation at
the platform layer.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
IS_WINDOWS = platform.system() == "Windows"
# Wall-clock ceilings, seconds.
PYTHON_TIMEOUT = int(os.getenv("PYTHON_TIMEOUT", "60"))
COMPILE_TIMEOUT = int(os.getenv("COMPILE_TIMEOUT", "90"))
RUN_TIMEOUT = int(os.getenv("RUN_TIMEOUT", "60"))
# POSIX-only resource ceilings.
MAX_CPU_SECONDS = int(os.getenv("MAX_CPU_SECONDS", "60"))
# rustc with -C lto=fat is memory-hungry; 2 GB was tight enough to risk killing
# legitimate compiles. The free Spaces container has 16 GB, so this is generous
# without being unbounded.
MAX_MEMORY_MB = int(os.getenv("MAX_MEMORY_MB", "4096"))
MAX_OUTPUT_CHARS = 20_000
@dataclass
class Result:
"""Outcome of one sandboxed run."""
ok: bool
stdout: str
stderr: str
seconds: float
stage: str = "run" # "compile" or "run" - which step produced the error
@property
def display(self) -> str:
"""Human-readable text for the UI output box."""
if self.ok:
return self.stdout or "(the program produced no output)"
label = "Compilation failed" if self.stage == "compile" else "Execution failed"
detail = (self.stderr or self.stdout or "").strip() or "no diagnostics available"
return f"{label}\n\n{detail}"
def _safe_env() -> dict[str, str]:
"""Build a minimal environment from scratch.
Deliberately constructed by allowlist rather than by copying os.environ and
deleting keys - a denylist silently fails the moment a new provider adds an
env var, and that failure mode leaks a credential.
"""
allowed: dict[str, str] = {}
# The bare minimum for a process to start and find a toolchain.
passthrough = ["PATH"]
if IS_WINDOWS:
# Windows cannot create a process without these.
passthrough += ["SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", "PATHEXT",
"NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
"USERPROFILE", "WINDIR"]
else:
passthrough += ["LANG", "LC_ALL", "HOME"]
for name in passthrough:
value = os.environ.get(name)
if value:
allowed[name] = value
# Keep the child's Python quiet and deterministic.
allowed["PYTHONUNBUFFERED"] = "1"
allowed["PYTHONDONTWRITEBYTECODE"] = "1"
return allowed
def assert_safe_to_deploy() -> None:
"""Refuse to serve the public if secrets are sitting on disk.
Scrubbing the child's environment stops submitted code from *inheriting* a
key, but it cannot stop it from calling open('.env'). Verified by test: a
sandboxed program read the dotenv file in plaintext.
On a public host that path has to be closed by not having the file at all -
secrets belong in the platform's secret store, injected as environment
variables into the parent only. This turns that requirement into a
startup check instead of a thing to remember.
"""
if os.getenv("PUBLIC_DEPLOYMENT", "0") != "1":
return
here = os.path.dirname(os.path.abspath(__file__))
offenders = [
name for name in (".env", ".env.local", ".env.production")
if os.path.exists(os.path.join(here, name))
]
if offenders:
raise RuntimeError(
"Refusing to start in public mode with secret files on disk: "
f"{', '.join(offenders)}.\n"
"Submitted code runs on this filesystem and can read them. "
"Delete the file and supply secrets as environment variables "
"(on Hugging Face Spaces: Settings -> Variables and secrets)."
)
def _preexec():
"""Apply POSIX resource limits in the child before exec. Linux/macOS only."""
import resource
resource.setrlimit(resource.RLIMIT_CPU, (MAX_CPU_SECONDS, MAX_CPU_SECONDS))
mem = MAX_MEMORY_MB * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (mem, mem))
resource.setrlimit(resource.RLIMIT_FSIZE, (64 * 1024 * 1024, 64 * 1024 * 1024))
# RLIMIT_NPROC is deliberately NOT set.
#
# It counts processes per *user*, not per process tree, so any cap here also
# counts every thread the host container already runs under this uid. In
# practice that starved the compilers of threads:
# rustc: Os { code: 11, kind: WouldBlock, ... }
# g++: cannot execute cc1plus: vfork: Resource temporarily unavailable
# Both are EAGAIN from the fork itself. Because the limit is shared with
# processes we did not start and cannot count, any value is either too low
# to compile or too high to matter - it is not a usable control here.
#
# A fork bomb is still bounded by RLIMIT_CPU, RLIMIT_AS, and the wall-clock
# kill, which terminates the entire process group.
# New process group so a timeout kill takes any children with it.
os.setsid()
def _popen_kwargs() -> dict:
kwargs: dict = {}
if IS_WINDOWS:
# Lets us terminate the whole tree on timeout.
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
kwargs["preexec_fn"] = _preexec
return kwargs
def _truncate(text: str) -> str:
if text is None:
return ""
if len(text) <= MAX_OUTPUT_CHARS:
return text
return text[:MAX_OUTPUT_CHARS] + f"\n\n... output truncated at {MAX_OUTPUT_CHARS:,} characters ..."
def _kill_tree(proc: subprocess.Popen) -> None:
"""Kill a process and everything it spawned."""
try:
if IS_WINDOWS:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=10)
else:
import signal
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
pass
def _run(cmd: list[str], cwd: str, timeout: int, stage: str) -> Result:
"""Run one command under the full set of restrictions."""
started = time.perf_counter()
try:
proc = subprocess.Popen(
cmd,
cwd=cwd,
env=_safe_env(),
stdin=subprocess.DEVNULL, # never block waiting on input
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
**_popen_kwargs(),
)
except FileNotFoundError as e:
return Result(False, "", f"Toolchain not found: {e}", 0.0, stage)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_kill_tree(proc)
elapsed = time.perf_counter() - started
return Result(
False, "",
f"Timed out after {timeout}s and was terminated.\n"
f"The sandbox caps how long submitted code may run.",
elapsed, stage,
)
elapsed = time.perf_counter() - started
return Result(proc.returncode == 0, _truncate(stdout), _truncate(stderr), elapsed, stage)
# --------------------------------------------------------------------------
# public API
# --------------------------------------------------------------------------
def run_python(code: str) -> Result:
"""Execute untrusted Python in a subprocess.
The notebook original used exec() in the web server's own process with full
builtins, which let any visitor read os.environ - and therefore every API
key on the host. This runs it out-of-process against a scrubbed environment
instead.
"""
if not code.strip():
return Result(False, "", "No Python code to run.", 0.0)
with tempfile.TemporaryDirectory(prefix="pyrun_") as tmp:
path = os.path.join(tmp, "program.py")
with open(path, "w", encoding="utf-8") as f:
f.write(code)
# -I: isolated mode. Ignores PYTHON* env vars and keeps the script's
# directory off sys.path, so it cannot import anything from this repo.
return _run([sys.executable, "-I", "-S", path], tmp, PYTHON_TIMEOUT, "run")
@dataclass(frozen=True)
class Language:
"""A compiled target the app can translate Python into."""
key: str # internal id
display: str # shown in the UI
extension: str # source file suffix
highlight: str # gradio Code component language
compilers: tuple[str, ...] # executables to look for, in order of preference
install_hint: str
def find_compiler(self) -> str | None:
for name in self.compilers:
found = shutil.which(name)
if found:
return found
return None
LANGUAGES: dict[str, Language] = {
"rust": Language(
key="rust",
display="Rust",
extension="rs",
highlight="cpp", # gradio has no rust mode; cpp reads acceptably
compilers=("rustc",),
install_hint="https://rustup.rs",
),
"cpp": Language(
key="cpp",
display="C++",
extension="cpp",
highlight="cpp",
compilers=("g++", "clang++", "c++"),
install_hint="https://gcc.gnu.org or https://winlibs.com on Windows",
),
}
DEFAULT_LANGUAGE = "rust"
def build_compile_command(language: Language, compiler: str, source: str,
binary: str) -> list[str]:
"""Optimisation flags per toolchain.
Both sets deliberately trade compile time for runtime - whole-program
optimisation, native CPU targeting, no unwinding machinery where it can be
dropped. Compile time is excluded from the measurement, so this is free.
"""
if language.key == "rust":
return [
compiler, source,
"-C", "opt-level=3",
"-C", "target-cpu=native",
"-C", "codegen-units=1",
"-C", "lto=fat",
"-C", "panic=abort",
"-C", "strip=symbols",
"-o", binary,
]
return [
compiler, source,
"-std=c++17",
"-O3",
"-march=native",
"-funroll-loops",
"-fno-plt",
"-flto",
"-o", binary,
]
def run_compiled(language_key: str, code: str) -> tuple[Result, str]:
"""Compile and execute untrusted code. Returns (result, compile command)."""
language = LANGUAGES.get(language_key)
if language is None:
return Result(False, "", f"Unknown language: {language_key}", 0.0, "compile"), ""
if not code.strip():
return Result(False, "", f"No {language.display} code to compile.",
0.0, "compile"), ""
compiler = language.find_compiler()
if not compiler:
return Result(
False, "",
f"No {language.display} compiler found on PATH "
f"(looked for: {', '.join(language.compilers)}).\n"
f"Install one from {language.install_hint} to run generated code.",
0.0, "compile",
), ""
binary = "program.exe" if IS_WINDOWS else "program"
source_name = f"main.{language.extension}"
with tempfile.TemporaryDirectory(prefix=f"{language.key}run_") as tmp:
with open(os.path.join(tmp, source_name), "w", encoding="utf-8") as f:
f.write(code)
cmd = build_compile_command(language, compiler, source_name, binary)
pretty = " ".join([os.path.basename(cmd[0])] + cmd[1:])
compiled = _run(cmd, tmp, COMPILE_TIMEOUT, "compile")
if not compiled.ok:
return compiled, pretty
# Time execution only - trading compile time for runtime is the entire
# premise of these flags, so charging the result for it measures wrong.
return _run([os.path.join(tmp, binary)], tmp, RUN_TIMEOUT, "run"), pretty
def available_languages() -> list[str]:
"""Language keys with a working compiler on this machine."""
return [key for key, lang in LANGUAGES.items() if lang.find_compiler()]
_probe_cache: dict[str, bool] = {}
def cpp_has_int128() -> bool:
"""Whether this C++ toolchain supports the __int128 extension.
Worth probing rather than assuming. It is a GCC/Clang extension present on
64-bit Linux (so the deployed container has it) but missing from MinGW on
Windows. Models reach for it unprompted when a benchmark needs wide
accumulators, and telling them "avoid non-portable extensions" is too weak
to stop it - stating the capability as a fact is not.
"""
if "int128" in _probe_cache:
return _probe_cache["int128"]
lang = LANGUAGES["cpp"]
compiler = lang.find_compiler()
if not compiler:
_probe_cache["int128"] = False
return False
snippet = "unsigned __int128 x = 1; int main(){ return (int)(x & 1) - 1; }"
with tempfile.TemporaryDirectory(prefix="probe_") as tmp:
path = os.path.join(tmp, "probe.cpp")
with open(path, "w", encoding="utf-8") as f:
f.write(snippet)
out = os.path.join(tmp, "probe.exe" if IS_WINDOWS else "probe")
try:
proc = subprocess.run(
[compiler, "-std=c++17", "-fsyntax-only", path],
capture_output=True, text=True, timeout=25, env=_safe_env(),
)
ok = proc.returncode == 0
except Exception:
ok = False
_probe_cache["int128"] = ok
return ok
def _version_of(path: str) -> str:
try:
out = subprocess.run([path, "--version"], capture_output=True,
text=True, timeout=5)
return (out.stdout or out.stderr).strip().splitlines()[0]
except Exception:
return os.path.basename(path)
def toolchain_status() -> dict[str, str]:
"""Report what the sandbox can actually run, for the UI to display."""
status: dict[str, str] = {}
for key, lang in LANGUAGES.items():
found = lang.find_compiler()
status[key] = _version_of(found) if found else ""
status["python"] = f"Python {platform.python_version()}"
status["isolation"] = (
"full (rlimits + scrubbed env)" if not IS_WINDOWS
else "partial (scrubbed env, no rlimits on Windows)"
)
return status
|