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.
@@ -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