| |
| from __future__ import annotations |
| import io, os, sys, uuid, base64, traceback, contextlib, tempfile, shutil |
| import multiprocessing as mp |
| from typing import Optional, Dict, Any, List |
| from pydantic import ValidationError |
| from langchain_core.tools import tool |
| from src.utils.code_run import ( |
| CodeRunRequest, CodeRunResult, EnvInfo, |
| PlotArtifact, DataFrameArtifact, |
| ) |
|
|
| |
|
|
| def _b64_png(fig, dpi: int) -> str: |
| import matplotlib.pyplot as plt |
| buf = io.BytesIO() |
| fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") |
| buf.seek(0) |
| data = base64.b64encode(buf.read()).decode("utf-8") |
| buf.close() |
| return data |
|
|
| def _clip_df(df, max_rows: int, max_cols: int): |
| sub = df.iloc[:max_rows, :max_cols] |
| head = sub.to_dict(orient="records") |
| dtypes = {str(k): str(v) for k, v in sub.dtypes.to_dict().items()} |
| return head, list(df.shape), dtypes |
|
|
| def _env_info() -> EnvInfo: |
| try: |
| import numpy as _np; nv = _np.__version__ |
| except Exception: |
| nv = None |
| try: |
| import pandas as _pd; pv = _pd.__version__ |
| except Exception: |
| pv = None |
| return EnvInfo(numpy=nv, pandas=pv) |
|
|
| |
|
|
| def _child_exec(payload: Dict[str, Any], queue: mp.Queue): |
| """ |
| Изолированное выполнение user-кода: |
| - урезанные builtins |
| - безопасный open (read-only в sandbox) |
| - белый список импортов |
| - запрет сети |
| - temp cwd + очистка |
| - RLIMIT CPU/AS (Unix) |
| - захват stdout/stderr |
| - сбор matplotlib и pandas.DataFrame (по флагам) |
| """ |
| import builtins, importlib |
|
|
| code: str = payload["code"] |
| limits: Dict[str, Any] = payload["limits"] |
| allowed: List[str] = payload["allowed"] |
| return_plots: bool = payload["return_plots"] |
| return_dfs: bool = payload["return_dfs"] |
|
|
| |
| try: |
| import resource |
| cpu = max(1, int(limits["timeout_seconds"])) |
| resource.setrlimit(resource.RLIMIT_CPU, (cpu, cpu + 1)) |
| |
| one_gb = 1024 * 1024 * 1024 |
| resource.setrlimit(resource.RLIMIT_AS, (int(1.5 * one_gb), int(1.5 * one_gb))) |
| |
| resource.setrlimit(resource.RLIMIT_FSIZE, (50 * 1024 * 1024, 50 * 1024 * 1024)) |
| except Exception: |
| pass |
|
|
| |
| workdir = tempfile.mkdtemp(prefix="ci_") |
| os.chdir(workdir) |
|
|
| |
| try: |
| import socket |
| class _NoNet(socket.socket): |
| def __init__(self, *a, **kw): |
| raise OSError("Network disabled in sandbox") |
| socket.socket = _NoNet |
| except Exception: |
| pass |
|
|
| |
| safe_names = [ |
| "abs","all","any","bool","dict","float","int","len","list","max","min", |
| "range","str","sum","print","enumerate","zip","map","filter","sorted", |
| "reversed","complex","pow","divmod", "round", "next", "set", "tuple", "type", "isinstance", "issubclass", |
| ] |
| safe_builtins = {n: getattr(builtins, n) for n in safe_names} |
|
|
| |
| real_open = open |
|
|
| def _safe_open(path, mode="r", *a, **kw): |
| |
| if any(m in mode for m in ("w", "a", "+", "x")): |
| raise PermissionError("Write access forbidden in sandbox") |
| abspath = os.path.abspath(path) |
| |
| if not abspath.startswith(workdir + os.sep) and abspath != workdir: |
| raise PermissionError("Access outside sandbox forbidden") |
| |
| return real_open(abspath, mode, *a, **kw) |
|
|
| |
| for banned in ["exec","eval","__import__","compile","input","globals","locals","vars","dir","help","__build_class__"]: |
| safe_builtins.pop(banned, None) |
| safe_builtins["open"] = _safe_open |
|
|
| |
| real_import = builtins.__import__ |
| ALLOWED = set(allowed) |
| def _safe_import(name, globals=None, locals=None, fromlist=(), level=0): |
| base = name.split(".")[0] |
| if (name not in ALLOWED) and (base not in ALLOWED): |
| raise ImportError(f"Module '{name}' is not allowed") |
| return real_import(name, globals, locals, fromlist, level) |
|
|
| glb: Dict[str, Any] = {"__builtins__": safe_builtins} |
| lcl: Dict[str, Any] = {} |
|
|
| |
| plt = None |
| if return_plots: |
| try: |
| import matplotlib |
| matplotlib.use("Agg") |
| import matplotlib.pyplot as _plt |
| plt = _plt |
| except Exception: |
| plt = None |
|
|
| |
| preloads = [ |
| "math","random","statistics","datetime","re","json","fractions","decimal", |
| "numpy","pandas","cmath", |
| "matplotlib","matplotlib.pyplot" |
| ] |
| for mod in preloads: |
| try: |
| if (mod in ALLOWED) or (mod.split(".")[0] in ALLOWED): |
| glb[mod.split(".")[-1]] = importlib.import_module(mod) |
| except Exception: |
| pass |
|
|
| |
| safe_builtins["__import__"] = _safe_import |
|
|
| |
| out_buf, err_buf = io.StringIO(), io.StringIO() |
| status = "error" |
| result_repr: Optional[str] = None |
| plots: List[Dict[str, Any]] = [] |
| dataframes: List[Dict[str, Any]] = [] |
|
|
| try: |
| with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf): |
| exec(code, glb, lcl) |
| status = "success" |
|
|
| |
| if "_" in lcl: |
| result_repr = repr(lcl["_"]) |
| elif "result" in lcl: |
| result_repr = repr(lcl["result"]) |
|
|
| |
| if plt is not None and return_plots: |
| fig_nums = plt.get_fignums()[: int(limits["max_plots"])] |
| for num in fig_nums: |
| fig = plt.figure(num) |
| b64 = _b64_png(fig, dpi=int(limits["plot_dpi"])) |
| plots.append({"data_base64": b64, "format": "png"}) |
| plt.close("all") |
|
|
| |
| if return_dfs: |
| try: |
| import pandas as _pd |
| for name, val in list(lcl.items()): |
| if isinstance(val, _pd.DataFrame): |
| if len(dataframes) >= int(limits["max_dataframes"]): |
| break |
| head, shape, dtypes = _clip_df( |
| val, |
| max_rows=int(limits["max_df_rows"]), |
| max_cols=int(limits["max_df_cols"]), |
| ) |
| dataframes.append({ |
| "name": str(name), |
| "head": head, |
| "shape": shape, |
| "dtypes": dtypes, |
| }) |
| except Exception: |
| pass |
|
|
| except Exception: |
| status = "error" |
| print(traceback.format_exc(), file=err_buf) |
| finally: |
| try: |
| shutil.rmtree(workdir, ignore_errors=True) |
| except Exception: |
| pass |
|
|
| queue.put({ |
| "status": status, |
| "stdout": out_buf.getvalue(), |
| "stderr": err_buf.getvalue(), |
| "result_repr": result_repr, |
| "plots": plots, |
| "dataframes": dataframes, |
| }) |
|
|
| |
|
|
| def run_python_in_subprocess(req: CodeRunRequest) -> CodeRunResult: |
| exec_id = str(uuid.uuid4()) |
| ctx = mp.get_context("spawn") |
| q: mp.Queue = ctx.Queue() |
|
|
| payload = { |
| "code": req.code, |
| "limits": req.limits.model_dump(), |
| "allowed": list(req.allowed_modules), |
| "return_plots": bool(req.return_plots), |
| "return_dfs": bool(req.return_dataframes), |
| } |
|
|
| p = ctx.Process(target=_child_exec, args=(payload, q), daemon=True) |
| p.start() |
| p.join(req.limits.timeout_seconds) |
|
|
| status = "timeout" |
| stdout = "" |
| stderr = "Timed out." |
| result_repr = None |
| plots: List[PlotArtifact] = [] |
| dataframes: List[DataFrameArtifact] = [] |
|
|
| if p.is_alive(): |
| p.terminate() |
| p.join(1) |
| else: |
| try: |
| msg = q.get_nowait() |
| status = msg.get("status", "error") |
| stdout = (msg.get("stdout") or "")[: req.limits.max_stdout_chars] |
| stderr = (msg.get("stderr") or "")[: req.limits.max_stderr_chars] |
| result_repr = msg.get("result_repr") |
| plots = [PlotArtifact(**p_) for p_ in msg.get("plots", [])] |
| dataframes = [DataFrameArtifact(**d_) for d_ in msg.get("dataframes", [])] |
| except Exception as e: |
| status = "error" |
| stderr = f"Worker crashed: {e}" |
|
|
| return CodeRunResult( |
| execution_id=exec_id, |
| status=status, |
| stdout=stdout, |
| stderr=stderr, |
| result_repr=result_repr, |
| plots=plots, |
| dataframes=dataframes, |
| env=_env_info(), |
| ) |
|
|
| |
|
|
| @tool |
| def safe_code_run(code:str) -> str: |
| """ |
| Safely execute Python code in an isolated subprocess with security restrictions. |
| |
| IMPORTANT - To see output, you MUST: |
| - Use print() statements for output |
| - Assign final result to variable 'result' or '_' |
| - Save data to variables for DataFrame/plot capture |
| |
| Examples: |
| ✅ Good: |
| result = 2 + 2 |
| print(f"Answer: {result}") |
| |
| ✅ Good: |
| import numpy as np |
| arr = np.array([1, 2, 3]) |
| print(arr.mean()) |
| |
| ✅ Good: |
| import pandas as pd |
| df = pd.DataFrame({'x': [1, 2], 'y': [3, 4]}) |
| print(df) |
| result = df.sum() |
| |
| ❌ Bad (no output): |
| 2 + 2 # This won't show anything |
| |
| Security features: |
| - Whitelisted imports only (numpy, pandas, matplotlib, etc.) |
| - Read-only file access within sandbox |
| - Network disabled |
| - Memory/CPU limits |
| - Timeout protection |
| |
| Returns JSON with: status, stdout, stderr, result_repr, plots, dataframes, env info |
| """ |
|
|
| |
| req = CodeRunRequest( |
| code=code, |
| |
| limits=dict(timeout_seconds=35) |
| ).model_dump_json() |
|
|
| res = run_python_in_subprocess(CodeRunRequest.model_validate_json(req)) |
| return res.model_dump_json() |