File size: 15,029 Bytes
d853cbf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
"""
Python Sandbox Tool for safe code execution
Uses standard exec() with AST validation and namespace control
"""

import io
import sys
import base64
import logging
import ast
from typing import Dict, Any, Optional, Set
from contextlib import redirect_stdout, redirect_stderr
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
import pandas as pd
import numpy as np
import matplotlib
matplotlib.use('Agg')  # Non-interactive backend
import matplotlib.pyplot as plt
import seaborn as sns

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class TimeoutException(Exception):
    """Exception raised when code execution times out"""
    pass


class CodeValidator(ast.NodeVisitor):
    """
    AST validator to block dangerous operations
    """
    
    # Dangerous functions/modules to block
    BLOCKED_NAMES: Set[str] = {
        'eval', 'exec', 'compile',
        'open', 'file', 'input', 'raw_input',
        'execfile', 'reload', 'breakpoint',
        'exit', 'quit', 'help',
    }
    
    # Dangerous modules to block
    BLOCKED_MODULES: Set[str] = {
        'os', 'sys', 'subprocess', 'socket', 'urllib',
        'requests', 'http', 'ftplib', 'telnetlib',
        'pickle', 'shelve', 'marshal', 'importlib',
    }
    
    def __init__(self):
        self.errors = []
    
    def visit_Import(self, node):
        """Check import statements"""
        for alias in node.names:
            module_name = alias.name.split('.')[0]
            if module_name in self.BLOCKED_MODULES:
                self.errors.append(f"Import of '{alias.name}' is not allowed")
        self.generic_visit(node)
    
    def visit_ImportFrom(self, node):
        """Check from-import statements"""
        if node.module:
            module_name = node.module.split('.')[0]
            if module_name in self.BLOCKED_MODULES:
                self.errors.append(f"Import from '{node.module}' is not allowed")
        self.generic_visit(node)
    
    def visit_Name(self, node):
        """Check for blocked names"""
        if node.id in self.BLOCKED_NAMES:
            self.errors.append(f"Use of '{node.id}' is not allowed")
        self.generic_visit(node)
    
    def visit_Attribute(self, node):
        """Check for dangerous attribute access"""
        # Block access to __builtins__, __globals__, etc.
        if isinstance(node.attr, str) and node.attr.startswith('__') and node.attr.endswith('__'):
            if node.attr not in {'__init__', '__str__', '__repr__'}:
                self.errors.append(f"Access to '{node.attr}' is not allowed")
        self.generic_visit(node)


