""" CodeAct sandbox for the Fraud Hunter environment. Allows agents to submit Python code that is executed in a restricted, time-limited sandbox with read-only access to the case SQLite database. Security boundaries: - Restricted builtins (no open/exec/import/eval/os/sys/subprocess) - 5-second timeout enforced via threading - No network I/O, no filesystem writes - Pre-injected: conn (read-only sqlite3), pd (pandas), json - Output capped at 4096 characters This implements the CodeAct paradigm where the agent can write arbitrary SQL-backed Python to extract, join and assert data in a single step, earning reward for successful database access and filesystem evidence reads. """ from __future__ import annotations import ast import io import json import os import sqlite3 import sys import threading import time import traceback from contextlib import redirect_stdout from typing import Callable, Optional try: import pandas as pd _HAS_PANDAS = True except ImportError: _HAS_PANDAS = False _SAFE_BUILTINS = { "abs": abs, "all": all, "any": any, "bool": bool, "dict": dict, "dir": dir, "enumerate": enumerate, "filter": filter, "float": float, "format": format, "frozenset": frozenset, "hasattr": hasattr, "hash": hash, "int": int, "isinstance": isinstance, "issubclass": issubclass, "iter": iter, "len": len, "list": list, "map": map, "max": max, "min": min, "next": next, "print": print, "range": range, "repr": repr, "reversed": reversed, "round": round, "set": set, "slice": slice, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple, "type": type, "zip": zip, "True": True, "False": False, "None": None, } # AST-based safety: reject these node-name attribute targets and these names. _FORBIDDEN_MODULES = frozenset({ "os", "sys", "subprocess", "socket", "shutil", "pathlib", "ctypes", "importlib", "builtins", "__builtins__", "__import__", "multiprocessing", "threading", }) _FORBIDDEN_NAMES = frozenset({ "exec", "eval", "compile", "__import__", "globals", "locals", "vars", "getattr", "setattr", "delattr", "breakpoint", "__builtins__", "__class__", "__bases__", "__subclasses__", "__mro__", "__globals__", "__getattribute__", }) _FORBIDDEN_DUNDERS = frozenset({ "__class__", "__bases__", "__subclasses__", "__mro__", "__globals__", "__getattribute__", "__reduce__", "__reduce_ex__", "__init_subclass__", "__import__", }) # Belt-and-suspenders substring blocklist; runs after the AST check. _FORBIDDEN_SUBSTRINGS = ( "__import__", "__builtins__", "__class__", "__subclasses__", "__bases__", "__mro__", "__globals__", ) _MAX_OUTPUT_CHARS = 4096 _TIMEOUT_SECONDS = 5 _FORBIDDEN_SQL = ("DROP ", "DELETE ", "INSERT ", "UPDATE ", "ATTACH ", "PRAGMA ") class _SafetyVisitor(ast.NodeVisitor): """Walks the AST and records the first safety violation, if any.""" def __init__(self) -> None: self.violation: Optional[str] = None def _flag(self, msg: str) -> None: if self.violation is None: self.violation = msg def visit_Import(self, node: ast.Import) -> None: names = ", ".join(a.name for a in node.names) self._flag(f"import statement disallowed: {names}") def visit_ImportFrom(self, node: ast.ImportFrom) -> None: self._flag(f"from-import disallowed: from {node.module or '?'}") def visit_Name(self, node: ast.Name) -> None: if node.id in _FORBIDDEN_NAMES: self._flag(f"forbidden name: {node.id}") self.generic_visit(node) def visit_Attribute(self, node: ast.Attribute) -> None: # Reject any access on a forbidden module name (e.g. os.system). target = node while isinstance(target, ast.Attribute): target = target.value if isinstance(target, ast.Name) and target.id in _FORBIDDEN_MODULES: self._flag(f"forbidden attribute access on: {target.id}") if node.attr in _FORBIDDEN_DUNDERS: self._flag(f"forbidden dunder attribute: {node.attr}") self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: # Reject getattr/setattr/delattr — they smuggle attribute access. if isinstance(node.func, ast.Name) and node.func.id in { "getattr", "setattr", "delattr" }: self._flag(f"forbidden call: {node.func.id}()") self.generic_visit(node) def _check_code_safety(code: str) -> Optional[str]: """Returns an error message if the code is unsafe, None if safe.""" # Layer 1: cheap substring scan catches the most obvious dunder smuggling. for needle in _FORBIDDEN_SUBSTRINGS: if needle in code: return f"Forbidden pattern detected: {needle!r}" # Layer 2: AST walk catches structural attempts (import, getattr, etc.). try: tree = ast.parse(code, mode="exec") except SyntaxError as exc: return f"SyntaxError: {exc.msg} (line {exc.lineno})" visitor = _SafetyVisitor() visitor.visit(tree) if visitor.violation: return visitor.violation return None def _validate_readonly_sql(sql: str) -> None: sql_upper = (sql or "").strip().upper() if not sql_upper.startswith("SELECT"): raise PermissionError("sandbox conn is read-only; only SELECT statements are permitted") for pat in _FORBIDDEN_SQL: if pat in sql_upper: raise PermissionError(f"sandbox conn rejected SQL operation: {pat.strip()}") class _ReadOnlyCursor: """Small sqlite cursor facade that never exposes the writable connection.""" def __init__(self, conn: sqlite3.Connection): self._conn = conn self._cursor: Optional[sqlite3.Cursor] = None def execute(self, sql: str, parameters=()): _validate_readonly_sql(sql) self._cursor = self._conn.execute(sql, parameters) return self def fetchone(self): if self._cursor is None: raise sqlite3.ProgrammingError("fetchone() called before execute()") return self._cursor.fetchone() def fetchmany(self, size: int | None = None): if self._cursor is None: raise sqlite3.ProgrammingError("fetchmany() called before execute()") return self._cursor.fetchmany() if size is None else self._cursor.fetchmany(size) def fetchall(self): if self._cursor is None: raise sqlite3.ProgrammingError("fetchall() called before execute()") return self._cursor.fetchall() @property def description(self): return self._cursor.description if self._cursor is not None else None def close(self) -> None: if self._cursor is not None: self._cursor.close() def __iter__(self): if self._cursor is None: return iter(()) return iter(self._cursor) class _ReadOnlyConnection: """Restricted DB facade exposed to agent CodeAct snippets.""" def __init__(self, conn: sqlite3.Connection): self._conn = conn def execute(self, sql: str, parameters=()): return _ReadOnlyCursor(self._conn).execute(sql, parameters) def cursor(self): return _ReadOnlyCursor(self._conn) def executescript(self, _script: str): raise PermissionError("sandbox conn is read-only; executescript() is disabled") def executemany(self, _sql: str, _seq_of_parameters): raise PermissionError("sandbox conn is read-only; executemany() is disabled") def commit(self) -> None: raise PermissionError("sandbox conn is read-only; commit() is disabled") def rollback(self) -> None: raise PermissionError("sandbox conn is read-only; rollback() is disabled") def execute_code( code: str, conn: sqlite3.Connection, case_dir: Optional[str] = None, on_access: Optional[Callable[[str], None]] = None, on_sql: Optional[Callable[[str], None]] = None, ) -> tuple[str, Optional[str], dict[str, int]]: """ Execute `code` in a restricted sandbox with access to `conn`. Returns: (stdout_output, error_message, execution_stats) - stdout_output: captured print() output, capped at _MAX_OUTPUT_CHARS - error_message: None on success, traceback string on error - execution_stats: counters describing successful DB/file access Path-confinement: all filesystem helpers (`open`, `listdir`, `path_exists`) resolve relative paths against `case_dir` (an absolute path). The sandbox NEVER calls os.chdir() — that is a process-wide side effect that races across concurrent sessions. """ safety_err = _check_code_safety(code) if safety_err: return "", f"SECURITY_VIOLATION: {safety_err}", { "rows_returned": 0, "files_read": 0, "directories_listed": 0, } # Inject PDF dependencies if available try: import pdfplumber except ImportError: pdfplumber = None try: import pytesseract except ImportError: pytesseract = None try: from PIL import Image except ImportError: Image = None # Narrow filesystem helpers: only within the case directory subtree. This # lets the agent enumerate intercepted_comms/ and scanned_claims/ without # needing `import os` (which the forbidden-pattern scanner would reject). # `case_dir` MUST be an absolute path; the environment guarantees this. _base = os.path.abspath(case_dir) if case_dir else None files_read = 0 directories_listed = 0 def _note_access(path: str) -> None: if on_access is not None: on_access(path) def _resolve_inside_case(path: str) -> str: """Resolve `path` (rel or abs) and confirm it stays inside _base. Raises PermissionError on traversal/escape.""" if _base is None: raise PermissionError("sandbox has no case_dir; filesystem access disabled") target = os.path.abspath(path if os.path.isabs(path) else os.path.join(_base, path)) if not (target == _base or target.startswith(_base + os.sep)): raise PermissionError(f"path outside case directory: {path!r}") return target def _safe_listdir(subdir: str = ".") -> list[str]: nonlocal directories_listed resolved = _resolve_inside_case(subdir) directories_listed += 1 rel = os.path.relpath(resolved, _base) if _base else resolved _note_access(rel) return sorted(os.listdir(resolved)) def _safe_path_join(*parts: str) -> str: """Join path parts, returning an ABSOLUTE path rooted at case_dir. We return an absolute path so the result can be passed to libraries that don't go through our `_safe_open` wrapper (e.g. pdfplumber, PIL.Image) without depending on a process-wide chdir. """ if not parts: return _base or "" joined = os.path.join(*parts) if os.path.isabs(joined): return _resolve_inside_case(joined) if _base is None: return joined return _resolve_inside_case(joined) def _safe_path_exists(path: str) -> bool: try: return os.path.exists(_resolve_inside_case(path)) except PermissionError: return False def _safe_open(path: str, mode: str = "r", *args, **kwargs): nonlocal files_read # Read-only access only. Reject any write/append/update modes. if any(c in mode for c in ("w", "a", "+", "x")): raise PermissionError(f"sandbox open() is read-only; mode={mode!r} rejected") resolved = _resolve_inside_case(path) files_read += 1 rel = os.path.relpath(resolved, _base) if _base else resolved _note_access(rel) return open(resolved, mode, *args, **kwargs) # Pre-injected stdlib modules — pure-python, no I/O, agent doesn't need to # import them (and import statements are now rejected by the AST check). import re as _re import datetime as _datetime import math as _math readonly_conn = _ReadOnlyConnection(conn) # Build restricted execution namespace namespace = { "__builtins__": _SAFE_BUILTINS, "conn": readonly_conn, "json": json, "re": _re, "datetime": _datetime, "math": _math, "pdfplumber": pdfplumber, "pytesseract": pytesseract, "Image": Image, "open": _safe_open, # Path-confined, read-only "listdir": _safe_listdir, # Enumerate evidence dirs "path_join": _safe_path_join, # Compose relative paths "path_exists": _safe_path_exists, # Test evidence paths "result": None, } if _HAS_PANDAS: namespace["pd"] = pd stdout_capture = io.StringIO() error: Optional[str] = None rows_returned = 0 deadline = time.monotonic() + _TIMEOUT_SECONDS def _timeout_trace(frame, event, arg): if time.monotonic() >= deadline: raise TimeoutError(f"Code exceeded {_TIMEOUT_SECONDS}s limit") return _timeout_trace def _run() -> None: nonlocal error, rows_returned try: sys.settrace(_timeout_trace) if on_sql is not None: conn.set_trace_callback(on_sql) with redirect_stdout(stdout_capture): exec(code, namespace) # noqa: S102 # Count rows if agent assigned a value to `result` if _HAS_PANDAS and isinstance(namespace.get("result"), pd.DataFrame): rows_returned = len(namespace["result"]) with redirect_stdout(stdout_capture): print(namespace["result"].to_string(max_rows=20)) elif isinstance(namespace.get("result"), (list, tuple, set, dict)): rows_returned = len(namespace["result"]) elif namespace.get("result") not in (None, ""): rows_returned = 1 except TimeoutError: error = f"TIMEOUT: Code exceeded {_TIMEOUT_SECONDS}s limit" except Exception: error = traceback.format_exc() finally: sys.settrace(None) if on_sql is not None: conn.set_trace_callback(None) thread = threading.Thread(target=_run, daemon=True) thread.start() thread.join(timeout=_TIMEOUT_SECONDS) if thread.is_alive(): return "", f"TIMEOUT: Code exceeded {_TIMEOUT_SECONDS}s limit", { "rows_returned": 0, "files_read": files_read, "directories_listed": directories_listed, } output = stdout_capture.getvalue() if len(output) > _MAX_OUTPUT_CHARS: output = output[:_MAX_OUTPUT_CHARS] + "\n... [output truncated]" return output, error, { "rows_returned": rows_returned, "files_read": files_read, "directories_listed": directories_listed, } def execute_sql( sql: str, conn: sqlite3.Connection, max_rows: int = 50, timeout_seconds: float = 1.5, ) -> tuple[str, Optional[str], int]: """ Execute a restricted SQL SELECT statement directly. Returns (formatted_result, error_message, rows_returned). """ sql_upper = sql.strip().upper() if not sql_upper.startswith("SELECT"): return "", "Only SELECT statements are permitted", 0 # Block dangerous SQL patterns for pat in _FORBIDDEN_SQL: if pat in sql_upper: return "", f"Forbidden SQL operation: {pat.strip()}", 0 timed_out = False def _progress_handler() -> int: nonlocal timed_out if time.monotonic() >= deadline: timed_out = True return 1 return 0 deadline = time.monotonic() + max(timeout_seconds, 0.05) try: conn.set_progress_handler(_progress_handler, 1_000) cur = conn.execute(sql) rows = cur.fetchmany(max_rows) cols = [d[0] for d in cur.description] if cur.description else [] if not rows: return "Query returned 0 rows.", None, 0 lines = ["\t".join(cols)] for row in rows: lines.append("\t".join(str(v) for v in row)) return "\n".join(lines), None, len(rows) except sqlite3.OperationalError as e: if timed_out and "interrupted" in str(e).lower(): return "", f"SQL_TIMEOUT: query exceeded {timeout_seconds:.2f}s", 0 return "", f"SQL_ERROR: {e}", 0 except Exception as e: return "", f"SQL_ERROR: {e}", 0 finally: try: conn.set_progress_handler(None, 0) except Exception: pass