tinyagent-py 0.0.15__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.
- tinyagent/code_agent/providers/__init__.py +14 -1
- tinyagent/code_agent/providers/base.py +29 -1
- tinyagent/code_agent/providers/modal_provider.py +9 -0
- tinyagent/code_agent/providers/seatbelt_provider.py +1065 -0
- tinyagent/code_agent/tiny_code_agent.py +674 -5
- tinyagent/code_agent/utils.py +187 -22
- tinyagent/prompts/truncation.yaml +13 -0
- tinyagent/tiny_agent.py +402 -49
- {tinyagent_py-0.0.15.dist-info → tinyagent_py-0.0.16.dist-info}/METADATA +25 -1
- {tinyagent_py-0.0.15.dist-info → tinyagent_py-0.0.16.dist-info}/RECORD +13 -11
- {tinyagent_py-0.0.15.dist-info → tinyagent_py-0.0.16.dist-info}/WHEEL +0 -0
- {tinyagent_py-0.0.15.dist-info → tinyagent_py-0.0.16.dist-info}/licenses/LICENSE +0 -0
- {tinyagent_py-0.0.15.dist-info → tinyagent_py-0.0.16.dist-info}/top_level.txt +0 -0
@@ -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))
|