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