|
|
|
|
|
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() |