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