cloudshellgpt 1.0.0__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.
- cloudshellgpt/__init__.py +11 -0
- cloudshellgpt/audit.py +175 -0
- cloudshellgpt/bedrock_translator.py +525 -0
- cloudshellgpt/cli.py +612 -0
- cloudshellgpt/config.py +296 -0
- cloudshellgpt/cost.py +443 -0
- cloudshellgpt/executor.py +382 -0
- cloudshellgpt/formatter.py +328 -0
- cloudshellgpt/i18n.py +203 -0
- cloudshellgpt/intent.py +1080 -0
- cloudshellgpt/learning.py +969 -0
- cloudshellgpt/mcp_server.py +264 -0
- cloudshellgpt/safety.py +952 -0
- cloudshellgpt-1.0.0.dist-info/METADATA +426 -0
- cloudshellgpt-1.0.0.dist-info/RECORD +18 -0
- cloudshellgpt-1.0.0.dist-info/WHEEL +4 -0
- cloudshellgpt-1.0.0.dist-info/entry_points.txt +2 -0
- cloudshellgpt-1.0.0.dist-info/licenses/LICENSE +198 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""AWS executor — runs commands via subprocess with safety controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Custom exception
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExecutorError(Exception):
|
|
19
|
+
"""Raised when command validation or execution fails.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
message: Human-readable description of the failure.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str) -> None:
|
|
26
|
+
self.message = message
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Constants
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
# Retry configuration defaults
|
|
35
|
+
DEFAULT_MAX_RETRIES: int = 3
|
|
36
|
+
INITIAL_BACKOFF_SECONDS: float = 1.0
|
|
37
|
+
BACKOFF_MULTIPLIER: float = 2.0
|
|
38
|
+
|
|
39
|
+
# AWS throttling error patterns detected in stderr
|
|
40
|
+
THROTTLING_PATTERNS: list[str] = [
|
|
41
|
+
"Throttling",
|
|
42
|
+
"Rate exceeded",
|
|
43
|
+
"ThrottlingException",
|
|
44
|
+
"TooManyRequestsException",
|
|
45
|
+
"RequestLimitExceeded",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Regex patterns that detect shell metacharacters and injection attempts.
|
|
49
|
+
# Order matters for readability, not for matching priority.
|
|
50
|
+
SHELL_METACHAR_PATTERNS: list[tuple[str, str]] = [
|
|
51
|
+
# Null bytes and newlines
|
|
52
|
+
(r"\x00", "null byte"),
|
|
53
|
+
(r"\n", "newline"),
|
|
54
|
+
# Command chaining / sequencing
|
|
55
|
+
(r"&&", "command chaining (&&)"),
|
|
56
|
+
(r"\|\|", "command chaining (||)"),
|
|
57
|
+
(r";", "command separator (;)"),
|
|
58
|
+
# Pipe
|
|
59
|
+
(r"\|", "pipe (|)"),
|
|
60
|
+
# Backticks
|
|
61
|
+
(r"`", "backtick substitution"),
|
|
62
|
+
# Subshell / command substitution
|
|
63
|
+
(r"\$\(", "command substitution $()"),
|
|
64
|
+
# Environment variable injection
|
|
65
|
+
(r"\$\{", "variable expansion ${...}"),
|
|
66
|
+
(r"\$[A-Za-z_]", "variable expansion $VAR"),
|
|
67
|
+
# Here-doc / here-string (must check before redirect patterns)
|
|
68
|
+
(r"<<<", "here-string (<<<)"),
|
|
69
|
+
(r"<<", "here-doc (<<)"),
|
|
70
|
+
# Process substitution
|
|
71
|
+
(r"<\(", "process substitution <()"),
|
|
72
|
+
(r">\(", "process substitution >()"),
|
|
73
|
+
# Redirects (stderr redirect must come before generic)
|
|
74
|
+
(r"2>", "stderr redirect (2>)"),
|
|
75
|
+
(r">>", "append redirect (>>)"),
|
|
76
|
+
(r">", "output redirect (>)"),
|
|
77
|
+
(r"<", "input redirect (<)"),
|
|
78
|
+
# Background execution — standalone & (not part of &&, which is caught above).
|
|
79
|
+
# Matches & preceded by a space (mid-command: "cmd1 & cmd2") or at end of string.
|
|
80
|
+
(r"(?<=[^&])&(?!&)", "background execution (&)"),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Compiled regex that matches ANY shell metacharacter.
|
|
84
|
+
# We build a single pattern with alternation for efficient matching.
|
|
85
|
+
_SHELL_INJECTION_RE = re.compile(
|
|
86
|
+
"|".join(f"(?:{pattern})" for pattern, _ in SHELL_METACHAR_PATTERNS)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Data model
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ExecutionResult(BaseModel):
|
|
96
|
+
"""Result of an AWS command execution."""
|
|
97
|
+
|
|
98
|
+
command: str
|
|
99
|
+
stdout: str
|
|
100
|
+
stderr: str
|
|
101
|
+
exit_code: int
|
|
102
|
+
duration_ms: int = Field(description="Execution time in milliseconds")
|
|
103
|
+
dry_run: bool = False
|
|
104
|
+
error: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Executor
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AWSExecutor:
|
|
113
|
+
"""Executes AWS CLI commands in a controlled manner.
|
|
114
|
+
|
|
115
|
+
Features:
|
|
116
|
+
- Shell injection prevention (strict metacharacter validation)
|
|
117
|
+
- Timeout enforcement
|
|
118
|
+
- Exponential backoff retry for transient errors
|
|
119
|
+
- Streaming output
|
|
120
|
+
- Dry-run injection
|
|
121
|
+
- Error capture and classification
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
125
|
+
STREAM_CHUNK_SIZE = 4096
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
dry_run: bool = False,
|
|
130
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
131
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.dry_run = dry_run
|
|
134
|
+
self.timeout = timeout
|
|
135
|
+
self.max_retries = max_retries
|
|
136
|
+
|
|
137
|
+
def _validate_command(self, command: str) -> ExecutionResult | None:
|
|
138
|
+
"""Validate command for shell injection and ensure it starts with 'aws'.
|
|
139
|
+
|
|
140
|
+
Checks for shell metacharacters, empty/whitespace commands, and
|
|
141
|
+
non-AWS prefixes. Returns an ExecutionResult with error details if
|
|
142
|
+
validation fails, or None if the command is safe.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
command: The raw command string to validate.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
ExecutionResult with error info if validation fails, None if safe.
|
|
149
|
+
"""
|
|
150
|
+
# Reject empty or whitespace-only commands
|
|
151
|
+
if not command or not command.strip():
|
|
152
|
+
return ExecutionResult(
|
|
153
|
+
command=command if command else "",
|
|
154
|
+
stdout="",
|
|
155
|
+
stderr="Refusing to execute empty command",
|
|
156
|
+
exit_code=1,
|
|
157
|
+
duration_ms=0,
|
|
158
|
+
error="Security: command is empty or whitespace-only",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
stripped = command.strip()
|
|
162
|
+
|
|
163
|
+
# Must start with 'aws' followed by whitespace or end of string
|
|
164
|
+
if not (stripped == "aws" or stripped.startswith("aws ")):
|
|
165
|
+
return ExecutionResult(
|
|
166
|
+
command=command,
|
|
167
|
+
stdout="",
|
|
168
|
+
stderr="Refusing to execute non-AWS command",
|
|
169
|
+
exit_code=1,
|
|
170
|
+
duration_ms=0,
|
|
171
|
+
error="Security: command must start with 'aws'",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Check for shell metacharacters in the raw command string.
|
|
175
|
+
# We scan the raw string to catch injection attempts that might
|
|
176
|
+
# survive shlex splitting.
|
|
177
|
+
# First, remove content inside quotes (single and double) since
|
|
178
|
+
# those are argument values, not shell operators. JMESPath uses |
|
|
179
|
+
# inside --query '...' which is valid and not a shell pipe.
|
|
180
|
+
unquoted = re.sub(r"'[^']*'", "", command)
|
|
181
|
+
unquoted = re.sub(r'"[^"]*"', "", unquoted)
|
|
182
|
+
|
|
183
|
+
for pattern, description in SHELL_METACHAR_PATTERNS:
|
|
184
|
+
if re.search(pattern, unquoted):
|
|
185
|
+
# Exception: the standalone '-' argument is valid for
|
|
186
|
+
# stdin/stdout usage (e.g., aws s3 cp - s3://bucket/file).
|
|
187
|
+
# Only skip '<'/'>' detection when the character appears
|
|
188
|
+
# solely because of a legitimate '-' argument pattern, not
|
|
189
|
+
# as an actual shell redirect operator.
|
|
190
|
+
if description in (
|
|
191
|
+
"input redirect (<)",
|
|
192
|
+
"output redirect (>)",
|
|
193
|
+
):
|
|
194
|
+
# Check if '-' is present as a standalone token.
|
|
195
|
+
# If so, verify the '<'/'>' isn't a real redirect:
|
|
196
|
+
# a real redirect has the operator as its own token
|
|
197
|
+
# (e.g., "cmd > file") or attached to a filename
|
|
198
|
+
# (e.g., "cmd >file"). If '-' is standalone and the
|
|
199
|
+
# '<'/'>' only appears within an argument containing '-'
|
|
200
|
+
# (e.g., a flag like --output), skip this check.
|
|
201
|
+
tokens = stripped.split()
|
|
202
|
+
has_standalone_dash = "-" in tokens
|
|
203
|
+
redirect_char = "<" if "input" in description else ">"
|
|
204
|
+
# Check if any token IS the redirect operator or starts
|
|
205
|
+
# with it (e.g., ">file") — that's a real redirect.
|
|
206
|
+
has_real_redirect = any(
|
|
207
|
+
tok == redirect_char or (tok.startswith(redirect_char) and tok != "-")
|
|
208
|
+
for tok in tokens
|
|
209
|
+
)
|
|
210
|
+
if has_standalone_dash and not has_real_redirect:
|
|
211
|
+
# No actual redirect — '-' is just a positional arg
|
|
212
|
+
continue
|
|
213
|
+
return ExecutionResult(
|
|
214
|
+
command=command,
|
|
215
|
+
stdout="",
|
|
216
|
+
stderr=f"Refusing to execute: shell metacharacter detected ({description})",
|
|
217
|
+
exit_code=1,
|
|
218
|
+
duration_ms=0,
|
|
219
|
+
error=f"Security: shell injection detected — {description}",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def _is_transient_error(self, result: ExecutionResult) -> bool:
|
|
225
|
+
"""Determine if an execution result represents a transient (retryable) error.
|
|
226
|
+
|
|
227
|
+
Checks stderr for known AWS throttling patterns.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
result: The execution result to evaluate.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if the error is transient and the command should be retried.
|
|
234
|
+
"""
|
|
235
|
+
if not result.stderr:
|
|
236
|
+
return False
|
|
237
|
+
return any(pattern in result.stderr for pattern in THROTTLING_PATTERNS)
|
|
238
|
+
|
|
239
|
+
def run(self, command: str) -> ExecutionResult:
|
|
240
|
+
"""Execute an AWS CLI command with exponential backoff retry.
|
|
241
|
+
|
|
242
|
+
Validates the command for shell injection attempts before execution.
|
|
243
|
+
Only pure AWS CLI commands without shell metacharacters are allowed.
|
|
244
|
+
Retries on transient errors (throttling, timeouts) with exponential
|
|
245
|
+
backoff and jitter.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
command: The full AWS CLI command string.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
ExecutionResult with stdout, stderr, exit code, etc.
|
|
252
|
+
"""
|
|
253
|
+
# Validate BEFORE any execution
|
|
254
|
+
validation_error = self._validate_command(command)
|
|
255
|
+
if validation_error is not None:
|
|
256
|
+
return validation_error
|
|
257
|
+
|
|
258
|
+
# Inject dry-run if needed
|
|
259
|
+
effective_command = self._inject_dry_run(command) if self.dry_run else command
|
|
260
|
+
|
|
261
|
+
# Re-validate after dry-run injection (defense in depth)
|
|
262
|
+
if self.dry_run:
|
|
263
|
+
post_inject_error = self._validate_command(effective_command)
|
|
264
|
+
if post_inject_error is not None:
|
|
265
|
+
return post_inject_error
|
|
266
|
+
|
|
267
|
+
last_result: ExecutionResult | None = None
|
|
268
|
+
|
|
269
|
+
for attempt in range(self.max_retries + 1):
|
|
270
|
+
# Apply backoff delay before retries (not before first attempt)
|
|
271
|
+
if attempt > 0:
|
|
272
|
+
backoff = INITIAL_BACKOFF_SECONDS * (BACKOFF_MULTIPLIER ** (attempt - 1))
|
|
273
|
+
jitter = random.uniform(0, backoff * 0.5) # noqa: S311
|
|
274
|
+
time.sleep(backoff + jitter)
|
|
275
|
+
|
|
276
|
+
start = time.time()
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
# Use shlex to safely split the command
|
|
280
|
+
args = shlex.split(effective_command)
|
|
281
|
+
|
|
282
|
+
# Final guard: validate first token is literally 'aws'
|
|
283
|
+
if not args or args[0] != "aws":
|
|
284
|
+
return ExecutionResult(
|
|
285
|
+
command=effective_command,
|
|
286
|
+
stdout="",
|
|
287
|
+
stderr="Refusing to execute non-AWS command",
|
|
288
|
+
exit_code=1,
|
|
289
|
+
duration_ms=0,
|
|
290
|
+
error="Security: command must start with 'aws'",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
result = subprocess.run(
|
|
294
|
+
args,
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
timeout=self.timeout,
|
|
298
|
+
check=False,
|
|
299
|
+
stdin=subprocess.DEVNULL,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
303
|
+
|
|
304
|
+
last_result = ExecutionResult(
|
|
305
|
+
command=effective_command,
|
|
306
|
+
stdout=result.stdout,
|
|
307
|
+
stderr=result.stderr,
|
|
308
|
+
exit_code=result.returncode,
|
|
309
|
+
duration_ms=duration_ms,
|
|
310
|
+
dry_run=self.dry_run,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# If successful or non-transient error, return immediately
|
|
314
|
+
if result.returncode == 0 or not self._is_transient_error(last_result):
|
|
315
|
+
return last_result
|
|
316
|
+
|
|
317
|
+
# Transient error — continue to next retry attempt
|
|
318
|
+
|
|
319
|
+
except subprocess.TimeoutExpired:
|
|
320
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
321
|
+
last_result = ExecutionResult(
|
|
322
|
+
command=effective_command,
|
|
323
|
+
stdout="",
|
|
324
|
+
stderr=f"Command timed out after {self.timeout}s",
|
|
325
|
+
exit_code=124,
|
|
326
|
+
duration_ms=duration_ms,
|
|
327
|
+
error="timeout",
|
|
328
|
+
)
|
|
329
|
+
# Timeout is transient — continue to next retry attempt
|
|
330
|
+
|
|
331
|
+
except FileNotFoundError:
|
|
332
|
+
return ExecutionResult(
|
|
333
|
+
command=effective_command,
|
|
334
|
+
stdout="",
|
|
335
|
+
stderr="AWS CLI not found. Install it: https://aws.amazon.com/cli/",
|
|
336
|
+
exit_code=127,
|
|
337
|
+
duration_ms=0,
|
|
338
|
+
error="aws_cli_missing",
|
|
339
|
+
)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
return ExecutionResult(
|
|
342
|
+
command=effective_command,
|
|
343
|
+
stdout="",
|
|
344
|
+
stderr=str(e),
|
|
345
|
+
exit_code=1,
|
|
346
|
+
duration_ms=0,
|
|
347
|
+
error=type(e).__name__,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# All retries exhausted — return last error result
|
|
351
|
+
assert last_result is not None # noqa: S101
|
|
352
|
+
return last_result
|
|
353
|
+
|
|
354
|
+
def _inject_dry_run(self, command: str) -> str:
|
|
355
|
+
"""Inject --dry-run flag into commands that support it.
|
|
356
|
+
|
|
357
|
+
Some AWS commands don't support --dry-run, in which case we
|
|
358
|
+
add a comment to make it clear this is a simulation.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
command: The original AWS CLI command.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Command with --dry-run injected, or prefixed with comment.
|
|
365
|
+
"""
|
|
366
|
+
# Commands that natively support --dry-run
|
|
367
|
+
dry_run_supported = [
|
|
368
|
+
"ec2 run-instances",
|
|
369
|
+
"ec2 terminate-instances",
|
|
370
|
+
"ec2 delete-volume",
|
|
371
|
+
"rds delete-db-instance",
|
|
372
|
+
"s3api delete-bucket",
|
|
373
|
+
"iam delete-user",
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
for pattern in dry_run_supported:
|
|
377
|
+
if pattern in command:
|
|
378
|
+
return command + " --dry-run"
|
|
379
|
+
|
|
380
|
+
# Fallback: return command unchanged (avoid injecting comments
|
|
381
|
+
# that would fail validation due to newline/# characters)
|
|
382
|
+
return command
|