YugJ commited on
Commit
531ad36
·
verified ·
1 Parent(s): 5355966

Upload sandbox.py with huggingface_hub

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