class PythonSandboxTool:
    """
    Safe Python code execution sandbox with restricted access
    """
    
    def __init__(self, timeout: int = 30):
        """
        Initialize the sandbox tool
        
        Args:
            timeout: Maximum execution time in seconds (default: 30)
        """
        self.timeout = timeout
        self.allowed_modules = {
            'pd': pd,
            'pandas': pd,
            'np': np,
            'numpy': np,
            'plt': plt,
            'matplotlib': matplotlib,
            'sns': sns,
            'seaborn': sns,
        }
    
    def _safe_import(self, name, globals=None, locals=None, fromlist=(), level=0):
        """
        Custom import function that uses pre-loaded modules
        
        Args:
            name: Module name to import
            globals: Global namespace (ignored)
            locals: Local namespace (ignored)
            fromlist: Names to import from module
            level: Relative import level
            
        Returns:
            Pre-loaded module object if allowed
            
        Raises:
            ImportError: If module is not allowed
        """
        # Map import names to allowed modules
        allowed = {
            'pandas': pd,
            'numpy': np,
            'matplotlib': matplotlib,
            'seaborn': sns,
        }
        
        # Return pre-loaded module if allowed
        if name in allowed:
            return allowed[name]
        
        # Handle matplotlib sub-modules (e.g., matplotlib.pyplot)
        if name.startswith('matplotlib.'):
            # Return the base matplotlib module
            # Python will then access the sub-module as an attribute
            return matplotlib
        
        # Check if it's a blocked module
        if name.split('.')[0] in CodeValidator.BLOCKED_MODULES:
            raise ImportError(f"Import of '{name}' is not allowed for security reasons")
        
        # For any other module not specifically allowed, raise error
        raise ImportError(f"Cannot import '{name}'. Only pandas, numpy, matplotlib, and seaborn are allowed.")
    
    def _create_safe_globals(self, file_path: Optional[str] = None) -> Dict[str, Any]:
        """
        Create a safe globals dictionary with whitelisted modules
        
        Args:
            file_path: Path to the uploaded Excel/CSV file
            
        Returns:
            Dictionary of safe globals
        """
        # Create a limited builtins dictionary
        safe_builtins = {
            'print': print,
            'len': len,
            'range': range,
            'enumerate': enumerate,
            'zip': zip,
            'map': map,
            'filter': filter,
            'sum': sum,
            'min': min,
            'max': max,
            'abs': abs,
            'round': round,
            'sorted': sorted,
            'list': list,
            'dict': dict,
            'set': set,
            'tuple': tuple,
            'str': str,
            'int': int,
            'float': float,
            'bool': bool,
            'isinstance': isinstance,
            'type': type,
            'hasattr': hasattr,
            'getattr': getattr,
            'setattr': setattr,
            'True': True,
            'False': False,
            'None': None,
            '__import__': self._safe_import,  # Enable safe imports
            # Exception types (necessary for try/except blocks)
            'Exception': Exception,
            'ValueError': ValueError,
            'TypeError': TypeError,
            'KeyError': KeyError,
            'IndexError': IndexError,
            'AttributeError': AttributeError,
            'RuntimeError': RuntimeError,
            'ImportError': ImportError,
            'ZeroDivisionError': ZeroDivisionError,
            # Additional useful builtins
            'locals': locals,
            'globals': lambda: safe_builtins,  # Return safe version
            'dir': dir,
            'any': any,
            'all': all,
        }
        
        safe_dict = {
            '__builtins__': safe_builtins,
            '__name__': 'sandbox',
        }
        
        # Add allowed modules (also available directly without import)
        safe_dict.update(self.allowed_modules)
        
        # Add file path if provided
        if file_path:
            safe_dict['file_path'] = file_path
            
        return safe_dict
    
    def _execute_code(self, byte_code, safe_dict, stdout_capture, stderr_capture) -> None:
        """
        Helper method to execute code (can be run in a separate thread)
        
        Args:
            byte_code: Compiled code object
            safe_dict: Safe globals dictionary
            stdout_capture: StringIO for capturing stdout
            stderr_capture: StringIO for capturing stderr
        """
        with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
            exec(byte_code, safe_dict)
    
    def execute(self, code: str, file_path: Optional[str] = None) -> Dict[str, Any]:
        """
        Execute Python code in a restricted environment
        
        Args:
            code: Python code to execute
            file_path: Path to the uploaded Excel/CSV file
            
        Returns:
            Dictionary containing:
                - success: bool
                - output: str (stdout)
                - error: str (if any)
                - dataframe: dict (if df variable exists)
                - images: list of base64 encoded images
        """
        result = {
            'success': False,
            'output': '',
            'error': '',
            'dataframe': None,
            'images': []
        }
        
        try:
            # Validate code using AST
            try:
                tree = ast.parse(code, filename='<user_code>', mode='exec')
            except SyntaxError as e:
                result['error'] = f"Syntax error: {str(e)}"
                logger.error(f"Syntax error: {str(e)}")
                return result
            
            # Check for dangerous operations
            validator = CodeValidator()
            validator.visit(tree)
            
            if validator.errors:
                result['error'] = f"Security validation failed:\n" + "\n".join(validator.errors)
                logger.error(f"Validation errors: {validator.errors}")
                return result
            
            # Configure pandas display to avoid truncated columns/rows in printed output
            try:
                pd.set_option('display.max_columns', None)
                pd.set_option('display.width', 2000)
                pd.set_option('display.max_colwidth', None)
                pd.set_option('display.expand_frame_repr', False)
            except Exception:
                pass

            # Compile the validated code
            byte_code = compile(tree, filename='<user_code>', mode='exec')
            
            # Create safe execution environment
            safe_dict = self._create_safe_globals(file_path)
            
            # Capture stdout and stderr
            stdout_capture = io.StringIO()
            stderr_capture = io.StringIO()
            
            # Execute with timeout using ThreadPoolExecutor
            try:
                with ThreadPoolExecutor(max_workers=1) as executor:
                    future = executor.submit(
                        self._execute_code,
                        byte_code,
                        safe_dict,
                        stdout_capture,
                        stderr_capture
                    )
                    # Wait for completion with timeout
                    future.result(timeout=self.timeout)
                
                # Get stdout
                result['output'] = stdout_capture.getvalue()
                
                # Check for dataframe in the namespace
                if 'df' in safe_dict and isinstance(safe_dict['df'], pd.DataFrame):
                    # Convert dataframe to dict and make it JSON-safe
                    records = safe_dict['df'].head(5).to_dict('records')
                    result['dataframe'] = self._make_json_safe_records(records)
                elif 'result' in safe_dict and isinstance(safe_dict['result'], pd.DataFrame):
                    records = safe_dict['result'].head(5).to_dict('records')
                    result['dataframe'] = self._make_json_safe_records(records)
                
                # Capture matplotlib figures
                figures = [plt.figure(i) for i in plt.get_fignums()]
                for fig in figures:
                    buf = io.BytesIO()
                    fig.savefig(buf, format='png', bbox_inches='tight', dpi=100)
                    buf.seek(0)
                    img_base64 = base64.b64encode(buf.read()).decode('utf-8')
                    result['images'].append(img_base64)
                    buf.close()
                
                # Close all figures to free memory
                plt.close('all')
                
                result['success'] = True
                logger.info("Code executed successfully")
                
            except FuturesTimeoutError:
                result['error'] = f"Execution timeout: Code took longer than {self.timeout} seconds"
                logger.error(f"Timeout: Code execution exceeded {self.timeout} seconds")
            except Exception as e:
                result['error'] = f"Runtime error: {str(e)}"
                logger.error(f"Runtime error: {str(e)}")
                
                # Include stderr if available
                stderr_output = stderr_capture.getvalue()
                if stderr_output:
                    result['error'] += f"\n{stderr_output}"
        
        except Exception as e:
            result['error'] = f"Sandbox error: {str(e)}"
            logger.error(f"Sandbox error: {str(e)}")

        logger.info(f"Result: {result}")
        
        return result

    def _make_json_safe_records(self, records):
        """
        Convert a list of dict records into JSON-serializable values:
        - pandas.Timestamp -> ISO string
        - numpy types -> native Python
        - NaN -> None
        """
        import math
        from datetime import datetime

        def to_safe(value):
            if isinstance(value, pd.Timestamp):
                return value.isoformat()
            if isinstance(value, datetime):
                return value.isoformat()
            if isinstance(value, np.generic):
                # numpy scalar -> native python
                py = value.item()
                if isinstance(py, float) and (math.isnan(py) or py == float('inf') or py == float('-inf')):
                    return None
                return py
            if isinstance(value, float):
                if math.isnan(value) or value == float('inf') or value == float('-inf'):
                    return None
                return value
            return value

        safe_records = []
        for rec in records or []:
            safe_rec = {k: to_safe(v) for k, v in rec.items()}
            safe_records.append(safe_rec)
        return safe_records
    
    def get_tool_definition(self) -> Dict[str, Any]:
        """
        Get the tool definition for OpenAI function calling
        
        Returns:
            Tool definition dictionary
        """
        return {
            "type": "function",
            "function": {
                "name": "execute_python_code",
                "description": "Execute Python code to analyze Excel/CSV data. Use pandas (pd), numpy (np), matplotlib (plt), and seaborn (sns). The uploaded file path is available as 'file_path' variable. Store results in 'df' or 'result' variable to return dataframes.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "code": {
                            "type": "string",
                            "description": "Python code to execute. Must use pandas to read the file (e.g., pd.read_excel(file_path) or pd.read_csv(file_path)). Store final dataframe in 'df' or 'result' variable."
                        }
                    },
                    "required": ["code"]
                }
            }
        }