amd-gaia 0.15.0__py3-none-any.whl → 0.15.1__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.
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/METADATA +223 -223
- amd_gaia-0.15.1.dist-info/RECORD +178 -0
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/entry_points.txt +1 -0
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/licenses/LICENSE.md +20 -20
- gaia/__init__.py +29 -29
- gaia/agents/__init__.py +19 -19
- gaia/agents/base/__init__.py +9 -9
- gaia/agents/base/agent.py +2177 -2177
- gaia/agents/base/api_agent.py +120 -120
- gaia/agents/base/console.py +1841 -1841
- gaia/agents/base/errors.py +237 -237
- gaia/agents/base/mcp_agent.py +86 -86
- gaia/agents/base/tools.py +83 -83
- gaia/agents/blender/agent.py +556 -556
- gaia/agents/blender/agent_simple.py +133 -135
- gaia/agents/blender/app.py +211 -211
- gaia/agents/blender/app_simple.py +41 -41
- gaia/agents/blender/core/__init__.py +16 -16
- gaia/agents/blender/core/materials.py +506 -506
- gaia/agents/blender/core/objects.py +316 -316
- gaia/agents/blender/core/rendering.py +225 -225
- gaia/agents/blender/core/scene.py +220 -220
- gaia/agents/blender/core/view.py +146 -146
- gaia/agents/chat/__init__.py +9 -9
- gaia/agents/chat/agent.py +835 -835
- gaia/agents/chat/app.py +1058 -1058
- gaia/agents/chat/session.py +508 -508
- gaia/agents/chat/tools/__init__.py +15 -15
- gaia/agents/chat/tools/file_tools.py +96 -96
- gaia/agents/chat/tools/rag_tools.py +1729 -1729
- gaia/agents/chat/tools/shell_tools.py +436 -436
- gaia/agents/code/__init__.py +7 -7
- gaia/agents/code/agent.py +549 -549
- gaia/agents/code/cli.py +377 -0
- gaia/agents/code/models.py +135 -135
- gaia/agents/code/orchestration/__init__.py +24 -24
- gaia/agents/code/orchestration/checklist_executor.py +1763 -1763
- gaia/agents/code/orchestration/checklist_generator.py +713 -713
- gaia/agents/code/orchestration/factories/__init__.py +9 -9
- gaia/agents/code/orchestration/factories/base.py +63 -63
- gaia/agents/code/orchestration/factories/nextjs_factory.py +118 -118
- gaia/agents/code/orchestration/factories/python_factory.py +106 -106
- gaia/agents/code/orchestration/orchestrator.py +841 -841
- gaia/agents/code/orchestration/project_analyzer.py +391 -391
- gaia/agents/code/orchestration/steps/__init__.py +67 -67
- gaia/agents/code/orchestration/steps/base.py +188 -188
- gaia/agents/code/orchestration/steps/error_handler.py +314 -314
- gaia/agents/code/orchestration/steps/nextjs.py +828 -828
- gaia/agents/code/orchestration/steps/python.py +307 -307
- gaia/agents/code/orchestration/template_catalog.py +469 -469
- gaia/agents/code/orchestration/workflows/__init__.py +14 -14
- gaia/agents/code/orchestration/workflows/base.py +80 -80
- gaia/agents/code/orchestration/workflows/nextjs.py +186 -186
- gaia/agents/code/orchestration/workflows/python.py +94 -94
- gaia/agents/code/prompts/__init__.py +11 -11
- gaia/agents/code/prompts/base_prompt.py +77 -77
- gaia/agents/code/prompts/code_patterns.py +2036 -2036
- gaia/agents/code/prompts/nextjs_prompt.py +40 -40
- gaia/agents/code/prompts/python_prompt.py +109 -109
- gaia/agents/code/schema_inference.py +365 -365
- gaia/agents/code/system_prompt.py +41 -41
- gaia/agents/code/tools/__init__.py +42 -42
- gaia/agents/code/tools/cli_tools.py +1138 -1138
- gaia/agents/code/tools/code_formatting.py +319 -319
- gaia/agents/code/tools/code_tools.py +769 -769
- gaia/agents/code/tools/error_fixing.py +1347 -1347
- gaia/agents/code/tools/external_tools.py +180 -180
- gaia/agents/code/tools/file_io.py +845 -845
- gaia/agents/code/tools/prisma_tools.py +190 -190
- gaia/agents/code/tools/project_management.py +1016 -1016
- gaia/agents/code/tools/testing.py +321 -321
- gaia/agents/code/tools/typescript_tools.py +122 -122
- gaia/agents/code/tools/validation_parsing.py +461 -461
- gaia/agents/code/tools/validation_tools.py +806 -806
- gaia/agents/code/tools/web_dev_tools.py +1758 -1758
- gaia/agents/code/validators/__init__.py +16 -16
- gaia/agents/code/validators/antipattern_checker.py +241 -241
- gaia/agents/code/validators/ast_analyzer.py +197 -197
- gaia/agents/code/validators/requirements_validator.py +145 -145
- gaia/agents/code/validators/syntax_validator.py +171 -171
- gaia/agents/docker/__init__.py +7 -7
- gaia/agents/docker/agent.py +642 -642
- gaia/agents/emr/__init__.py +8 -8
- gaia/agents/emr/agent.py +1506 -1506
- gaia/agents/emr/cli.py +1322 -1322
- gaia/agents/emr/constants.py +475 -475
- gaia/agents/emr/dashboard/__init__.py +4 -4
- gaia/agents/emr/dashboard/server.py +1974 -1974
- gaia/agents/jira/__init__.py +11 -11
- gaia/agents/jira/agent.py +894 -894
- gaia/agents/jira/jql_templates.py +299 -299
- gaia/agents/routing/__init__.py +7 -7
- gaia/agents/routing/agent.py +567 -570
- gaia/agents/routing/system_prompt.py +75 -75
- gaia/agents/summarize/__init__.py +11 -0
- gaia/agents/summarize/agent.py +885 -0
- gaia/agents/summarize/prompts.py +129 -0
- gaia/api/__init__.py +23 -23
- gaia/api/agent_registry.py +238 -238
- gaia/api/app.py +305 -305
- gaia/api/openai_server.py +575 -575
- gaia/api/schemas.py +186 -186
- gaia/api/sse_handler.py +373 -373
- gaia/apps/__init__.py +4 -4
- gaia/apps/llm/__init__.py +6 -6
- gaia/apps/llm/app.py +173 -169
- gaia/apps/summarize/app.py +116 -633
- gaia/apps/summarize/html_viewer.py +133 -133
- gaia/apps/summarize/pdf_formatter.py +284 -284
- gaia/audio/__init__.py +2 -2
- gaia/audio/audio_client.py +439 -439
- gaia/audio/audio_recorder.py +269 -269
- gaia/audio/kokoro_tts.py +599 -599
- gaia/audio/whisper_asr.py +432 -432
- gaia/chat/__init__.py +16 -16
- gaia/chat/app.py +430 -430
- gaia/chat/prompts.py +522 -522
- gaia/chat/sdk.py +1228 -1225
- gaia/cli.py +5481 -5632
- gaia/database/__init__.py +10 -10
- gaia/database/agent.py +176 -176
- gaia/database/mixin.py +290 -290
- gaia/database/testing.py +64 -64
- gaia/eval/batch_experiment.py +2332 -2332
- gaia/eval/claude.py +542 -542
- gaia/eval/config.py +37 -37
- gaia/eval/email_generator.py +512 -512
- gaia/eval/eval.py +3179 -3179
- gaia/eval/groundtruth.py +1130 -1130
- gaia/eval/transcript_generator.py +582 -582
- gaia/eval/webapp/README.md +167 -167
- gaia/eval/webapp/package-lock.json +875 -875
- gaia/eval/webapp/package.json +20 -20
- gaia/eval/webapp/public/app.js +3402 -3402
- gaia/eval/webapp/public/index.html +87 -87
- gaia/eval/webapp/public/styles.css +3661 -3661
- gaia/eval/webapp/server.js +415 -415
- gaia/eval/webapp/test-setup.js +72 -72
- gaia/llm/__init__.py +9 -2
- gaia/llm/base_client.py +60 -0
- gaia/llm/exceptions.py +12 -0
- gaia/llm/factory.py +70 -0
- gaia/llm/lemonade_client.py +3236 -3221
- gaia/llm/lemonade_manager.py +294 -294
- gaia/llm/providers/__init__.py +9 -0
- gaia/llm/providers/claude.py +108 -0
- gaia/llm/providers/lemonade.py +120 -0
- gaia/llm/providers/openai_provider.py +79 -0
- gaia/llm/vlm_client.py +382 -382
- gaia/logger.py +189 -189
- gaia/mcp/agent_mcp_server.py +245 -245
- gaia/mcp/blender_mcp_client.py +138 -138
- gaia/mcp/blender_mcp_server.py +648 -648
- gaia/mcp/context7_cache.py +332 -332
- gaia/mcp/external_services.py +518 -518
- gaia/mcp/mcp_bridge.py +811 -550
- gaia/mcp/servers/__init__.py +6 -6
- gaia/mcp/servers/docker_mcp.py +83 -83
- gaia/perf_analysis.py +361 -0
- gaia/rag/__init__.py +10 -10
- gaia/rag/app.py +293 -293
- gaia/rag/demo.py +304 -304
- gaia/rag/pdf_utils.py +235 -235
- gaia/rag/sdk.py +2194 -2194
- gaia/security.py +163 -163
- gaia/talk/app.py +289 -289
- gaia/talk/sdk.py +538 -538
- gaia/testing/__init__.py +87 -87
- gaia/testing/assertions.py +330 -330
- gaia/testing/fixtures.py +333 -333
- gaia/testing/mocks.py +493 -493
- gaia/util.py +46 -46
- gaia/utils/__init__.py +33 -33
- gaia/utils/file_watcher.py +675 -675
- gaia/utils/parsing.py +223 -223
- gaia/version.py +100 -100
- amd_gaia-0.15.0.dist-info/RECORD +0 -168
- gaia/agents/code/app.py +0 -266
- gaia/llm/llm_client.py +0 -723
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/WHEEL +0 -0
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.1.dist-info}/top_level.txt +0 -0
|
@@ -1,436 +1,436 @@
|
|
|
1
|
-
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
-
# SPDX-License-Identifier: MIT
|
|
3
|
-
"""
|
|
4
|
-
Shell Tools Mixin for Chat Agent.
|
|
5
|
-
|
|
6
|
-
Provides shell command execution capabilities for file operations and system queries.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import logging
|
|
10
|
-
import os
|
|
11
|
-
import shlex
|
|
12
|
-
import subprocess
|
|
13
|
-
import time
|
|
14
|
-
from collections import deque
|
|
15
|
-
from datetime import datetime
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Any, Dict, Optional
|
|
18
|
-
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class ShellToolsMixin:
|
|
23
|
-
"""
|
|
24
|
-
Mixin providing shell command execution tools with rate limiting.
|
|
25
|
-
|
|
26
|
-
Tools provided:
|
|
27
|
-
- run_shell_command: Execute terminal commands with timeout and safety checks
|
|
28
|
-
|
|
29
|
-
Rate Limiting:
|
|
30
|
-
- Max 10 commands per minute to prevent DOS
|
|
31
|
-
- Max 3 commands per 10 seconds for burst prevention
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
def __init__(self, *args, **kwargs):
|
|
35
|
-
"""Initialize shell tools with rate limiting."""
|
|
36
|
-
super().__init__(*args, **kwargs)
|
|
37
|
-
|
|
38
|
-
# Rate limiting configuration
|
|
39
|
-
self.shell_command_times = deque(maxlen=100) # Track last 100 command times
|
|
40
|
-
self.max_commands_per_minute = 10
|
|
41
|
-
self.max_commands_per_10_seconds = 3
|
|
42
|
-
|
|
43
|
-
def _check_rate_limit(self) -> tuple:
|
|
44
|
-
"""
|
|
45
|
-
Check if rate limit allows another command.
|
|
46
|
-
|
|
47
|
-
Returns:
|
|
48
|
-
(allowed: bool, reason: str, wait_time: float)
|
|
49
|
-
"""
|
|
50
|
-
# Initialize if not already done (defensive programming)
|
|
51
|
-
if not hasattr(self, "shell_command_times"):
|
|
52
|
-
self.shell_command_times = deque(maxlen=100)
|
|
53
|
-
self.max_commands_per_minute = 10
|
|
54
|
-
self.max_commands_per_10_seconds = 3
|
|
55
|
-
|
|
56
|
-
current_time = time.time()
|
|
57
|
-
|
|
58
|
-
# Remove old timestamps outside the window
|
|
59
|
-
minute_ago = current_time - 60
|
|
60
|
-
ten_sec_ago = current_time - 10
|
|
61
|
-
|
|
62
|
-
# Count recent commands
|
|
63
|
-
recent_minute = sum(1 for t in self.shell_command_times if t > minute_ago)
|
|
64
|
-
recent_10_sec = sum(1 for t in self.shell_command_times if t > ten_sec_ago)
|
|
65
|
-
|
|
66
|
-
# Check 10-second burst limit
|
|
67
|
-
if recent_10_sec >= self.max_commands_per_10_seconds:
|
|
68
|
-
recent_times = [t for t in self.shell_command_times if t > ten_sec_ago]
|
|
69
|
-
if recent_times:
|
|
70
|
-
oldest_in_window = min(recent_times)
|
|
71
|
-
wait_time = 10 - (current_time - oldest_in_window)
|
|
72
|
-
else:
|
|
73
|
-
wait_time = 10.0
|
|
74
|
-
return (
|
|
75
|
-
False,
|
|
76
|
-
f"Rate limit: max {self.max_commands_per_10_seconds} commands per 10 seconds. Wait {wait_time:.1f}s",
|
|
77
|
-
wait_time,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
# Check 1-minute limit
|
|
81
|
-
if recent_minute >= self.max_commands_per_minute:
|
|
82
|
-
recent_times = [t for t in self.shell_command_times if t > minute_ago]
|
|
83
|
-
if recent_times:
|
|
84
|
-
oldest_in_window = min(recent_times)
|
|
85
|
-
wait_time = 60 - (current_time - oldest_in_window)
|
|
86
|
-
else:
|
|
87
|
-
wait_time = 60.0
|
|
88
|
-
return (
|
|
89
|
-
False,
|
|
90
|
-
f"Rate limit: max {self.max_commands_per_minute} commands per minute. Wait {wait_time:.1f}s",
|
|
91
|
-
wait_time,
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
return True, "", 0.0
|
|
95
|
-
|
|
96
|
-
def _record_command_execution(self):
|
|
97
|
-
"""Record command execution timestamp for rate limiting."""
|
|
98
|
-
self.shell_command_times.append(time.time())
|
|
99
|
-
|
|
100
|
-
def register_shell_tools(self) -> None:
|
|
101
|
-
"""Register shell command execution tools."""
|
|
102
|
-
from gaia.agents.base.tools import tool
|
|
103
|
-
|
|
104
|
-
@tool(
|
|
105
|
-
name="run_shell_command",
|
|
106
|
-
description="Execute a shell/terminal command. Useful for listing directories (ls/dir), checking files (cat, stat), finding files (find), text processing (grep, head, tail), and navigation (pwd).",
|
|
107
|
-
parameters={
|
|
108
|
-
"command": {
|
|
109
|
-
"type": "str",
|
|
110
|
-
"description": "The shell command to execute (e.g., 'ls -la', 'pwd', 'cat file.txt')",
|
|
111
|
-
"required": True,
|
|
112
|
-
},
|
|
113
|
-
"working_directory": {
|
|
114
|
-
"type": "str",
|
|
115
|
-
"description": "Directory to run the command in (defaults to current directory)",
|
|
116
|
-
"required": False,
|
|
117
|
-
},
|
|
118
|
-
"timeout": {
|
|
119
|
-
"type": "int",
|
|
120
|
-
"description": "Timeout in seconds (default: 30)",
|
|
121
|
-
"required": False,
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
)
|
|
125
|
-
def run_shell_command(
|
|
126
|
-
command: str, working_directory: Optional[str] = None, timeout: int = 30
|
|
127
|
-
) -> Dict[str, Any]:
|
|
128
|
-
"""
|
|
129
|
-
Execute a shell command and return the output.
|
|
130
|
-
|
|
131
|
-
Args:
|
|
132
|
-
command: Shell command to execute
|
|
133
|
-
working_directory: Directory to run command in
|
|
134
|
-
timeout: Maximum execution time in seconds
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
Dictionary with status, output, and error information
|
|
138
|
-
"""
|
|
139
|
-
try:
|
|
140
|
-
# Check rate limits first to prevent DOS
|
|
141
|
-
allowed, reason, wait_time = self._check_rate_limit()
|
|
142
|
-
if not allowed:
|
|
143
|
-
return {
|
|
144
|
-
"status": "error",
|
|
145
|
-
"error": f"{reason}. Please wait {wait_time:.1f} seconds.",
|
|
146
|
-
"has_errors": True,
|
|
147
|
-
"rate_limited": True,
|
|
148
|
-
"wait_time_seconds": wait_time,
|
|
149
|
-
"hint": "Rate limiting prevents excessive command execution",
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
# Validate working directory if specified
|
|
153
|
-
if working_directory:
|
|
154
|
-
if not os.path.exists(working_directory):
|
|
155
|
-
return {
|
|
156
|
-
"status": "error",
|
|
157
|
-
"error": f"Working directory not found: {working_directory}",
|
|
158
|
-
"has_errors": True,
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if not os.path.isdir(working_directory):
|
|
162
|
-
return {
|
|
163
|
-
"status": "error",
|
|
164
|
-
"error": f"Path is not a directory: {working_directory}",
|
|
165
|
-
"has_errors": True,
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
# Validate path is allowed
|
|
169
|
-
# Use PathValidator if available (ChatAgent), otherwise fallback or skip
|
|
170
|
-
if hasattr(self, "path_validator"):
|
|
171
|
-
if not self.path_validator.is_path_allowed(working_directory):
|
|
172
|
-
return {
|
|
173
|
-
"status": "error",
|
|
174
|
-
"error": f"Access denied: {working_directory} is not in allowed paths",
|
|
175
|
-
"has_errors": True,
|
|
176
|
-
}
|
|
177
|
-
elif hasattr(self, "_is_path_allowed"):
|
|
178
|
-
# Backward compatibility
|
|
179
|
-
if not self._is_path_allowed(working_directory):
|
|
180
|
-
return {
|
|
181
|
-
"status": "error",
|
|
182
|
-
"error": f"Access denied: {working_directory} is not in allowed paths",
|
|
183
|
-
"has_errors": True,
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
cwd = str(Path(working_directory).resolve())
|
|
187
|
-
else:
|
|
188
|
-
cwd = str(Path.cwd())
|
|
189
|
-
|
|
190
|
-
# Parse command safely
|
|
191
|
-
try:
|
|
192
|
-
cmd_parts = shlex.split(command)
|
|
193
|
-
except ValueError as e:
|
|
194
|
-
return {
|
|
195
|
-
"status": "error",
|
|
196
|
-
"error": f"Invalid command syntax: {e}",
|
|
197
|
-
"has_errors": True,
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if not cmd_parts:
|
|
201
|
-
return {
|
|
202
|
-
"status": "error",
|
|
203
|
-
"error": "Empty command",
|
|
204
|
-
"has_errors": True,
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
# Validate arguments for path traversal
|
|
208
|
-
# This prevents "cat ../secret.txt" even if "cat" is allowed
|
|
209
|
-
if hasattr(self, "path_validator"):
|
|
210
|
-
for arg in cmd_parts[1:]:
|
|
211
|
-
# Skip flags that don't look like paths (simple heuristics)
|
|
212
|
-
# We check for path separators or ".."
|
|
213
|
-
# We also handle --flag=/path/to/file
|
|
214
|
-
|
|
215
|
-
candidate_path = arg
|
|
216
|
-
if arg.startswith("-"):
|
|
217
|
-
if "=" in arg:
|
|
218
|
-
_, candidate_path = arg.split("=", 1)
|
|
219
|
-
else:
|
|
220
|
-
# Skip flags without value (e.g. -l, --verbose)
|
|
221
|
-
# But what about -f/path? Hard to parse without knowing the tool.
|
|
222
|
-
# We'll assume if it has a path separator, it might be a path attached to a flag
|
|
223
|
-
if os.sep not in arg and "/" not in arg:
|
|
224
|
-
continue
|
|
225
|
-
# If it has separators, treat the whole thing or part of it as path?
|
|
226
|
-
# Treating "-f/tmp" as a path "/tmp" is hard.
|
|
227
|
-
# Let's be conservative: if it contains separators, check it.
|
|
228
|
-
|
|
229
|
-
# Check if it looks like a path
|
|
230
|
-
if (
|
|
231
|
-
os.sep in candidate_path
|
|
232
|
-
or "/" in candidate_path
|
|
233
|
-
or ".." in candidate_path
|
|
234
|
-
):
|
|
235
|
-
# Ignore URLs
|
|
236
|
-
if candidate_path.startswith(
|
|
237
|
-
("http://", "https://", "git://", "ssh://")
|
|
238
|
-
):
|
|
239
|
-
continue
|
|
240
|
-
|
|
241
|
-
# Resolve path relative to CWD
|
|
242
|
-
try:
|
|
243
|
-
# Handle potential flag prefix if we didn't split it cleanly
|
|
244
|
-
# This is best-effort.
|
|
245
|
-
clean_path = candidate_path
|
|
246
|
-
|
|
247
|
-
# Resolve
|
|
248
|
-
resolved_path = str(
|
|
249
|
-
Path(cwd).joinpath(clean_path).resolve()
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
if not self.path_validator.is_path_allowed(
|
|
253
|
-
resolved_path
|
|
254
|
-
):
|
|
255
|
-
return {
|
|
256
|
-
"status": "error",
|
|
257
|
-
"error": f"Access denied: Argument '{arg}' resolves to forbidden path '{resolved_path}'",
|
|
258
|
-
"has_errors": True,
|
|
259
|
-
}
|
|
260
|
-
except Exception:
|
|
261
|
-
# If we can't resolve it (e.g. invalid chars), we might warn or ignore.
|
|
262
|
-
# For security, maybe ignore if it's not a valid path anyway?
|
|
263
|
-
pass
|
|
264
|
-
|
|
265
|
-
# Security: WHITELIST approach - only allow explicitly safe commands
|
|
266
|
-
# This is much safer than a blacklist which always misses dangerous commands
|
|
267
|
-
ALLOWED_COMMANDS = {
|
|
268
|
-
# File listing and navigation (READ-ONLY)
|
|
269
|
-
"ls",
|
|
270
|
-
"dir",
|
|
271
|
-
"pwd",
|
|
272
|
-
"cd",
|
|
273
|
-
# File content viewing (READ-ONLY)
|
|
274
|
-
"cat",
|
|
275
|
-
"head",
|
|
276
|
-
"tail",
|
|
277
|
-
"more",
|
|
278
|
-
"less",
|
|
279
|
-
# Text processing (READ-ONLY)
|
|
280
|
-
"grep",
|
|
281
|
-
"find",
|
|
282
|
-
"wc",
|
|
283
|
-
"sort",
|
|
284
|
-
"uniq",
|
|
285
|
-
"diff",
|
|
286
|
-
# File information (READ-ONLY)
|
|
287
|
-
"file",
|
|
288
|
-
"stat",
|
|
289
|
-
"du",
|
|
290
|
-
"df",
|
|
291
|
-
# System information (READ-ONLY)
|
|
292
|
-
"whoami",
|
|
293
|
-
"hostname",
|
|
294
|
-
"uname",
|
|
295
|
-
"date",
|
|
296
|
-
"uptime",
|
|
297
|
-
# Path utilities
|
|
298
|
-
"which",
|
|
299
|
-
"whereis",
|
|
300
|
-
"basename",
|
|
301
|
-
"dirname",
|
|
302
|
-
# Safe output
|
|
303
|
-
"echo",
|
|
304
|
-
"printf",
|
|
305
|
-
# Process information (READ-ONLY)
|
|
306
|
-
"ps",
|
|
307
|
-
"top",
|
|
308
|
-
"jobs",
|
|
309
|
-
# Git commands (mostly safe, read-only operations)
|
|
310
|
-
"git", # Individual git subcommands checked separately
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
cmd_base = cmd_parts[0].lower()
|
|
314
|
-
|
|
315
|
-
# Special handling for git - only allow read-only operations
|
|
316
|
-
if cmd_base == "git":
|
|
317
|
-
if len(cmd_parts) > 1:
|
|
318
|
-
git_subcmd = cmd_parts[1].lower()
|
|
319
|
-
safe_git_commands = {
|
|
320
|
-
"status",
|
|
321
|
-
"log",
|
|
322
|
-
"show",
|
|
323
|
-
"diff",
|
|
324
|
-
"branch",
|
|
325
|
-
"remote",
|
|
326
|
-
"ls-files",
|
|
327
|
-
"ls-tree",
|
|
328
|
-
"describe",
|
|
329
|
-
"rev-parse",
|
|
330
|
-
"config",
|
|
331
|
-
"help",
|
|
332
|
-
}
|
|
333
|
-
if git_subcmd not in safe_git_commands:
|
|
334
|
-
return {
|
|
335
|
-
"status": "error",
|
|
336
|
-
"error": f"Git command '{git_subcmd}' is not allowed. Only read-only git operations are permitted.",
|
|
337
|
-
"has_errors": True,
|
|
338
|
-
"allowed_git_commands": list(safe_git_commands),
|
|
339
|
-
}
|
|
340
|
-
elif cmd_base not in ALLOWED_COMMANDS:
|
|
341
|
-
return {
|
|
342
|
-
"status": "error",
|
|
343
|
-
"error": f"Command '{cmd_base}' is not in the allowed list for security reasons",
|
|
344
|
-
"has_errors": True,
|
|
345
|
-
"hint": "Only read-only, informational commands are allowed",
|
|
346
|
-
"examples": "ls, cat, grep, find, git status, etc.",
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
# Log command execution (debug mode)
|
|
350
|
-
if hasattr(self, "debug") and self.debug:
|
|
351
|
-
logger.info(f"Executing command: {command} in {cwd}")
|
|
352
|
-
|
|
353
|
-
# Execute command
|
|
354
|
-
start_time = datetime.utcnow()
|
|
355
|
-
try:
|
|
356
|
-
result = subprocess.run(
|
|
357
|
-
cmd_parts,
|
|
358
|
-
cwd=cwd,
|
|
359
|
-
capture_output=True,
|
|
360
|
-
text=True,
|
|
361
|
-
timeout=timeout,
|
|
362
|
-
check=False,
|
|
363
|
-
env=os.environ.copy(),
|
|
364
|
-
)
|
|
365
|
-
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
366
|
-
|
|
367
|
-
# Record successful command execution for rate limiting
|
|
368
|
-
self._record_command_execution()
|
|
369
|
-
except subprocess.TimeoutExpired as exc:
|
|
370
|
-
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
371
|
-
|
|
372
|
-
# Handle timeout gracefully
|
|
373
|
-
stdout_str = ""
|
|
374
|
-
stderr_str = ""
|
|
375
|
-
if exc.stdout:
|
|
376
|
-
stdout_str = (
|
|
377
|
-
exc.stdout
|
|
378
|
-
if isinstance(exc.stdout, str)
|
|
379
|
-
else exc.stdout.decode("utf-8", errors="replace")
|
|
380
|
-
)
|
|
381
|
-
if exc.stderr:
|
|
382
|
-
stderr_str = (
|
|
383
|
-
exc.stderr
|
|
384
|
-
if isinstance(exc.stderr, str)
|
|
385
|
-
else exc.stderr.decode("utf-8", errors="replace")
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
return {
|
|
389
|
-
"status": "error",
|
|
390
|
-
"error": f"Command timed out after {timeout} seconds",
|
|
391
|
-
"command": command,
|
|
392
|
-
"stdout": stdout_str,
|
|
393
|
-
"stderr": stderr_str,
|
|
394
|
-
"has_errors": True,
|
|
395
|
-
"timed_out": True,
|
|
396
|
-
"timeout": timeout,
|
|
397
|
-
"duration_seconds": duration,
|
|
398
|
-
"cwd": cwd,
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
# Capture and truncate output if too long
|
|
402
|
-
stdout = result.stdout or ""
|
|
403
|
-
stderr = result.stderr or ""
|
|
404
|
-
truncated = False
|
|
405
|
-
max_output = 10_000
|
|
406
|
-
|
|
407
|
-
if len(stdout) > max_output:
|
|
408
|
-
stdout = stdout[:max_output] + "\n...output truncated (stdout)..."
|
|
409
|
-
truncated = True
|
|
410
|
-
|
|
411
|
-
if len(stderr) > max_output:
|
|
412
|
-
stderr = stderr[:max_output] + "\n...output truncated (stderr)..."
|
|
413
|
-
truncated = True
|
|
414
|
-
|
|
415
|
-
# Debug logging
|
|
416
|
-
if hasattr(self, "debug") and self.debug:
|
|
417
|
-
logger.info(
|
|
418
|
-
f"Command completed in {duration:.2f}s with return code {result.returncode}"
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
return {
|
|
422
|
-
"status": "success",
|
|
423
|
-
"command": command,
|
|
424
|
-
"stdout": stdout,
|
|
425
|
-
"stderr": stderr,
|
|
426
|
-
"return_code": result.returncode,
|
|
427
|
-
"has_errors": result.returncode != 0,
|
|
428
|
-
"duration_seconds": duration,
|
|
429
|
-
"timeout": timeout,
|
|
430
|
-
"cwd": cwd,
|
|
431
|
-
"output_truncated": truncated,
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
except Exception as exc:
|
|
435
|
-
logger.error(f"Error executing shell command: {exc}")
|
|
436
|
-
return {"status": "error", "error": str(exc), "has_errors": True}
|
|
1
|
+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Shell Tools Mixin for Chat Agent.
|
|
5
|
+
|
|
6
|
+
Provides shell command execution capabilities for file operations and system queries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ShellToolsMixin:
|
|
23
|
+
"""
|
|
24
|
+
Mixin providing shell command execution tools with rate limiting.
|
|
25
|
+
|
|
26
|
+
Tools provided:
|
|
27
|
+
- run_shell_command: Execute terminal commands with timeout and safety checks
|
|
28
|
+
|
|
29
|
+
Rate Limiting:
|
|
30
|
+
- Max 10 commands per minute to prevent DOS
|
|
31
|
+
- Max 3 commands per 10 seconds for burst prevention
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, *args, **kwargs):
|
|
35
|
+
"""Initialize shell tools with rate limiting."""
|
|
36
|
+
super().__init__(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
# Rate limiting configuration
|
|
39
|
+
self.shell_command_times = deque(maxlen=100) # Track last 100 command times
|
|
40
|
+
self.max_commands_per_minute = 10
|
|
41
|
+
self.max_commands_per_10_seconds = 3
|
|
42
|
+
|
|
43
|
+
def _check_rate_limit(self) -> tuple:
|
|
44
|
+
"""
|
|
45
|
+
Check if rate limit allows another command.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
(allowed: bool, reason: str, wait_time: float)
|
|
49
|
+
"""
|
|
50
|
+
# Initialize if not already done (defensive programming)
|
|
51
|
+
if not hasattr(self, "shell_command_times"):
|
|
52
|
+
self.shell_command_times = deque(maxlen=100)
|
|
53
|
+
self.max_commands_per_minute = 10
|
|
54
|
+
self.max_commands_per_10_seconds = 3
|
|
55
|
+
|
|
56
|
+
current_time = time.time()
|
|
57
|
+
|
|
58
|
+
# Remove old timestamps outside the window
|
|
59
|
+
minute_ago = current_time - 60
|
|
60
|
+
ten_sec_ago = current_time - 10
|
|
61
|
+
|
|
62
|
+
# Count recent commands
|
|
63
|
+
recent_minute = sum(1 for t in self.shell_command_times if t > minute_ago)
|
|
64
|
+
recent_10_sec = sum(1 for t in self.shell_command_times if t > ten_sec_ago)
|
|
65
|
+
|
|
66
|
+
# Check 10-second burst limit
|
|
67
|
+
if recent_10_sec >= self.max_commands_per_10_seconds:
|
|
68
|
+
recent_times = [t for t in self.shell_command_times if t > ten_sec_ago]
|
|
69
|
+
if recent_times:
|
|
70
|
+
oldest_in_window = min(recent_times)
|
|
71
|
+
wait_time = 10 - (current_time - oldest_in_window)
|
|
72
|
+
else:
|
|
73
|
+
wait_time = 10.0
|
|
74
|
+
return (
|
|
75
|
+
False,
|
|
76
|
+
f"Rate limit: max {self.max_commands_per_10_seconds} commands per 10 seconds. Wait {wait_time:.1f}s",
|
|
77
|
+
wait_time,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Check 1-minute limit
|
|
81
|
+
if recent_minute >= self.max_commands_per_minute:
|
|
82
|
+
recent_times = [t for t in self.shell_command_times if t > minute_ago]
|
|
83
|
+
if recent_times:
|
|
84
|
+
oldest_in_window = min(recent_times)
|
|
85
|
+
wait_time = 60 - (current_time - oldest_in_window)
|
|
86
|
+
else:
|
|
87
|
+
wait_time = 60.0
|
|
88
|
+
return (
|
|
89
|
+
False,
|
|
90
|
+
f"Rate limit: max {self.max_commands_per_minute} commands per minute. Wait {wait_time:.1f}s",
|
|
91
|
+
wait_time,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return True, "", 0.0
|
|
95
|
+
|
|
96
|
+
def _record_command_execution(self):
|
|
97
|
+
"""Record command execution timestamp for rate limiting."""
|
|
98
|
+
self.shell_command_times.append(time.time())
|
|
99
|
+
|
|
100
|
+
def register_shell_tools(self) -> None:
|
|
101
|
+
"""Register shell command execution tools."""
|
|
102
|
+
from gaia.agents.base.tools import tool
|
|
103
|
+
|
|
104
|
+
@tool(
|
|
105
|
+
name="run_shell_command",
|
|
106
|
+
description="Execute a shell/terminal command. Useful for listing directories (ls/dir), checking files (cat, stat), finding files (find), text processing (grep, head, tail), and navigation (pwd).",
|
|
107
|
+
parameters={
|
|
108
|
+
"command": {
|
|
109
|
+
"type": "str",
|
|
110
|
+
"description": "The shell command to execute (e.g., 'ls -la', 'pwd', 'cat file.txt')",
|
|
111
|
+
"required": True,
|
|
112
|
+
},
|
|
113
|
+
"working_directory": {
|
|
114
|
+
"type": "str",
|
|
115
|
+
"description": "Directory to run the command in (defaults to current directory)",
|
|
116
|
+
"required": False,
|
|
117
|
+
},
|
|
118
|
+
"timeout": {
|
|
119
|
+
"type": "int",
|
|
120
|
+
"description": "Timeout in seconds (default: 30)",
|
|
121
|
+
"required": False,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
def run_shell_command(
|
|
126
|
+
command: str, working_directory: Optional[str] = None, timeout: int = 30
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""
|
|
129
|
+
Execute a shell command and return the output.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
command: Shell command to execute
|
|
133
|
+
working_directory: Directory to run command in
|
|
134
|
+
timeout: Maximum execution time in seconds
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dictionary with status, output, and error information
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
# Check rate limits first to prevent DOS
|
|
141
|
+
allowed, reason, wait_time = self._check_rate_limit()
|
|
142
|
+
if not allowed:
|
|
143
|
+
return {
|
|
144
|
+
"status": "error",
|
|
145
|
+
"error": f"{reason}. Please wait {wait_time:.1f} seconds.",
|
|
146
|
+
"has_errors": True,
|
|
147
|
+
"rate_limited": True,
|
|
148
|
+
"wait_time_seconds": wait_time,
|
|
149
|
+
"hint": "Rate limiting prevents excessive command execution",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Validate working directory if specified
|
|
153
|
+
if working_directory:
|
|
154
|
+
if not os.path.exists(working_directory):
|
|
155
|
+
return {
|
|
156
|
+
"status": "error",
|
|
157
|
+
"error": f"Working directory not found: {working_directory}",
|
|
158
|
+
"has_errors": True,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if not os.path.isdir(working_directory):
|
|
162
|
+
return {
|
|
163
|
+
"status": "error",
|
|
164
|
+
"error": f"Path is not a directory: {working_directory}",
|
|
165
|
+
"has_errors": True,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Validate path is allowed
|
|
169
|
+
# Use PathValidator if available (ChatAgent), otherwise fallback or skip
|
|
170
|
+
if hasattr(self, "path_validator"):
|
|
171
|
+
if not self.path_validator.is_path_allowed(working_directory):
|
|
172
|
+
return {
|
|
173
|
+
"status": "error",
|
|
174
|
+
"error": f"Access denied: {working_directory} is not in allowed paths",
|
|
175
|
+
"has_errors": True,
|
|
176
|
+
}
|
|
177
|
+
elif hasattr(self, "_is_path_allowed"):
|
|
178
|
+
# Backward compatibility
|
|
179
|
+
if not self._is_path_allowed(working_directory):
|
|
180
|
+
return {
|
|
181
|
+
"status": "error",
|
|
182
|
+
"error": f"Access denied: {working_directory} is not in allowed paths",
|
|
183
|
+
"has_errors": True,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
cwd = str(Path(working_directory).resolve())
|
|
187
|
+
else:
|
|
188
|
+
cwd = str(Path.cwd())
|
|
189
|
+
|
|
190
|
+
# Parse command safely
|
|
191
|
+
try:
|
|
192
|
+
cmd_parts = shlex.split(command)
|
|
193
|
+
except ValueError as e:
|
|
194
|
+
return {
|
|
195
|
+
"status": "error",
|
|
196
|
+
"error": f"Invalid command syntax: {e}",
|
|
197
|
+
"has_errors": True,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if not cmd_parts:
|
|
201
|
+
return {
|
|
202
|
+
"status": "error",
|
|
203
|
+
"error": "Empty command",
|
|
204
|
+
"has_errors": True,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Validate arguments for path traversal
|
|
208
|
+
# This prevents "cat ../secret.txt" even if "cat" is allowed
|
|
209
|
+
if hasattr(self, "path_validator"):
|
|
210
|
+
for arg in cmd_parts[1:]:
|
|
211
|
+
# Skip flags that don't look like paths (simple heuristics)
|
|
212
|
+
# We check for path separators or ".."
|
|
213
|
+
# We also handle --flag=/path/to/file
|
|
214
|
+
|
|
215
|
+
candidate_path = arg
|
|
216
|
+
if arg.startswith("-"):
|
|
217
|
+
if "=" in arg:
|
|
218
|
+
_, candidate_path = arg.split("=", 1)
|
|
219
|
+
else:
|
|
220
|
+
# Skip flags without value (e.g. -l, --verbose)
|
|
221
|
+
# But what about -f/path? Hard to parse without knowing the tool.
|
|
222
|
+
# We'll assume if it has a path separator, it might be a path attached to a flag
|
|
223
|
+
if os.sep not in arg and "/" not in arg:
|
|
224
|
+
continue
|
|
225
|
+
# If it has separators, treat the whole thing or part of it as path?
|
|
226
|
+
# Treating "-f/tmp" as a path "/tmp" is hard.
|
|
227
|
+
# Let's be conservative: if it contains separators, check it.
|
|
228
|
+
|
|
229
|
+
# Check if it looks like a path
|
|
230
|
+
if (
|
|
231
|
+
os.sep in candidate_path
|
|
232
|
+
or "/" in candidate_path
|
|
233
|
+
or ".." in candidate_path
|
|
234
|
+
):
|
|
235
|
+
# Ignore URLs
|
|
236
|
+
if candidate_path.startswith(
|
|
237
|
+
("http://", "https://", "git://", "ssh://")
|
|
238
|
+
):
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Resolve path relative to CWD
|
|
242
|
+
try:
|
|
243
|
+
# Handle potential flag prefix if we didn't split it cleanly
|
|
244
|
+
# This is best-effort.
|
|
245
|
+
clean_path = candidate_path
|
|
246
|
+
|
|
247
|
+
# Resolve
|
|
248
|
+
resolved_path = str(
|
|
249
|
+
Path(cwd).joinpath(clean_path).resolve()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if not self.path_validator.is_path_allowed(
|
|
253
|
+
resolved_path
|
|
254
|
+
):
|
|
255
|
+
return {
|
|
256
|
+
"status": "error",
|
|
257
|
+
"error": f"Access denied: Argument '{arg}' resolves to forbidden path '{resolved_path}'",
|
|
258
|
+
"has_errors": True,
|
|
259
|
+
}
|
|
260
|
+
except Exception:
|
|
261
|
+
# If we can't resolve it (e.g. invalid chars), we might warn or ignore.
|
|
262
|
+
# For security, maybe ignore if it's not a valid path anyway?
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Security: WHITELIST approach - only allow explicitly safe commands
|
|
266
|
+
# This is much safer than a blacklist which always misses dangerous commands
|
|
267
|
+
ALLOWED_COMMANDS = {
|
|
268
|
+
# File listing and navigation (READ-ONLY)
|
|
269
|
+
"ls",
|
|
270
|
+
"dir",
|
|
271
|
+
"pwd",
|
|
272
|
+
"cd",
|
|
273
|
+
# File content viewing (READ-ONLY)
|
|
274
|
+
"cat",
|
|
275
|
+
"head",
|
|
276
|
+
"tail",
|
|
277
|
+
"more",
|
|
278
|
+
"less",
|
|
279
|
+
# Text processing (READ-ONLY)
|
|
280
|
+
"grep",
|
|
281
|
+
"find",
|
|
282
|
+
"wc",
|
|
283
|
+
"sort",
|
|
284
|
+
"uniq",
|
|
285
|
+
"diff",
|
|
286
|
+
# File information (READ-ONLY)
|
|
287
|
+
"file",
|
|
288
|
+
"stat",
|
|
289
|
+
"du",
|
|
290
|
+
"df",
|
|
291
|
+
# System information (READ-ONLY)
|
|
292
|
+
"whoami",
|
|
293
|
+
"hostname",
|
|
294
|
+
"uname",
|
|
295
|
+
"date",
|
|
296
|
+
"uptime",
|
|
297
|
+
# Path utilities
|
|
298
|
+
"which",
|
|
299
|
+
"whereis",
|
|
300
|
+
"basename",
|
|
301
|
+
"dirname",
|
|
302
|
+
# Safe output
|
|
303
|
+
"echo",
|
|
304
|
+
"printf",
|
|
305
|
+
# Process information (READ-ONLY)
|
|
306
|
+
"ps",
|
|
307
|
+
"top",
|
|
308
|
+
"jobs",
|
|
309
|
+
# Git commands (mostly safe, read-only operations)
|
|
310
|
+
"git", # Individual git subcommands checked separately
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
cmd_base = cmd_parts[0].lower()
|
|
314
|
+
|
|
315
|
+
# Special handling for git - only allow read-only operations
|
|
316
|
+
if cmd_base == "git":
|
|
317
|
+
if len(cmd_parts) > 1:
|
|
318
|
+
git_subcmd = cmd_parts[1].lower()
|
|
319
|
+
safe_git_commands = {
|
|
320
|
+
"status",
|
|
321
|
+
"log",
|
|
322
|
+
"show",
|
|
323
|
+
"diff",
|
|
324
|
+
"branch",
|
|
325
|
+
"remote",
|
|
326
|
+
"ls-files",
|
|
327
|
+
"ls-tree",
|
|
328
|
+
"describe",
|
|
329
|
+
"rev-parse",
|
|
330
|
+
"config",
|
|
331
|
+
"help",
|
|
332
|
+
}
|
|
333
|
+
if git_subcmd not in safe_git_commands:
|
|
334
|
+
return {
|
|
335
|
+
"status": "error",
|
|
336
|
+
"error": f"Git command '{git_subcmd}' is not allowed. Only read-only git operations are permitted.",
|
|
337
|
+
"has_errors": True,
|
|
338
|
+
"allowed_git_commands": list(safe_git_commands),
|
|
339
|
+
}
|
|
340
|
+
elif cmd_base not in ALLOWED_COMMANDS:
|
|
341
|
+
return {
|
|
342
|
+
"status": "error",
|
|
343
|
+
"error": f"Command '{cmd_base}' is not in the allowed list for security reasons",
|
|
344
|
+
"has_errors": True,
|
|
345
|
+
"hint": "Only read-only, informational commands are allowed",
|
|
346
|
+
"examples": "ls, cat, grep, find, git status, etc.",
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
# Log command execution (debug mode)
|
|
350
|
+
if hasattr(self, "debug") and self.debug:
|
|
351
|
+
logger.info(f"Executing command: {command} in {cwd}")
|
|
352
|
+
|
|
353
|
+
# Execute command
|
|
354
|
+
start_time = datetime.utcnow()
|
|
355
|
+
try:
|
|
356
|
+
result = subprocess.run(
|
|
357
|
+
cmd_parts,
|
|
358
|
+
cwd=cwd,
|
|
359
|
+
capture_output=True,
|
|
360
|
+
text=True,
|
|
361
|
+
timeout=timeout,
|
|
362
|
+
check=False,
|
|
363
|
+
env=os.environ.copy(),
|
|
364
|
+
)
|
|
365
|
+
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
366
|
+
|
|
367
|
+
# Record successful command execution for rate limiting
|
|
368
|
+
self._record_command_execution()
|
|
369
|
+
except subprocess.TimeoutExpired as exc:
|
|
370
|
+
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
371
|
+
|
|
372
|
+
# Handle timeout gracefully
|
|
373
|
+
stdout_str = ""
|
|
374
|
+
stderr_str = ""
|
|
375
|
+
if exc.stdout:
|
|
376
|
+
stdout_str = (
|
|
377
|
+
exc.stdout
|
|
378
|
+
if isinstance(exc.stdout, str)
|
|
379
|
+
else exc.stdout.decode("utf-8", errors="replace")
|
|
380
|
+
)
|
|
381
|
+
if exc.stderr:
|
|
382
|
+
stderr_str = (
|
|
383
|
+
exc.stderr
|
|
384
|
+
if isinstance(exc.stderr, str)
|
|
385
|
+
else exc.stderr.decode("utf-8", errors="replace")
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"status": "error",
|
|
390
|
+
"error": f"Command timed out after {timeout} seconds",
|
|
391
|
+
"command": command,
|
|
392
|
+
"stdout": stdout_str,
|
|
393
|
+
"stderr": stderr_str,
|
|
394
|
+
"has_errors": True,
|
|
395
|
+
"timed_out": True,
|
|
396
|
+
"timeout": timeout,
|
|
397
|
+
"duration_seconds": duration,
|
|
398
|
+
"cwd": cwd,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# Capture and truncate output if too long
|
|
402
|
+
stdout = result.stdout or ""
|
|
403
|
+
stderr = result.stderr or ""
|
|
404
|
+
truncated = False
|
|
405
|
+
max_output = 10_000
|
|
406
|
+
|
|
407
|
+
if len(stdout) > max_output:
|
|
408
|
+
stdout = stdout[:max_output] + "\n...output truncated (stdout)..."
|
|
409
|
+
truncated = True
|
|
410
|
+
|
|
411
|
+
if len(stderr) > max_output:
|
|
412
|
+
stderr = stderr[:max_output] + "\n...output truncated (stderr)..."
|
|
413
|
+
truncated = True
|
|
414
|
+
|
|
415
|
+
# Debug logging
|
|
416
|
+
if hasattr(self, "debug") and self.debug:
|
|
417
|
+
logger.info(
|
|
418
|
+
f"Command completed in {duration:.2f}s with return code {result.returncode}"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
"status": "success",
|
|
423
|
+
"command": command,
|
|
424
|
+
"stdout": stdout,
|
|
425
|
+
"stderr": stderr,
|
|
426
|
+
"return_code": result.returncode,
|
|
427
|
+
"has_errors": result.returncode != 0,
|
|
428
|
+
"duration_seconds": duration,
|
|
429
|
+
"timeout": timeout,
|
|
430
|
+
"cwd": cwd,
|
|
431
|
+
"output_truncated": truncated,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
logger.error(f"Error executing shell command: {exc}")
|
|
436
|
+
return {"status": "error", "error": str(exc), "has_errors": True}
|