Spaces:
Running on Zero
Running on Zero
File size: 6,492 Bytes
4c464e3 | 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 | """Sandboxed execution + validation of generated plotting code."""
import multiprocessing as mp
import re
import matplotlib
matplotlib.use("Agg") # Headless mode
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
try:
import seaborn as sns
except ImportError:
sns = None
from schemas import GENERATORS
# ---------------------------------------------------------------------------
# Execution guard
# ---------------------------------------------------------------------------
# The code we exec() here is machine-generated (Gemini teacher, and later the
# fine-tuned student). It is NOT trusted. Running it with the full builtins and
# unrestricted imports would let a hallucinated snippet run `import os;
# os.system(...)`, delete files, or open network sockets on this machine.
#
# This is a *proportionate* guard against accidental / hallucinated harm, not a
# hardened sandbox: it removes the obvious escape hatches (open/eval/exec/compile
# and imports of os/subprocess/socket/etc.) while still allowing normal plotting
# code. For genuinely adversarial code, run this inside a container or VM.
# Top-level modules a plotting snippet is allowed to import.
_ALLOWED_IMPORTS = {
"matplotlib", "mpl_toolkits", "pandas", "numpy", "seaborn",
"scipy", "math", "statistics", "datetime", "collections",
"itertools", "functools", "warnings", "random", "cycler",
"numbers", "decimal", "fractions",
}
# Builtins safe to expose. Deliberately omits open, eval, exec, compile, input,
# __import__ (replaced below), globals, locals, vars, exit, quit, help,
# breakpoint, memoryview.
_SAFE_BUILTIN_NAMES = (
"abs", "all", "any", "bool", "bytes", "callable", "chr", "classmethod",
"complex", "dict", "divmod", "enumerate", "filter", "float", "format",
"frozenset", "getattr", "hasattr", "hash", "hex", "int", "isinstance",
"issubclass", "iter", "len", "list", "map", "max", "min", "next", "object",
"oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
"round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum",
"super", "tuple", "type", "zip", "True", "False", "None",
"__build_class__", # needed for class definitions in generated code
)
def _guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
"""Allow imports only for the whitelisted data/plotting modules."""
root = name.split(".")[0]
if root not in _ALLOWED_IMPORTS:
raise ImportError(f"import of '{name}' is blocked in the execution sandbox")
return __import__(name, globals, locals, fromlist, level)
def _safe_builtins() -> dict:
import builtins as _b
safe = {n: getattr(_b, n) for n in _SAFE_BUILTIN_NAMES if hasattr(_b, n)}
safe["__import__"] = _guarded_import
return safe
GENERATOR_MAP = (
{g.__name__: g if callable(g) else g for g in GENERATORS}
if isinstance(GENERATORS, (list, tuple, set))
else GENERATORS
)
def clean_code_string(code: str) -> str:
"""Strip markdown code blocks from generated code safely."""
if not code:
return ""
code = code.strip()
bt3 = chr(96) * 3 # Dynamically generates triple backticks to avoid UI glitches
# Extract content inside markdown python code block if present
pattern = bt3 + r"(?:python)?\s*\n?(.*?)\n?" + bt3
match = re.search(pattern, code, flags=re.DOTALL | re.IGNORECASE)
if match:
cleaned = match.group(1).strip()
else:
# Fallback: strip leading/trailing backtick lines
lines = code.splitlines()
if lines and lines[0].strip().startswith(bt3):
lines = lines[1:]
if lines and lines[-1].strip().startswith(bt3):
lines = lines[:-1]
cleaned = "\n".join(lines).strip()
return cleaned
def _run(gen_name: str, code: str, q: mp.Queue, seed: int = 0) -> None:
# Generated code triggers a lot of library deprecation chatter (seaborn
# palette/hue, pandas futures). It is not our code and we cannot fix it, so
# keep it out of the validation log -- real failures come back via the queue.
import warnings
warnings.filterwarnings("ignore")
try:
if callable(gen_name):
gen_func = gen_name
elif gen_name in GENERATOR_MAP:
gen_func = GENERATOR_MAP[gen_name]
else:
q.put(("error", f"Generator '{gen_name}' not found."))
return
clean_code = clean_code_string(code)
if not clean_code:
q.put(("error", "Empty code block after stripping formatting."))
return
# Same (generator, seed) the teacher saw -> the code is validated against
# the exact DataFrame whose preview is stored on the record.
df = gen_func(seed=seed)
ns = {
"df": df,
"plt": plt,
"pd": pd,
"np": np,
"matplotlib": matplotlib,
"__builtins__": _safe_builtins(), # restricted: no os/open/eval/etc.
}
if sns is not None:
ns["sns"] = sns
plt.close("all")
exec(clean_code, ns)
fignums = plt.get_fignums()
if not fignums:
q.put(("no_figure", "No matplotlib figure was created."))
return
fig = plt.gcf()
has_axes = len(fig.axes) > 0 and any(len(ax.get_children()) > 0 for ax in fig.axes)
plt.close("all")
if has_axes:
q.put(("ok", None))
else:
q.put(("no_figure", "Figure created but contains no axes or visual elements."))
except Exception as e:
plt.close("all")
q.put(("error", f"{type(e).__name__}: {str(e)}"))
def validate(gen_name: str, code: str, timeout: int = 10, seed: int = 0) -> tuple[bool, str | None]:
if not code:
return False, "empty code"
q = mp.Queue()
p = mp.Process(target=_run, args=(gen_name, code, q, seed))
p.start()
p.join(timeout)
if p.is_alive():
p.terminate()
p.join(timeout=2)
if p.is_alive():
p.kill()
p.join()
return False, f"Timeout after {timeout} seconds"
if q.empty():
return False, "Process terminated unexpectedly with no output"
status, detail = q.get()
return status == "ok", detail |