tinyagent-py 0.0.13__py3-none-any.whl → 0.0.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1065 @@
1
+ import os
2
+ import sys
3
+ import asyncio
4
+ import tempfile
5
+ import platform
6
+ import subprocess
7
+ import cloudpickle
8
+ import json
9
+ import re
10
+ from typing import Dict, List, Any, Optional
11
+ from pathlib import Path
12
+
13
+ from tinyagent.hooks.logging_manager import LoggingManager
14
+ from .base import CodeExecutionProvider
15
+ from ..utils import clean_response, make_session_blob
16
+
17
+ # Define colors for output formatting
18
+ COLOR = {
19
+ "HEADER": "\033[95m",
20
+ "BLUE": "\033[94m",
21
+ "GREEN": "\033[92m",
22
+ "RED": "\033[91m",
23
+ "ENDC": "\033[0m",
24
+ }
25
+
26
+ # Regular expression to strip ANSI color codes
27
+ ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
28
+
29
+ def strip_ansi_codes(text):
30
+ """
31
+ Remove ANSI color and style codes from text.
32
+
33
+ Args:
34
+ text: Text that may contain ANSI escape sequences
35
+
36
+ Returns:
37
+ Clean text without ANSI codes
38
+ """
39
+ return ANSI_ESCAPE.sub('', text)
40
+
41
+
42
+ class SeatbeltProvider(CodeExecutionProvider):
43
+ """
44
+ A code execution provider that uses macOS's sandbox-exec (seatbelt) for sandboxed execution.
45
+
46
+ This provider executes Python code and shell commands within a macOS sandbox for enhanced security.
47
+ It only works on macOS systems and requires local execution.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ log_manager: Optional[LoggingManager] = None,
53
+ code_tools: List[Any] = None,
54
+ seatbelt_profile: Optional[str] = None,
55
+ seatbelt_profile_path: Optional[str] = None,
56
+ python_env_path: Optional[str] = None,
57
+ authorized_imports: list[str] | None = None,
58
+ authorized_functions: list[str] | None = None,
59
+ check_string_obfuscation: bool = True,
60
+ bypass_shell_safety: bool = True, # Default to True for SeatbeltProvider
61
+ additional_safe_shell_commands: Optional[List[str]] = None,
62
+ additional_safe_control_operators: Optional[List[str]] = None,
63
+ additional_read_dirs: Optional[List[str]] = None, # New parameter for additional read directories
64
+ additional_write_dirs: Optional[List[str]] = None, # New parameter for additional write directories
65
+ environment_variables: Optional[Dict[str, str]] = None, # New parameter for environment variables
66
+ **kwargs
67
+ ):
68
+ """
69
+ Initialize the SeatbeltProvider.
70
+
71
+ Args:
72
+ log_manager: Optional logging manager
73
+ code_tools: List of tools available in the Python execution environment
74
+ seatbelt_profile: String containing seatbelt profile rules
75
+ seatbelt_profile_path: Path to a file containing seatbelt profile rules
76
+ python_env_path: Path to the Python environment to use
77
+ authorized_imports: Optional allow-list of modules the user code is permitted to import
78
+ authorized_functions: Optional allow-list of dangerous functions the user code is permitted to use
79
+ check_string_obfuscation: If True, check for string obfuscation techniques
80
+ bypass_shell_safety: If True, bypass shell command safety checks (default: True for seatbelt)
81
+ additional_safe_shell_commands: Additional shell commands to consider safe
82
+ additional_safe_control_operators: Additional shell control operators to consider safe
83
+ additional_read_dirs: List of additional directories to allow read access to
84
+ additional_write_dirs: List of additional directories to allow write access to
85
+ environment_variables: Dictionary of environment variables to make available in the sandbox
86
+ **kwargs: Additional arguments passed to CodeExecutionProvider
87
+ """
88
+ # Initialize logger first to avoid AttributeError
89
+ self.logger = None
90
+ if log_manager:
91
+ self.logger = log_manager.get_logger('tinyagent.code_agent.providers.seatbelt_provider')
92
+
93
+ super().__init__(
94
+ log_manager=log_manager,
95
+ code_tools=code_tools,
96
+ bypass_shell_safety=bypass_shell_safety,
97
+ additional_safe_shell_commands=additional_safe_shell_commands,
98
+ additional_safe_control_operators=additional_safe_control_operators,
99
+ **kwargs
100
+ )
101
+
102
+ # Check if running on macOS
103
+ if platform.system() != "Darwin":
104
+ raise RuntimeError("SeatbeltProvider only works on macOS systems")
105
+
106
+ # Store additional read/write directories
107
+ self.additional_read_dirs = additional_read_dirs or []
108
+ self.additional_write_dirs = additional_write_dirs or []
109
+
110
+ # Expand and normalize paths to avoid issues with symlinks and relative paths
111
+ self.additional_read_dirs = [os.path.abspath(os.path.expanduser(path)) for path in self.additional_read_dirs]
112
+ self.additional_write_dirs = [os.path.abspath(os.path.expanduser(path)) for path in self.additional_write_dirs]
113
+
114
+ # Store environment variables
115
+ self.environment_variables = environment_variables.copy() if environment_variables else {}
116
+
117
+ # Set up seatbelt profile
118
+ self.seatbelt_profile = seatbelt_profile
119
+ self.seatbelt_profile_path = seatbelt_profile_path
120
+
121
+ # If neither profile nor path is provided, use a default restrictive profile
122
+ if not self.seatbelt_profile and not self.seatbelt_profile_path:
123
+ self.seatbelt_profile = self._get_default_seatbelt_profile()
124
+
125
+ # If a profile string is provided but no path, write it to a temporary file
126
+ if self.seatbelt_profile and not self.seatbelt_profile_path:
127
+ self._write_seatbelt_profile_to_temp_file()
128
+
129
+ # Set Python environment path
130
+ self.python_env_path = python_env_path
131
+
132
+ # Safety settings - by default, more permissive than Modal/local
133
+ self.authorized_imports = authorized_imports
134
+ self.authorized_functions = authorized_functions or []
135
+ self.check_string_obfuscation = check_string_obfuscation
136
+ self.is_trusted_code = kwargs.get("trust_code", False)
137
+
138
+ # Log initialization
139
+ if self.logger:
140
+ profile_path = self.seatbelt_profile_path or "default profile (not yet written to file)"
141
+ self.logger.info("Initialized SeatbeltProvider with sandbox profile at: %s", profile_path)
142
+ if self.additional_read_dirs:
143
+ self.logger.info("Additional read directories: %s", ", ".join(self.additional_read_dirs))
144
+ if self.additional_write_dirs:
145
+ self.logger.info("Additional write directories: %s", ", ".join(self.additional_write_dirs))
146
+ if self.environment_variables:
147
+ env_keys = list(self.environment_variables.keys())
148
+ self.logger.info("Environment variables: %s", ", ".join(env_keys))
149
+
150
+ def set_environment_variables(self, env_vars: Dict[str, str]):
151
+ """
152
+ Set environment variables for the sandbox.
153
+
154
+ Args:
155
+ env_vars: Dictionary of environment variable name -> value pairs
156
+ """
157
+ self.environment_variables = env_vars.copy()
158
+ if self.logger:
159
+ env_keys = list(self.environment_variables.keys())
160
+ self.logger.info("Updated environment variables: %s", ", ".join(env_keys))
161
+
162
+ def add_environment_variable(self, name: str, value: str):
163
+ """
164
+ Add a single environment variable.
165
+
166
+ Args:
167
+ name: Environment variable name
168
+ value: Environment variable value
169
+ """
170
+ self.environment_variables[name] = value
171
+ if self.logger:
172
+ self.logger.info("Added environment variable: %s", name)
173
+
174
+ def remove_environment_variable(self, name: str):
175
+ """
176
+ Remove an environment variable.
177
+
178
+ Args:
179
+ name: Environment variable name to remove
180
+ """
181
+ if name in self.environment_variables:
182
+ del self.environment_variables[name]
183
+ if self.logger:
184
+ self.logger.info("Removed environment variable: %s", name)
185
+
186
+ def get_environment_variables(self) -> Dict[str, str]:
187
+ """
188
+ Get a copy of current environment variables.
189
+
190
+ Returns:
191
+ Dictionary of current environment variables
192
+ """
193
+ return self.environment_variables.copy()
194
+
195
+ def _get_sandbox_environment(self) -> Dict[str, str]:
196
+ """
197
+ Get the complete environment for sandbox execution.
198
+
199
+ Returns:
200
+ Dictionary containing all environment variables for the sandbox
201
+ """
202
+ # Start with essential system environment variables
203
+ base_env = {
204
+ 'PATH': os.environ.get('PATH', '/usr/bin:/bin:/usr/sbin:/sbin'),
205
+ 'HOME': os.environ.get('HOME', '/tmp'),
206
+ 'USER': os.environ.get('USER', 'nobody'),
207
+ 'TERM': os.environ.get('TERM', 'xterm'),
208
+ 'LANG': os.environ.get('LANG', 'en_US.UTF-8'),
209
+ 'LC_ALL': os.environ.get('LC_ALL', 'en_US.UTF-8'),
210
+ }
211
+
212
+ # Add Python-specific environment variables if available
213
+ python_vars = ['PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV', 'CONDA_PREFIX']
214
+ for var in python_vars:
215
+ if var in os.environ:
216
+ base_env[var] = os.environ[var]
217
+
218
+ # Add user-defined environment variables (these can override base ones)
219
+ base_env.update(self.environment_variables)
220
+
221
+ return base_env
222
+
223
+
224
+
225
+ def _get_default_seatbelt_profile(self) -> str:
226
+ """
227
+ Get a default restrictive seatbelt profile.
228
+
229
+ Returns:
230
+ String containing default seatbelt profile rules
231
+ """
232
+ current_dir = os.getcwd()
233
+ home_dir = os.path.expanduser("~")
234
+ temp_dir = tempfile.gettempdir()
235
+
236
+ # Build additional read directories section
237
+ additional_read_dirs_rules = ""
238
+ for dir_path in self.additional_read_dirs:
239
+ additional_read_dirs_rules += f' (subpath "{dir_path}")\n'
240
+
241
+ # Build additional write directories section
242
+ additional_write_dirs_rules = ""
243
+ for dir_path in self.additional_write_dirs:
244
+ additional_write_dirs_rules += f' (subpath "{dir_path}")\n'
245
+
246
+ return f"""(version 1)
247
+
248
+ ; Default to deny everything
249
+ (deny default)
250
+
251
+ ; Allow network connections with proper DNS resolution
252
+ (allow network*)
253
+ (allow network-outbound)
254
+ (allow mach-lookup)
255
+ (allow system-socket)
256
+
257
+ ; Allow process execution
258
+ (allow process-exec)
259
+ (allow process-fork)
260
+ (allow signal (target self))
261
+
262
+ ; Restrict file read to current path and system files
263
+ (deny file-read* (subpath "/Users"))
264
+ (allow file-read*
265
+ (subpath "{current_dir}")
266
+ (subpath "{home_dir}/.conda")
267
+ (subpath "{home_dir}/.pyenv")
268
+ (subpath "/usr")
269
+ (subpath "/System")
270
+ (subpath "/Library")
271
+ (subpath "/bin")
272
+ (subpath "/sbin")
273
+ (subpath "/opt")
274
+ (subpath "{temp_dir}")
275
+ (subpath "/private/tmp")
276
+ (subpath "/private/var/tmp")
277
+ (subpath "/dev")
278
+ (subpath "/etc")
279
+ (literal "/")
280
+ (literal "/.")
281
+ {additional_read_dirs_rules})
282
+
283
+ ; Allow write access to specified folder and temp directories
284
+ (deny file-write* (subpath "/"))
285
+ (allow file-write*
286
+ (subpath "{current_dir}")
287
+ (subpath "{temp_dir}")
288
+ (subpath "/private/tmp")
289
+ (subpath "/private/var/tmp")
290
+ (subpath "/dev")
291
+ {additional_write_dirs_rules})
292
+
293
+ ; Allow standard device operations
294
+ (allow file-write-data
295
+ (literal "/dev/null")
296
+ (literal "/dev/dtracehelper")
297
+ (literal "/dev/tty")
298
+ (literal "/dev/stdout")
299
+ (literal "/dev/stderr"))
300
+
301
+ ; Allow iokit operations needed for system functions
302
+ (allow iokit-open)
303
+
304
+ ; Allow shared memory operations
305
+ (allow ipc-posix-shm)
306
+
307
+ ; Allow basic system operations
308
+ (allow file-read-metadata)
309
+ (allow process-info-pidinfo)
310
+ (allow process-info-setcontrol)
311
+
312
+ ; Allow Git operations
313
+ (allow sysctl-read)
314
+ (allow file-read-xattr)
315
+ (allow file-write-xattr)
316
+ (allow file-issue-extension (extension "com.apple.app-sandbox.read"))
317
+ (allow file-issue-extension (extension "com.apple.app-sandbox.read-write"))
318
+ (allow file-map-executable)
319
+ (allow file-read-data)
320
+ """
321
+
322
+ def _write_seatbelt_profile_to_temp_file(self):
323
+ """
324
+ Write the seatbelt profile to a temporary file.
325
+ """
326
+ try:
327
+ fd, path = tempfile.mkstemp(suffix='.sb', prefix='tinyagent_seatbelt_')
328
+ with os.fdopen(fd, 'w') as f:
329
+ f.write(self.seatbelt_profile)
330
+ self.seatbelt_profile_path = path
331
+ if self.logger:
332
+ self.logger.info("Wrote seatbelt profile to temporary file: %s", path)
333
+ except Exception as e:
334
+ if self.logger:
335
+ self.logger.error("Failed to write seatbelt profile to temporary file: %s", str(e))
336
+ raise RuntimeError(f"Failed to write seatbelt profile: {str(e)}")
337
+
338
+ async def execute_python(self, code_lines: List[str], timeout: int = 120) -> Dict[str, Any]:
339
+ """
340
+ Execute Python code within a sandbox and return the result.
341
+
342
+ Args:
343
+ code_lines: List of Python code lines to execute
344
+ timeout: Maximum execution time in seconds
345
+
346
+ Returns:
347
+ Dictionary containing execution results
348
+ """
349
+ if isinstance(code_lines, str):
350
+ code_lines = [code_lines]
351
+
352
+ full_code = "\n".join(code_lines)
353
+
354
+ print("#" * 100)
355
+ print("##########################################code##########################################")
356
+ print(full_code)
357
+ print("#" * 100)
358
+
359
+ # Prepare the full code with tools and default codes if needed
360
+ if self.executed_default_codes:
361
+ print("✔️ default codes already executed")
362
+ complete_code = "\n".join(self.code_tools_definitions) + "\n\n" + full_code
363
+ else:
364
+ complete_code = "\n".join(self.code_tools_definitions) + "\n\n" + "\n".join(self.default_python_codes) + "\n\n" + full_code
365
+ self.executed_default_codes = True
366
+
367
+ # Create a temporary file for the Python state and code
368
+ with tempfile.NamedTemporaryFile(suffix='_state.pkl', prefix='tinyagent_', delete=False, mode='wb') as state_file:
369
+ # Serialize the globals and locals dictionaries
370
+ cloudpickle.dump({
371
+ 'globals': self._globals_dict,
372
+ 'locals': self._locals_dict,
373
+ 'authorized_imports': self.authorized_imports,
374
+ 'authorized_functions': self.authorized_functions,
375
+ 'trusted_code': self.is_trusted_code,
376
+ 'check_string_obfuscation': self.check_string_obfuscation
377
+ }, state_file)
378
+ state_file_path = state_file.name
379
+
380
+ # Create a temporary file for the Python code
381
+ with tempfile.NamedTemporaryFile(suffix='.py', prefix='tinyagent_', delete=False, mode='w') as code_file:
382
+ # Write the wrapper script that will execute the code and maintain state
383
+ code_file.write(f"""
384
+ import sys
385
+ import os
386
+ import cloudpickle
387
+ import json
388
+ import traceback
389
+ import io
390
+ import contextlib
391
+ from pathlib import Path
392
+
393
+ # Import safety modules if available
394
+ try:
395
+ from tinyagent.code_agent.safety import validate_code_safety, function_safety_context
396
+ SAFETY_AVAILABLE = True
397
+ except ImportError:
398
+ SAFETY_AVAILABLE = False
399
+ # Define dummy safety functions
400
+ def validate_code_safety(*args, **kwargs):
401
+ pass
402
+
403
+ def function_safety_context(*args, **kwargs):
404
+ class DummyContext:
405
+ def __enter__(self):
406
+ pass
407
+ def __exit__(self, *args):
408
+ pass
409
+ return DummyContext()
410
+
411
+ # Load state from the state file
412
+ state_path = "{state_file_path}"
413
+ with open(state_path, 'rb') as f:
414
+ state = cloudpickle.load(f)
415
+
416
+ globals_dict = state['globals']
417
+ locals_dict = state['locals']
418
+ authorized_imports = state['authorized_imports']
419
+ authorized_functions = state['authorized_functions']
420
+ trusted_code = state['trusted_code']
421
+ check_string_obfuscation = state['check_string_obfuscation']
422
+
423
+ # The code to execute
424
+ code = '''
425
+ {complete_code}
426
+ '''
427
+
428
+ # Run the code and capture output
429
+ def run_code():
430
+ # Static safety analysis if available
431
+ if SAFETY_AVAILABLE:
432
+ validate_code_safety(
433
+ code,
434
+ authorized_imports=authorized_imports,
435
+ authorized_functions=authorized_functions,
436
+ trusted_code=trusted_code,
437
+ check_string_obfuscation=check_string_obfuscation
438
+ )
439
+
440
+ # Make copies to avoid mutating the original parameters
441
+ updated_globals = globals_dict.copy()
442
+ updated_locals = locals_dict.copy()
443
+
444
+ # Pre-import essential modules
445
+ essential_modules = ['requests', 'json', 'time', 'datetime', 're', 'random', 'math', 'cloudpickle']
446
+ for module_name in essential_modules:
447
+ try:
448
+ module = __import__(module_name)
449
+ updated_globals[module_name] = module
450
+ except ImportError:
451
+ print(f"⚠️ Warning: {{module_name}} module not available")
452
+
453
+ # Parse and compile the code
454
+ import ast
455
+ try:
456
+ tree = ast.parse(code, mode="exec")
457
+ compiled = compile(tree, filename="<ast>", mode="exec")
458
+ except SyntaxError as e:
459
+ return {{
460
+ "printed_output": "",
461
+ "return_value": None,
462
+ "stderr": "",
463
+ "error_traceback": f"Syntax error: {{str(e)}}",
464
+ "updated_globals": updated_globals,
465
+ "updated_locals": updated_locals
466
+ }}
467
+
468
+ # Execute with exception handling
469
+ error_traceback = None
470
+ output = None
471
+ stdout_buf = io.StringIO()
472
+ stderr_buf = io.StringIO()
473
+
474
+ # Merge globals and locals for execution
475
+ merged_globals = updated_globals.copy()
476
+ merged_globals.update(updated_locals)
477
+
478
+ with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
479
+ try:
480
+ # Add 'exec' to authorized_functions for internal use
481
+ internal_authorized_functions = ['exec', 'eval']
482
+ if authorized_functions is not None and not isinstance(authorized_functions, bool):
483
+ internal_authorized_functions.extend(authorized_functions)
484
+
485
+ # Execute with safety context if available
486
+ if SAFETY_AVAILABLE:
487
+ with function_safety_context(authorized_functions=internal_authorized_functions, trusted_code=trusted_code):
488
+ output = exec(compiled, merged_globals)
489
+ else:
490
+ output = exec(compiled, merged_globals)
491
+
492
+ # Update dictionaries with new variables
493
+ for key, value in merged_globals.items():
494
+ if key not in updated_globals and key not in updated_locals:
495
+ updated_locals[key] = value
496
+ elif key in updated_locals or key not in updated_globals:
497
+ updated_locals[key] = value
498
+ updated_globals[key] = value
499
+ except Exception:
500
+ # Capture the full traceback
501
+ error_traceback = traceback.format_exc()
502
+
503
+ # Update variables even on exception
504
+ for key, value in merged_globals.items():
505
+ if key.startswith('__') or key in ['builtins', 'traceback', 'contextlib', 'io', 'ast', 'sys']:
506
+ continue
507
+ if key in updated_locals or key not in updated_globals:
508
+ updated_locals[key] = value
509
+ updated_globals[key] = value
510
+
511
+ printed_output = stdout_buf.getvalue()
512
+ stderr_output = stderr_buf.getvalue()
513
+
514
+ return {{
515
+ "printed_output": printed_output,
516
+ "return_value": output,
517
+ "stderr": stderr_output,
518
+ "error_traceback": error_traceback,
519
+ "updated_globals": updated_globals,
520
+ "updated_locals": updated_locals
521
+ }}
522
+
523
+ # Run the code and get the result
524
+ result = run_code()
525
+
526
+ # Serialize the globals and locals for the next run
527
+ with open(state_path, 'wb') as f:
528
+ cloudpickle.dump({{
529
+ 'globals': result['updated_globals'],
530
+ 'locals': result['updated_locals'],
531
+ 'authorized_imports': authorized_imports,
532
+ 'authorized_functions': authorized_functions,
533
+ 'trusted_code': trusted_code,
534
+ 'check_string_obfuscation': check_string_obfuscation
535
+ }}, f)
536
+
537
+ # Clean the result for output
538
+ cleaned_result = {{
539
+ "printed_output": result["printed_output"],
540
+ "return_value": result["return_value"],
541
+ "stderr": result["stderr"],
542
+ "error_traceback": result["error_traceback"]
543
+ }}
544
+
545
+ # Print the result as JSON for the parent process to capture
546
+ print(json.dumps(cleaned_result))
547
+ """)
548
+ code_file_path = code_file.name
549
+
550
+ try:
551
+ # Prepare the sandbox command
552
+ python_cmd = sys.executable
553
+ if self.python_env_path:
554
+ python_cmd = os.path.join(self.python_env_path, 'bin', 'python')
555
+
556
+ # Get the complete environment for the sandbox
557
+ sandbox_env = self._get_sandbox_environment()
558
+
559
+ sandbox_cmd = [
560
+ "sandbox-exec",
561
+ "-f", self.seatbelt_profile_path,
562
+ python_cmd,
563
+ code_file_path
564
+ ]
565
+
566
+ if self.logger:
567
+ self.logger.debug("Executing Python code in sandbox: %s", " ".join(sandbox_cmd))
568
+
569
+ # Execute the command
570
+ process = await asyncio.create_subprocess_exec(
571
+ *sandbox_cmd,
572
+ stdout=asyncio.subprocess.PIPE,
573
+ stderr=asyncio.subprocess.PIPE,
574
+ env=sandbox_env
575
+ )
576
+
577
+ try:
578
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
579
+ stdout_str = stdout.decode('utf-8', errors='replace')
580
+ stderr_str = stderr.decode('utf-8', errors='replace')
581
+
582
+ # Try to parse the JSON result from stdout
583
+ try:
584
+ # The last line should be our JSON result
585
+ json_result = json.loads(stdout_str.strip())
586
+ result = json_result
587
+ except json.JSONDecodeError:
588
+ # If we can't parse JSON, return the raw output
589
+ result = {
590
+ "printed_output": stdout_str,
591
+ "return_value": None,
592
+ "stderr": stderr_str,
593
+ "error_traceback": f"Failed to parse result as JSON: {stderr_str}"
594
+ }
595
+
596
+ # Load updated state
597
+ try:
598
+ with open(state_file_path, 'rb') as f:
599
+ state = cloudpickle.load(f)
600
+ self._globals_dict = state['globals']
601
+ self._locals_dict = state['locals']
602
+
603
+ # Update user variables from the updated globals and locals
604
+ self.update_user_variables_from_globals(self._globals_dict)
605
+ self.update_user_variables_from_globals(self._locals_dict)
606
+ except Exception as e:
607
+ print(f"Warning: Failed to update globals/locals after execution: {str(e)}")
608
+
609
+ if process.returncode != 0:
610
+ result["error"] = f"Process exited with code {process.returncode}"
611
+
612
+ # Log the response
613
+ self._log_response(result)
614
+
615
+ return clean_response(result)
616
+
617
+ except asyncio.TimeoutError:
618
+ process.kill()
619
+ return {
620
+ "printed_output": "",
621
+ "return_value": None,
622
+ "stderr": f"Execution timed out after {timeout} seconds",
623
+ "error_traceback": f"Execution timed out after {timeout} seconds"
624
+ }
625
+
626
+ except Exception as e:
627
+ if self.logger:
628
+ self.logger.error("Error executing Python in sandbox: %s", str(e))
629
+ return {
630
+ "printed_output": "",
631
+ "return_value": None,
632
+ "stderr": f"Error executing code: {str(e)}",
633
+ "error_traceback": f"Error executing code: {str(e)}"
634
+ }
635
+
636
+ finally:
637
+ # Clean up the temporary files
638
+ try:
639
+ os.unlink(code_file_path)
640
+ os.unlink(state_file_path)
641
+ except Exception:
642
+ pass
643
+
644
+ def _log_response(self, response: Dict[str, Any]):
645
+ """Log the response from code execution."""
646
+ print("######################### SEATBELT EXECUTION #########################")
647
+ print("#########################<printed_output>#########################")
648
+ print(response["printed_output"])
649
+ print("#########################</printed_output>#########################")
650
+ if response.get("return_value", None) not in [None, ""]:
651
+ print("#########################<return_value>#########################")
652
+ print(response["return_value"])
653
+ print("#########################</return_value>#########################")
654
+ if response.get("stderr", None) not in [None, ""]:
655
+ print("#########################<stderr>#########################")
656
+ print(response["stderr"])
657
+ print("#########################</stderr>#########################")
658
+ if response.get("error_traceback", None) not in [None, ""]:
659
+ print("#########################<traceback>#########################")
660
+ # Check if this is a security exception and highlight it in red if so
661
+ error_text = response["error_traceback"]
662
+ if "SECURITY" in error_text:
663
+ print(f"{COLOR['RED']}{error_text}{COLOR['ENDC']}")
664
+ else:
665
+ print(error_text)
666
+ print("#########################</traceback>#########################")
667
+
668
+ def _needs_shell_wrapper(self, command: List[str]) -> bool:
669
+ """
670
+ Determine if a command needs bash -c wrapper based on shell features.
671
+
672
+ Args:
673
+ command: List of command parts
674
+
675
+ Returns:
676
+ True if command needs bash -c wrapper, False if it can run directly
677
+ """
678
+ if not command:
679
+ return False
680
+
681
+ command_str = " ".join(command)
682
+
683
+ # Shell metacharacters that require bash -c
684
+ shell_metacharacters = [
685
+ "|", "&", ";", "(", ")", "{", "}", "[", "]",
686
+ "&&", "||", ">>", "<<", "<", ">", "<<<",
687
+ "$", "`", "~", "*", "?", "!", "^"
688
+ ]
689
+
690
+ # Check for shell metacharacters
691
+ for char in shell_metacharacters:
692
+ if char in command_str:
693
+ return True
694
+
695
+ # Shell built-ins that require bash -c
696
+ shell_builtins = [
697
+ "cd", "export", "source", ".", "alias", "unalias", "set", "unset",
698
+ "echo", "printf", "test", "[", "[[", "declare", "local", "readonly",
699
+ "typeset", "eval", "exec", "exit", "return", "break", "continue",
700
+ "shift", "getopts", "read", "wait", "jobs", "fg", "bg", "disown",
701
+ "kill", "trap", "ulimit", "umask", "type", "command", "builtin",
702
+ "enable", "help", "history", "fc", "dirs", "pushd", "popd",
703
+ "suspend", "times", "caller", "complete", "compgen", "shopt"
704
+ ]
705
+
706
+ # Check if first command is a shell built-in
707
+ if command[0] in shell_builtins:
708
+ return True
709
+
710
+ # Special cases that need shell interpretation
711
+ if (
712
+ # Variable assignment (VAR=value)
713
+ any("=" in arg and not arg.startswith("-") for arg in command) or
714
+ # Command substitution patterns
715
+ "$((" in command_str or "))" in command_str or
716
+ # Brace expansion
717
+ "{" in command_str and "}" in command_str
718
+ ):
719
+ return True
720
+
721
+ return False
722
+
723
+ async def _prepare_git_sandbox_command(self, command: List[str]) -> List[str]:
724
+ """
725
+ Prepare a specialized sandbox command for git operations.
726
+
727
+ Args:
728
+ command: Git command to execute
729
+
730
+ Returns:
731
+ List of sandbox command parts
732
+ """
733
+ # Create a temporary directory for git operations
734
+ temp_dir = tempfile.mkdtemp(prefix='tinyagent_git_')
735
+ self._temp_git_dir = temp_dir # Store for cleanup
736
+
737
+ # Get GitHub credentials from environment
738
+ github_username = self.environment_variables.get('GITHUB_USERNAME', 'tinyagent')
739
+ github_token = self.environment_variables.get('GITHUB_TOKEN', '')
740
+ git_author_name = self.environment_variables.get('GIT_AUTHOR_NAME', 'TinyAgent')
741
+ git_author_email = self.environment_variables.get('GIT_AUTHOR_EMAIL', 'tinyagent@example.com')
742
+
743
+ # Create a git config file in the temp directory
744
+ git_config_path = os.path.join(temp_dir, '.gitconfig')
745
+ with open(git_config_path, 'w') as git_config:
746
+ git_config.write(f"""[user]
747
+ name = {git_author_name}
748
+ email = {git_author_email}
749
+ [safe]
750
+ directory = *
751
+ [http]
752
+ sslVerify = true
753
+ [core]
754
+ autocrlf = input
755
+ askpass = /bin/echo
756
+ [credential]
757
+ helper = ""
758
+ useHttpPath = false
759
+ [credential "https://github.com"]
760
+ helper = ""
761
+ [credential "https://api.github.com"]
762
+ helper = ""
763
+ [credential "https://gist.github.com"]
764
+ helper = ""
765
+ """)
766
+
767
+ # Create a netrc file for additional authentication bypass
768
+ netrc_path = os.path.join(temp_dir, '.netrc')
769
+ if github_token and github_username:
770
+ with open(netrc_path, 'w') as netrc_file:
771
+ netrc_file.write(f"machine github.com login {github_username} password {github_token}\n")
772
+ netrc_file.write(f"machine api.github.com login {github_username} password {github_token}\n")
773
+ os.chmod(netrc_path, 0o600) # Secure permissions for .netrc
774
+
775
+ # Create a modified seatbelt profile that allows access to the temp directory
776
+ temp_profile_path = os.path.join(temp_dir, 'git_seatbelt.sb')
777
+ with open(temp_profile_path, 'w') as profile_file:
778
+ # Get the original profile content
779
+ profile_content = self.seatbelt_profile
780
+
781
+ # Add temp directory to the profile for git operations
782
+ profile_content = profile_content.replace(
783
+ "; Allow Git operations",
784
+ f"; Allow Git operations\n(allow file-read* (subpath \"{temp_dir}\"))\n(allow file-write* (subpath \"{temp_dir}\"))"
785
+ )
786
+
787
+ # Ensure additional directories are included in the modified profile
788
+ if self.additional_read_dirs or self.additional_write_dirs:
789
+ # Build additional read directories section
790
+ additional_read_dirs_rules = ""
791
+ for dir_path in self.additional_read_dirs:
792
+ if f'(subpath "{dir_path}")' not in profile_content:
793
+ additional_read_dirs_rules += f'(allow file-read* (subpath "{dir_path}"))\n'
794
+
795
+ # Build additional write directories section
796
+ additional_write_dirs_rules = ""
797
+ for dir_path in self.additional_write_dirs:
798
+ if f'(subpath "{dir_path}")' not in profile_content:
799
+ additional_write_dirs_rules += f'(allow file-write* (subpath "{dir_path}"))\n'
800
+
801
+ # Add any missing directories to the profile
802
+ if additional_read_dirs_rules or additional_write_dirs_rules:
803
+ profile_content = profile_content.replace(
804
+ "; Allow Git operations",
805
+ f"; Allow Git operations\n{additional_read_dirs_rules}{additional_write_dirs_rules}"
806
+ )
807
+
808
+ profile_file.write(profile_content)
809
+
810
+ # Get the base sandbox environment and add git-specific variables
811
+ sandbox_env = self._get_sandbox_environment()
812
+
813
+ # Add git-specific environment variables
814
+ git_env = {
815
+ "GIT_CONFIG_GLOBAL": git_config_path,
816
+ "HOME": temp_dir,
817
+ # Completely disable all credential helpers and prompts
818
+ "GIT_TERMINAL_PROMPT": "0",
819
+ "GIT_ASKPASS": "/bin/echo",
820
+ "SSH_ASKPASS": "/bin/echo",
821
+ "DISPLAY": "",
822
+ "GIT_CONFIG_NOSYSTEM": "1",
823
+ # Disable credential storage completely
824
+ "GIT_CREDENTIAL_HELPER": "",
825
+ # Disable macOS keychain specifically
826
+ "GIT_CREDENTIAL_OSXKEYCHAIN": "0",
827
+ # Force use of netrc if available
828
+ "NETRC": netrc_path if github_token and github_username else "",
829
+ # Additional security environment variables
830
+ "GIT_CURL_VERBOSE": "0",
831
+ "GIT_QUIET": "1",
832
+ }
833
+
834
+ # If this is a push command and we have a token, modify the command to use the token directly
835
+ if github_token and len(command) >= 3 and command[1] == "push":
836
+ # Get the remote name (e.g., "fork" or "origin")
837
+ remote_name = command[2]
838
+
839
+ # Create a script that will set up the remote URL with the token and then execute the push
840
+ script_path = os.path.join(temp_dir, 'git_push_with_token.sh')
841
+ with open(script_path, 'w') as script_file:
842
+ script_file.write(f"""#!/bin/bash
843
+ set -e
844
+
845
+ # Disable all credential helpers explicitly
846
+ export GIT_CREDENTIAL_HELPER=""
847
+ export GIT_CREDENTIAL_OSXKEYCHAIN="0"
848
+ export GIT_TERMINAL_PROMPT="0"
849
+ export GIT_ASKPASS="/bin/echo"
850
+
851
+ # Get the current remote URL
852
+ REMOTE_URL=$(git remote get-url {remote_name} 2>/dev/null || echo "")
853
+
854
+ # Check if it's a GitHub URL
855
+ if [[ "$REMOTE_URL" == *"github.com"* ]]; then
856
+ # Extract the repo path from the URL
857
+ REPO_PATH=$(echo "$REMOTE_URL" | sed -E 's|https://[^/]*github\.com/||' | sed -E 's|git@github\.com:||' | sed 's|\.git$||')
858
+
859
+ # Set the remote URL with the token
860
+ git remote set-url {remote_name} "https://{github_username}:{github_token}@github.com/$REPO_PATH.git"
861
+ fi
862
+
863
+ # Execute the original git command with credential helpers disabled
864
+ exec git -c credential.helper= -c credential.useHttpPath=false {' '.join(command[1:])}
865
+ """)
866
+
867
+ # Make the script executable
868
+ os.chmod(script_path, 0o755)
869
+
870
+ # Modify the command to use the script
871
+ command = ["bash", script_path]
872
+
873
+ # Merge git environment with sandbox environment
874
+ final_env = sandbox_env.copy()
875
+ final_env.update(git_env)
876
+
877
+ # Prepare the sandbox command with git environment
878
+ env_args = [f"{key}={value}" for key, value in final_env.items()]
879
+
880
+ sandbox_cmd = ["env", "-i"]
881
+ sandbox_cmd.extend(env_args)
882
+ sandbox_cmd.extend([
883
+ "sandbox-exec",
884
+ "-f", temp_profile_path
885
+ ])
886
+ sandbox_cmd.extend(command)
887
+
888
+ return sandbox_cmd
889
+
890
+ async def execute_shell(self, command: List[str], timeout: int = 10, workdir: Optional[str] = None) -> Dict[str, Any]:
891
+ """
892
+ Execute a shell command securely within a sandbox and return the result.
893
+
894
+ Args:
895
+ command: List of command parts to execute
896
+ timeout: Maximum execution time in seconds
897
+ workdir: Working directory for command execution
898
+
899
+ Returns:
900
+ Dictionary containing execution results
901
+ """
902
+ if self.logger:
903
+ self.logger.debug("Executing shell command in sandbox: %s", " ".join(command))
904
+
905
+ print("#########################<Bash>#########################")
906
+ print(f"{COLOR['BLUE']}>{command}{COLOR['ENDC']}")
907
+
908
+ # Check if the command is safe
909
+ safety_check = self.is_safe_command(command)
910
+ if not safety_check["safe"]:
911
+ response = {
912
+ "stdout": "",
913
+ "stderr": f"Command rejected for security reasons: {safety_check['reason']}",
914
+ "exit_code": 1
915
+ }
916
+ print(f"{COLOR['RED']}{response['stderr']}{COLOR['ENDC']}")
917
+ return response
918
+
919
+ try:
920
+ # Special handling for git commands
921
+ if len(command) > 0 and command[0] == "git":
922
+ sandbox_cmd = await self._prepare_git_sandbox_command(command)
923
+ temp_dir = getattr(self, '_temp_git_dir', None)
924
+
925
+ # Special handling for bash login shell to avoid profile loading errors
926
+ elif len(command) >= 3 and command[0] == "bash" and command[1] == "-lc":
927
+ # Get sandbox environment and add bash-specific variables
928
+ bash_env = self._get_sandbox_environment()
929
+ bash_env.update({
930
+ "BASH_ENV": "/dev/null",
931
+ "ENV": "/dev/null",
932
+ "BASH_PROFILE": "/dev/null",
933
+ "PROFILE": "/dev/null",
934
+ })
935
+
936
+ env_args = [f"{key}={value}" for key, value in bash_env.items()]
937
+
938
+ sandbox_cmd = ["env", "-i"]
939
+ sandbox_cmd.extend(env_args)
940
+ sandbox_cmd.extend([
941
+ "sandbox-exec",
942
+ "-f", self.seatbelt_profile_path,
943
+ "bash", "-c", command[2]
944
+ ])
945
+ temp_dir = None
946
+
947
+ # Determine if command needs shell wrapper
948
+ elif self._needs_shell_wrapper(command):
949
+ # Commands that need shell interpretation
950
+ sandbox_cmd = [
951
+ "sandbox-exec",
952
+ "-f", self.seatbelt_profile_path,
953
+ "bash", "-c", " ".join(command)
954
+ ]
955
+ temp_dir = None
956
+ else:
957
+ # Commands that can run directly
958
+ sandbox_cmd = [
959
+ "sandbox-exec",
960
+ "-f", self.seatbelt_profile_path
961
+ ]
962
+ sandbox_cmd.extend(command)
963
+ temp_dir = None
964
+
965
+ # Set working directory
966
+ cwd = workdir if workdir else os.getcwd()
967
+
968
+ # Get the complete environment for the sandbox
969
+ sandbox_env = self._get_sandbox_environment()
970
+
971
+ # Execute the command
972
+ process = await asyncio.create_subprocess_exec(
973
+ *sandbox_cmd,
974
+ stdout=asyncio.subprocess.PIPE,
975
+ stderr=asyncio.subprocess.PIPE,
976
+ cwd=cwd,
977
+ env=sandbox_env
978
+ )
979
+
980
+ try:
981
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
982
+
983
+ # Decode and strip ANSI color codes from stdout and stderr
984
+ stdout_text = stdout.decode('utf-8', errors='replace')
985
+ stderr_text = stderr.decode('utf-8', errors='replace')
986
+
987
+ # Strip ANSI color codes to make output more readable
988
+ clean_stdout = strip_ansi_codes(stdout_text)
989
+ clean_stderr = strip_ansi_codes(stderr_text)
990
+
991
+ result = {
992
+ "stdout": clean_stdout,
993
+ "stderr": clean_stderr,
994
+ "exit_code": process.returncode
995
+ }
996
+
997
+ # For display purposes, show the original output with colors
998
+ print(f"{COLOR['GREEN']}{{\"stdout\": \"{stdout_text}\", \"stderr\": \"{stderr_text}\", \"exit_code\": {process.returncode}}}{COLOR['ENDC']}")
999
+ return result
1000
+
1001
+ except asyncio.TimeoutError:
1002
+ process.kill()
1003
+ response = {
1004
+ "stdout": "",
1005
+ "stderr": f"Command timed out after {timeout} seconds",
1006
+ "exit_code": 124 # 124 is the exit code for timeout in timeout command
1007
+ }
1008
+ print(f"{COLOR['RED']}{response['stderr']}{COLOR['ENDC']}")
1009
+ return response
1010
+
1011
+ finally:
1012
+ # Clean up git temporary directory if it was created
1013
+ if temp_dir and hasattr(self, '_temp_git_dir'):
1014
+ try:
1015
+ import shutil
1016
+ shutil.rmtree(temp_dir, ignore_errors=True)
1017
+ delattr(self, '_temp_git_dir')
1018
+ except Exception:
1019
+ pass
1020
+
1021
+ except Exception as e:
1022
+ if self.logger:
1023
+ self.logger.error("Error executing shell command in sandbox: %s", str(e))
1024
+ response = {
1025
+ "stdout": "",
1026
+ "stderr": f"Error executing command: {str(e)}",
1027
+ "exit_code": 1
1028
+ }
1029
+ print(f"{COLOR['RED']}{response['stderr']}{COLOR['ENDC']}")
1030
+ return response
1031
+
1032
+ @classmethod
1033
+ def is_supported(cls) -> bool:
1034
+ """
1035
+ Check if the current system supports seatbelt sandboxing.
1036
+
1037
+ Returns:
1038
+ True if the system supports seatbelt (macOS), False otherwise
1039
+ """
1040
+ if platform.system() != "Darwin":
1041
+ return False
1042
+
1043
+ # Check if sandbox-exec exists
1044
+ try:
1045
+ subprocess.run(["which", "sandbox-exec"], check=True, capture_output=True)
1046
+ return True
1047
+ except subprocess.CalledProcessError:
1048
+ return False
1049
+
1050
+ async def cleanup(self):
1051
+ """Clean up any resources used by the provider."""
1052
+ # Reset state
1053
+ self.executed_default_codes = False
1054
+ self._globals_dict = {}
1055
+ self._locals_dict = {}
1056
+
1057
+ # Remove temporary seatbelt profile file if we created one
1058
+ if self.seatbelt_profile and self.seatbelt_profile_path and os.path.exists(self.seatbelt_profile_path):
1059
+ try:
1060
+ os.unlink(self.seatbelt_profile_path)
1061
+ if self.logger:
1062
+ self.logger.debug("Removed temporary seatbelt profile: %s", self.seatbelt_profile_path)
1063
+ except Exception as e:
1064
+ if self.logger:
1065
+ self.logger.warning("Failed to remove temporary seatbelt profile: %s", str(e))