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
cloudshellgpt/safety.py
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
"""Safety layer — risk assessment, cost preview, confirmation flow, and dry-run support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from cloudshellgpt.bedrock_translator import Translation
|
|
11
|
+
from cloudshellgpt.cost import CostEstimate
|
|
12
|
+
|
|
13
|
+
RiskLevel = Literal["low", "medium", "high", "critical"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Custom exception
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SafetyError(Exception):
|
|
22
|
+
"""Raised when a safety check fails or cannot be completed.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
message: Human-readable description of the safety failure.
|
|
26
|
+
risk_level: The risk level that triggered the error, if applicable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, *, risk_level: RiskLevel | None = None) -> None:
|
|
30
|
+
self.message = message
|
|
31
|
+
self.risk_level = risk_level
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Data model
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SafetyCheck(BaseModel):
|
|
41
|
+
"""Result of a safety assessment on a translation.
|
|
42
|
+
|
|
43
|
+
Contains all information needed for the CLI to decide whether to execute,
|
|
44
|
+
prompt for confirmation, or require dry-run before proceeding.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
risk_level: RiskLevel = Field(description="Assessed risk level of the operation")
|
|
48
|
+
requires_confirmation: bool = Field(
|
|
49
|
+
description="Whether user confirmation is needed before execution"
|
|
50
|
+
)
|
|
51
|
+
requires_dry_run: bool = Field(
|
|
52
|
+
description="Whether a dry-run must be performed before real execution"
|
|
53
|
+
)
|
|
54
|
+
estimated_cost: str = Field(description="Estimated cost string (e.g. '$0.00')")
|
|
55
|
+
confirmation_prompt: str = Field(
|
|
56
|
+
description="Human-readable prompt to show the user for confirmation"
|
|
57
|
+
)
|
|
58
|
+
warnings: list[str] = Field(default_factory=list)
|
|
59
|
+
affected_resources: list[str] = Field(default_factory=list)
|
|
60
|
+
reversible: bool = Field(
|
|
61
|
+
default=True,
|
|
62
|
+
description="Whether the operation can be undone without data loss",
|
|
63
|
+
)
|
|
64
|
+
cost_breakdown: dict[str, str] = Field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DryRunResult(BaseModel):
|
|
68
|
+
"""Result of dry-run injection for a command.
|
|
69
|
+
|
|
70
|
+
Encapsulates the (possibly modified) command along with metadata
|
|
71
|
+
indicating whether native dry-run is supported or if the command
|
|
72
|
+
should only be shown as a preview without execution.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
command: The (possibly modified) AWS CLI command.
|
|
76
|
+
is_native_dry_run: True if the service supports native dry-run
|
|
77
|
+
(EC2 via --dry-run, CloudFormation via change sets).
|
|
78
|
+
preview_only: True if the command cannot be dry-run natively and
|
|
79
|
+
should only be displayed without executing.
|
|
80
|
+
dry_run_notes: Human-readable explanation of what dry-run mode
|
|
81
|
+
does for this particular service.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
command: str = Field(description="The (possibly modified) AWS CLI command")
|
|
85
|
+
is_native_dry_run: bool = Field(
|
|
86
|
+
description="True if the service supports native dry-run mechanism"
|
|
87
|
+
)
|
|
88
|
+
preview_only: bool = Field(description="True if command should be shown without executing")
|
|
89
|
+
dry_run_notes: str = Field(description="Explanation of what dry-run mode does for this service")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Constants
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
RISK_ORDER: dict[str, int] = {
|
|
97
|
+
"low": 0,
|
|
98
|
+
"medium": 1,
|
|
99
|
+
"high": 2,
|
|
100
|
+
"critical": 3,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
READ_ONLY_PATTERNS: list[str] = [
|
|
104
|
+
"list",
|
|
105
|
+
"describe",
|
|
106
|
+
"get",
|
|
107
|
+
"head",
|
|
108
|
+
"wait",
|
|
109
|
+
"show",
|
|
110
|
+
"ls",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Reversible operations — pairs where the operation has a direct inverse.
|
|
115
|
+
# Key = prefix of the "forward" operation, Value = prefix of its inverse.
|
|
116
|
+
# If a command matches a reversible prefix AND does NOT destroy data, it's medium.
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
REVERSIBLE_OPERATIONS: dict[str, str] = {
|
|
120
|
+
"create-": "delete-",
|
|
121
|
+
"attach-": "detach-",
|
|
122
|
+
"enable-": "disable-",
|
|
123
|
+
"register-": "deregister-",
|
|
124
|
+
"add-": "remove-",
|
|
125
|
+
"tag-resource": "untag-resource",
|
|
126
|
+
"put-": "delete-",
|
|
127
|
+
"associate-": "disassociate-",
|
|
128
|
+
"start-": "stop-",
|
|
129
|
+
"update-": "revert/re-apply",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Data-destroying patterns — operations that eliminate data or access and
|
|
134
|
+
# require manual recreation. These always classify as high risk.
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
DATA_DESTROYING_PATTERNS: list[str] = [
|
|
138
|
+
"delete-bucket",
|
|
139
|
+
"delete-object",
|
|
140
|
+
"terminate-instances",
|
|
141
|
+
"delete-table",
|
|
142
|
+
"delete-db-instance",
|
|
143
|
+
"delete-db-cluster",
|
|
144
|
+
"remove-permission",
|
|
145
|
+
"revoke-security-group",
|
|
146
|
+
"delete-volume",
|
|
147
|
+
"delete-snapshot",
|
|
148
|
+
"empty-bucket",
|
|
149
|
+
"purge-queue",
|
|
150
|
+
"delete-stack",
|
|
151
|
+
"delete-user",
|
|
152
|
+
"delete-role",
|
|
153
|
+
"delete-policy",
|
|
154
|
+
"delete-function",
|
|
155
|
+
"delete-queue",
|
|
156
|
+
"delete-topic",
|
|
157
|
+
"delete-distribution",
|
|
158
|
+
"release-address",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Mutation verbs — verbs that suggest state-changing operations.
|
|
163
|
+
# If a command contains one of these but isn't in any known list, it should
|
|
164
|
+
# be upgraded to medium (never left at low when there's doubt).
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
MUTATION_VERBS: list[str] = [
|
|
168
|
+
"create",
|
|
169
|
+
"delete",
|
|
170
|
+
"update",
|
|
171
|
+
"put",
|
|
172
|
+
"remove",
|
|
173
|
+
"modify",
|
|
174
|
+
"replace",
|
|
175
|
+
"set",
|
|
176
|
+
"reset",
|
|
177
|
+
"import",
|
|
178
|
+
"export",
|
|
179
|
+
"invoke",
|
|
180
|
+
"execute",
|
|
181
|
+
"run",
|
|
182
|
+
"send",
|
|
183
|
+
"publish",
|
|
184
|
+
"cancel",
|
|
185
|
+
"stop",
|
|
186
|
+
"start",
|
|
187
|
+
"reboot",
|
|
188
|
+
"restore",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# Legacy medium/high patterns kept for backward compatibility reference.
|
|
192
|
+
# The new heuristic uses REVERSIBLE_OPERATIONS and DATA_DESTROYING_PATTERNS instead.
|
|
193
|
+
|
|
194
|
+
MEDIUM_PATTERNS: list[str] = [
|
|
195
|
+
"create-bucket",
|
|
196
|
+
"tag-resource",
|
|
197
|
+
"put-metric-alarm",
|
|
198
|
+
"enable-",
|
|
199
|
+
"create-snapshot",
|
|
200
|
+
"put-",
|
|
201
|
+
"add-",
|
|
202
|
+
"attach-",
|
|
203
|
+
"register-",
|
|
204
|
+
"create-",
|
|
205
|
+
"update-",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
HIGH_PATTERNS: list[str] = [
|
|
209
|
+
"delete-bucket",
|
|
210
|
+
"terminate-instances",
|
|
211
|
+
"revoke-security-group-ingress",
|
|
212
|
+
"detach-volume",
|
|
213
|
+
"remove-",
|
|
214
|
+
"deregister-",
|
|
215
|
+
"delete-",
|
|
216
|
+
"terminate-",
|
|
217
|
+
"revoke-",
|
|
218
|
+
"detach-",
|
|
219
|
+
"disable-",
|
|
220
|
+
"release-",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
CRITICAL_PATTERNS: list[str] = [
|
|
224
|
+
"--force-delete",
|
|
225
|
+
"--skip-final-snapshot",
|
|
226
|
+
"--no-preserve-root",
|
|
227
|
+
"--bypass-governance-retention",
|
|
228
|
+
"--delete-all-versions",
|
|
229
|
+
"--force-destroy",
|
|
230
|
+
"--permanently-delete",
|
|
231
|
+
"--no-undo",
|
|
232
|
+
"empty-bucket",
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
# Destructive verbs that become critical when combined with --recursive
|
|
236
|
+
_RECURSIVE_DESTRUCTIVE_VERBS: list[str] = [
|
|
237
|
+
"delete",
|
|
238
|
+
"rm",
|
|
239
|
+
"remove",
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
DESTRUCTIVE_PATTERNS: list[str] = [
|
|
243
|
+
# Generic destructive verbs
|
|
244
|
+
"delete",
|
|
245
|
+
"terminate",
|
|
246
|
+
"rm",
|
|
247
|
+
"remove",
|
|
248
|
+
"drop",
|
|
249
|
+
"destroy",
|
|
250
|
+
"force",
|
|
251
|
+
"purge",
|
|
252
|
+
"wipe",
|
|
253
|
+
"nuke",
|
|
254
|
+
# AWS-specific destructive actions
|
|
255
|
+
"deregister",
|
|
256
|
+
"revoke",
|
|
257
|
+
"detach",
|
|
258
|
+
"disable",
|
|
259
|
+
"release",
|
|
260
|
+
"empty",
|
|
261
|
+
# Dangerous flags
|
|
262
|
+
"--recursive",
|
|
263
|
+
"--force",
|
|
264
|
+
"-f",
|
|
265
|
+
"--no-preserve",
|
|
266
|
+
"--skip-final-snapshot",
|
|
267
|
+
"--force-delete",
|
|
268
|
+
"--permanently-delete",
|
|
269
|
+
"--no-undo",
|
|
270
|
+
"--force-destroy",
|
|
271
|
+
"--delete-all-versions",
|
|
272
|
+
"--bypass-governance-retention",
|
|
273
|
+
"--no-preserve-root",
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
DRY_RUN_SERVICES: list[str] = [
|
|
277
|
+
"ec2",
|
|
278
|
+
"rds",
|
|
279
|
+
"s3api",
|
|
280
|
+
"iam",
|
|
281
|
+
"cloudformation",
|
|
282
|
+
"lambda",
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
# Services that support native --dry-run flag (EC2 is the only one with broad support)
|
|
286
|
+
_NATIVE_DRY_RUN_SERVICES: frozenset[str] = frozenset({"ec2"})
|
|
287
|
+
|
|
288
|
+
# CloudFormation commands that can be transformed to change sets
|
|
289
|
+
_CFN_CREATE_PATTERN: str = "create-stack"
|
|
290
|
+
_CFN_UPDATE_PATTERN: str = "update-stack"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Safety layer
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class SafetyLayer:
|
|
299
|
+
"""Assesses risk and cost of AWS operations before execution.
|
|
300
|
+
|
|
301
|
+
Combines:
|
|
302
|
+
- Rule-based destructive pattern detection (independent of LLM assessment)
|
|
303
|
+
- Risk upgrade ladder (never downgrades below LLM suggestion)
|
|
304
|
+
- Cost estimation via AWS Cost Explorer API
|
|
305
|
+
- Confirmation prompting appropriate to risk level
|
|
306
|
+
|
|
307
|
+
Heuristic for classification:
|
|
308
|
+
- If operation has a direct inverse and doesn't destroy data → medium
|
|
309
|
+
- If operation eliminates data or access → high
|
|
310
|
+
- If in doubt → upgrade
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(self, region: str = "us-east-1", max_cost_alert: int = 100) -> None:
|
|
314
|
+
"""Initialize the safety layer.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
region: AWS region for Cost Explorer API calls.
|
|
318
|
+
max_cost_alert: USD threshold above which a cost warning is added.
|
|
319
|
+
"""
|
|
320
|
+
self.ce_client = boto3.client("ce", region_name=region)
|
|
321
|
+
self.region = region
|
|
322
|
+
self.max_cost_alert = max_cost_alert
|
|
323
|
+
|
|
324
|
+
def assess(
|
|
325
|
+
self,
|
|
326
|
+
translation: Translation,
|
|
327
|
+
cost_estimate: CostEstimate | None = None,
|
|
328
|
+
) -> SafetyCheck:
|
|
329
|
+
"""Run a full safety assessment on a translated command.
|
|
330
|
+
|
|
331
|
+
Combines rule-based classification with LLM assessment, applying
|
|
332
|
+
the upgrade ladder when destructive patterns are detected.
|
|
333
|
+
|
|
334
|
+
When a CostEstimate is provided, integrates cost data into the
|
|
335
|
+
safety check: propagates warnings, checks against max_cost_alert,
|
|
336
|
+
and populates cost_breakdown from the estimate.
|
|
337
|
+
|
|
338
|
+
**Independence guarantee:** The rule-based classifier works
|
|
339
|
+
independently of the LLM's risk assessment. The final risk is
|
|
340
|
+
ALWAYS >= the LLM's suggested risk (never downgrade). When the
|
|
341
|
+
LLM says "low" but the command contains destructive patterns
|
|
342
|
+
(delete, terminate, --force, etc.), the rule-based system
|
|
343
|
+
overrides upward via the _upgrade_risk ladder.
|
|
344
|
+
|
|
345
|
+
Algorithm:
|
|
346
|
+
1. Get llm_risk from translation.risk_level
|
|
347
|
+
2. Get rule_risk from _classify_risk_by_rules (independent)
|
|
348
|
+
3. If the command has destructive patterns AND rule_risk is
|
|
349
|
+
still below "high", apply _upgrade_risk to escalate
|
|
350
|
+
4. Final risk = max(llm_risk, upgraded_rule_risk)
|
|
351
|
+
→ NEVER below llm_risk
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
translation: The Bedrock-generated translation to assess.
|
|
355
|
+
cost_estimate: Optional CostEstimate from CostEstimator. When
|
|
356
|
+
provided, cost data is integrated into the SafetyCheck.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
SafetyCheck with all risk, cost, and confirmation information.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
SafetyError: If the assessment cannot be completed.
|
|
363
|
+
"""
|
|
364
|
+
# --- Independent assessments ---
|
|
365
|
+
# LLM assessment (from Bedrock translation)
|
|
366
|
+
llm_risk = self._validate_risk_level(translation.risk_level)
|
|
367
|
+
|
|
368
|
+
# Rule-based assessment (independent of LLM, pattern-matching only)
|
|
369
|
+
rule_risk = self._classify_risk_by_rules(translation.command)
|
|
370
|
+
|
|
371
|
+
# --- Read-only override ---
|
|
372
|
+
# If the command's ACTION (verb) is read-only, force low regardless of LLM.
|
|
373
|
+
# We check the action specifically (parts[2]) not the whole string,
|
|
374
|
+
# to avoid false positives when "get" or "list" appears in argument values.
|
|
375
|
+
cmd_parts = translation.command.strip().split()
|
|
376
|
+
action = cmd_parts[2] if len(cmd_parts) > 2 else ""
|
|
377
|
+
read_only_actions = ("describe", "list", "get", "head", "wait", "show", "ls")
|
|
378
|
+
is_action_read_only = any(action.lower().startswith(prefix) for prefix in read_only_actions)
|
|
379
|
+
|
|
380
|
+
if is_action_read_only and not self._is_destructive(translation.command):
|
|
381
|
+
risk_level: RiskLevel = "low"
|
|
382
|
+
else:
|
|
383
|
+
# --- Destructive pattern upgrade ---
|
|
384
|
+
# If the command contains destructive patterns (broader than what
|
|
385
|
+
# _classify_risk_by_rules may catch) and rule_risk hasn't already
|
|
386
|
+
# escalated to "high" or above, apply the upgrade ladder.
|
|
387
|
+
if (
|
|
388
|
+
self._is_destructive(translation.command)
|
|
389
|
+
and RISK_ORDER[rule_risk] < RISK_ORDER["high"]
|
|
390
|
+
):
|
|
391
|
+
rule_risk = self._upgrade_risk(rule_risk)
|
|
392
|
+
|
|
393
|
+
# Final risk = max of both assessments.
|
|
394
|
+
# Invariant: result >= llm_risk (never downgrade below LLM suggestion)
|
|
395
|
+
risk_level = self._max_risk(llm_risk, rule_risk)
|
|
396
|
+
|
|
397
|
+
# Confirmation flow:
|
|
398
|
+
# low → execute directly (no confirmation)
|
|
399
|
+
# medium → show plan, ask Y/N
|
|
400
|
+
# high → show command + affected resources + cost, typed confirmation
|
|
401
|
+
# critical → dry-run first + "yes-i-understand"
|
|
402
|
+
requires_confirmation = risk_level in ("medium", "high", "critical")
|
|
403
|
+
|
|
404
|
+
# Dry-run required for critical or if LLM flagged it
|
|
405
|
+
requires_dry_run = translation.requires_dry_run or risk_level == "critical"
|
|
406
|
+
|
|
407
|
+
# Build confirmation prompt appropriate to risk level
|
|
408
|
+
confirmation_prompt = self._build_confirmation_prompt(translation, risk_level)
|
|
409
|
+
|
|
410
|
+
# Estimate costs for resource-creating operations
|
|
411
|
+
cost_breakdown = self._estimate_costs(translation)
|
|
412
|
+
|
|
413
|
+
# Determine reversibility: critical operations are not reversible
|
|
414
|
+
reversible = risk_level not in ("critical",)
|
|
415
|
+
|
|
416
|
+
# --- Integrate CostEstimate when provided ---
|
|
417
|
+
warnings: list[str] = list(translation.affected_resources)
|
|
418
|
+
estimated_cost = translation.estimated_cost
|
|
419
|
+
|
|
420
|
+
if cost_estimate is not None:
|
|
421
|
+
# Propagate CostEstimate warnings into SafetyCheck warnings
|
|
422
|
+
warnings.extend(cost_estimate.warnings)
|
|
423
|
+
|
|
424
|
+
if cost_estimate.status == "unknown":
|
|
425
|
+
warnings.append("Cost estimation unavailable — proceed with caution")
|
|
426
|
+
elif cost_estimate.estimated_monthly_cost > self.max_cost_alert:
|
|
427
|
+
warnings.append(
|
|
428
|
+
f"Estimated cost ${cost_estimate.estimated_monthly_cost:.2f}/month "
|
|
429
|
+
f"exceeds max_cost_alert threshold (${self.max_cost_alert})"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Use CostEstimate breakdown (convert float values to strings)
|
|
433
|
+
cost_breakdown = {k: f"${v:.2f}" for k, v in cost_estimate.cost_breakdown.items()}
|
|
434
|
+
|
|
435
|
+
# Update estimated_cost with formatted value from CostEstimate
|
|
436
|
+
if cost_estimate.status == "estimated" and cost_estimate.estimated_monthly_cost > 0:
|
|
437
|
+
estimated_cost = f"${cost_estimate.estimated_monthly_cost:.2f}/month"
|
|
438
|
+
|
|
439
|
+
return SafetyCheck(
|
|
440
|
+
risk_level=risk_level,
|
|
441
|
+
requires_confirmation=requires_confirmation,
|
|
442
|
+
requires_dry_run=requires_dry_run,
|
|
443
|
+
estimated_cost=estimated_cost,
|
|
444
|
+
confirmation_prompt=confirmation_prompt,
|
|
445
|
+
warnings=warnings,
|
|
446
|
+
affected_resources=translation.affected_resources,
|
|
447
|
+
reversible=reversible,
|
|
448
|
+
cost_breakdown=cost_breakdown,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _validate_risk_level(self, level: str) -> RiskLevel:
|
|
452
|
+
"""Validate and cast a string to RiskLevel.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
level: Raw risk level string from the LLM.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
A valid RiskLevel literal. Defaults to 'low' if invalid.
|
|
459
|
+
"""
|
|
460
|
+
if level in ("low", "medium", "high", "critical"):
|
|
461
|
+
return level # type: ignore[return-value]
|
|
462
|
+
return "low"
|
|
463
|
+
|
|
464
|
+
def _classify_risk_by_rules(self, command: str) -> RiskLevel:
|
|
465
|
+
"""Classify risk independently using pattern matching rules.
|
|
466
|
+
|
|
467
|
+
Applies the heuristic in strict priority order:
|
|
468
|
+
1. Critical: force/recursive/skip-safety flags
|
|
469
|
+
2. High: operations that destroy data or access
|
|
470
|
+
3. Medium: operations with a direct inverse that don't destroy data
|
|
471
|
+
4. Ambiguity resolution: if both reversible AND destructive → high (upgrade)
|
|
472
|
+
5. Low: read-only operations
|
|
473
|
+
6. Default: if mutation verb detected but no known pattern → medium (doubt → upgrade)
|
|
474
|
+
|
|
475
|
+
Works INDEPENDENTLY of the LLM's assessment.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
command: The AWS CLI command string to classify.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Rule-based risk level classification.
|
|
482
|
+
"""
|
|
483
|
+
cmd_lower = command.lower()
|
|
484
|
+
|
|
485
|
+
# 1. Critical: force/recursive/skip-safety flags (highest priority)
|
|
486
|
+
if self._is_critical(cmd_lower):
|
|
487
|
+
return "critical"
|
|
488
|
+
|
|
489
|
+
# 2. Check data destruction and reversibility
|
|
490
|
+
destroys = self._destroys_data(cmd_lower)
|
|
491
|
+
has_inverse = self._has_direct_inverse(cmd_lower)
|
|
492
|
+
|
|
493
|
+
# 3. Ambiguity resolution: if BOTH reversible AND destructive → upgrade to high
|
|
494
|
+
# (doubt → always upgrade)
|
|
495
|
+
if destroys and has_inverse:
|
|
496
|
+
return "high"
|
|
497
|
+
|
|
498
|
+
# 4. If destroys data → high
|
|
499
|
+
if destroys:
|
|
500
|
+
return "high"
|
|
501
|
+
|
|
502
|
+
# 5. Legacy high patterns (backward compat for patterns not in DATA_DESTROYING)
|
|
503
|
+
if self._is_high(cmd_lower):
|
|
504
|
+
return "high"
|
|
505
|
+
|
|
506
|
+
# 6. If has direct inverse and doesn't destroy data → medium
|
|
507
|
+
if has_inverse:
|
|
508
|
+
return "medium"
|
|
509
|
+
|
|
510
|
+
# 7. Legacy medium patterns (backward compat)
|
|
511
|
+
if self._is_medium(cmd_lower):
|
|
512
|
+
return "medium"
|
|
513
|
+
|
|
514
|
+
# 8. Read-only → low
|
|
515
|
+
if self._is_read_only(cmd_lower):
|
|
516
|
+
return "low"
|
|
517
|
+
|
|
518
|
+
# 9. Doubt → upgrade: if the command contains mutation verbs but didn't
|
|
519
|
+
# match any known pattern, classify as medium (never leave at low if in doubt)
|
|
520
|
+
if self._has_mutation_verb(cmd_lower):
|
|
521
|
+
return "medium"
|
|
522
|
+
|
|
523
|
+
# 10. Truly read-only or unrecognized → low
|
|
524
|
+
return "low"
|
|
525
|
+
|
|
526
|
+
def _is_critical(self, cmd_lower: str) -> bool:
|
|
527
|
+
"""Check if the command matches critical-level patterns.
|
|
528
|
+
|
|
529
|
+
Critical means: recursive/batch delete, force operations, or
|
|
530
|
+
flags that skip safety nets.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
cmd_lower: Lowercased command string.
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
True if the command matches critical patterns.
|
|
537
|
+
"""
|
|
538
|
+
# Check explicit critical patterns (force flags, skip-safety-net)
|
|
539
|
+
for pattern in CRITICAL_PATTERNS:
|
|
540
|
+
if pattern in cmd_lower:
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
# Check --recursive combined with destructive verbs
|
|
544
|
+
if "--recursive" in cmd_lower:
|
|
545
|
+
for verb in _RECURSIVE_DESTRUCTIVE_VERBS:
|
|
546
|
+
if verb in cmd_lower:
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
def _is_high(self, cmd_lower: str) -> bool:
|
|
552
|
+
"""Check if the command matches high-level patterns.
|
|
553
|
+
|
|
554
|
+
High means: delete/terminate/revoke on single resources that
|
|
555
|
+
eliminate data or access and require manual recreation.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
cmd_lower: Lowercased command string.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
True if the command matches high-risk patterns.
|
|
562
|
+
"""
|
|
563
|
+
return any(pattern in cmd_lower for pattern in HIGH_PATTERNS)
|
|
564
|
+
|
|
565
|
+
def _is_medium(self, cmd_lower: str) -> bool:
|
|
566
|
+
"""Check if the command matches medium-level patterns.
|
|
567
|
+
|
|
568
|
+
Medium means: create/update operations with easy rollback
|
|
569
|
+
(operation has a direct inverse and doesn't destroy data).
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
cmd_lower: Lowercased command string.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
True if the command matches medium-risk patterns.
|
|
576
|
+
"""
|
|
577
|
+
return any(pattern in cmd_lower for pattern in MEDIUM_PATTERNS)
|
|
578
|
+
|
|
579
|
+
def _is_read_only(self, cmd_lower: str) -> bool:
|
|
580
|
+
"""Check if the command matches read-only (low) patterns.
|
|
581
|
+
|
|
582
|
+
Read-only operations: list, describe, get, head, wait, show, ls.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
cmd_lower: Lowercased command string.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
True if the command matches read-only patterns.
|
|
589
|
+
"""
|
|
590
|
+
return any(pattern in cmd_lower for pattern in READ_ONLY_PATTERNS)
|
|
591
|
+
|
|
592
|
+
def _has_direct_inverse(self, cmd_lower: str) -> bool:
|
|
593
|
+
"""Check if the command corresponds to an operation with a direct inverse.
|
|
594
|
+
|
|
595
|
+
Operations with a direct inverse are easily reversible (e.g., create → delete,
|
|
596
|
+
attach → detach, enable → disable). These are classified as medium risk when
|
|
597
|
+
they don't also destroy data.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
cmd_lower: Lowercased command string.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
True if the command matches a reversible operation prefix.
|
|
604
|
+
"""
|
|
605
|
+
return any(prefix in cmd_lower for prefix in REVERSIBLE_OPERATIONS)
|
|
606
|
+
|
|
607
|
+
def _destroys_data(self, cmd_lower: str) -> bool:
|
|
608
|
+
"""Check if the command destroys data or access.
|
|
609
|
+
|
|
610
|
+
Operations that eliminate data or access require manual recreation and
|
|
611
|
+
are always classified as high risk. This includes deleting storage,
|
|
612
|
+
terminating instances, revoking security group rules, etc.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
cmd_lower: Lowercased command string.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
True if the command matches a data-destroying pattern.
|
|
619
|
+
"""
|
|
620
|
+
return any(pattern in cmd_lower for pattern in DATA_DESTROYING_PATTERNS)
|
|
621
|
+
|
|
622
|
+
def _has_mutation_verb(self, cmd_lower: str) -> bool:
|
|
623
|
+
"""Check if the command contains verbs that suggest state mutation.
|
|
624
|
+
|
|
625
|
+
Used as a fallback when no specific pattern matches: if a command has
|
|
626
|
+
a mutation verb, it should NOT be classified as low (doubt → upgrade).
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
cmd_lower: Lowercased command string.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
True if the command contains any mutation verb.
|
|
633
|
+
"""
|
|
634
|
+
return any(verb in cmd_lower for verb in MUTATION_VERBS)
|
|
635
|
+
|
|
636
|
+
def _max_risk(self, *levels: RiskLevel) -> RiskLevel:
|
|
637
|
+
"""Return the highest risk level from the given levels.
|
|
638
|
+
|
|
639
|
+
Uses RISK_ORDER to compare levels numerically.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
*levels: One or more risk levels to compare.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
The highest risk level among the inputs.
|
|
646
|
+
"""
|
|
647
|
+
return max(levels, key=lambda lvl: RISK_ORDER.get(lvl, 0))
|
|
648
|
+
|
|
649
|
+
def _is_destructive(self, command: str) -> bool:
|
|
650
|
+
"""Check if a command contains destructive patterns.
|
|
651
|
+
|
|
652
|
+
Scans the command against the full DESTRUCTIVE_PATTERNS list
|
|
653
|
+
independently of the LLM's risk assessment.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
command: The AWS CLI command string to check.
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
True if any destructive pattern is found in the command.
|
|
660
|
+
"""
|
|
661
|
+
cmd_lower = command.lower()
|
|
662
|
+
return any(pattern in cmd_lower for pattern in DESTRUCTIVE_PATTERNS)
|
|
663
|
+
|
|
664
|
+
def _upgrade_risk(self, current: RiskLevel) -> RiskLevel:
|
|
665
|
+
"""Upgrade risk level when destructive patterns are detected.
|
|
666
|
+
|
|
667
|
+
Ladder:
|
|
668
|
+
- low → high
|
|
669
|
+
- medium → high
|
|
670
|
+
- high → critical
|
|
671
|
+
- critical → critical (already at max)
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
current: The current risk level before upgrade.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
The upgraded risk level.
|
|
678
|
+
"""
|
|
679
|
+
ladder: dict[RiskLevel, RiskLevel] = {
|
|
680
|
+
"low": "high",
|
|
681
|
+
"medium": "high",
|
|
682
|
+
"high": "critical",
|
|
683
|
+
"critical": "critical",
|
|
684
|
+
}
|
|
685
|
+
return ladder[current]
|
|
686
|
+
|
|
687
|
+
def _build_confirmation_prompt(self, translation: Translation, risk: RiskLevel) -> str:
|
|
688
|
+
"""Build a human-readable confirmation prompt based on risk level.
|
|
689
|
+
|
|
690
|
+
- low: empty (no confirmation needed)
|
|
691
|
+
- medium: show command + explanation, ask Y/N
|
|
692
|
+
- high: show command + affected resources + cost, ask typed confirmation
|
|
693
|
+
- critical: warning banner + affected resources + cost + "yes-i-understand"
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
translation: The translation containing command metadata.
|
|
697
|
+
risk: The assessed risk level.
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
Formatted confirmation prompt string.
|
|
701
|
+
"""
|
|
702
|
+
if risk == "critical":
|
|
703
|
+
resources = (
|
|
704
|
+
"\n".join(f" - {r}" for r in translation.affected_resources)
|
|
705
|
+
or " - (unknown resources)"
|
|
706
|
+
)
|
|
707
|
+
return (
|
|
708
|
+
"\u26a0\ufe0f CRITICAL OPERATION\n\n"
|
|
709
|
+
"This action is IRREVERSIBLE and will affect:\n"
|
|
710
|
+
f"{resources}\n\n"
|
|
711
|
+
f"Estimated cost: {translation.estimated_cost}\n"
|
|
712
|
+
f"Command: {translation.command}\n\n"
|
|
713
|
+
"A dry-run will be performed first.\n"
|
|
714
|
+
"Type 'yes-i-understand' to proceed:"
|
|
715
|
+
)
|
|
716
|
+
elif risk == "high":
|
|
717
|
+
resources = ", ".join(translation.affected_resources) or "unknown"
|
|
718
|
+
return (
|
|
719
|
+
"\u26a0\ufe0f HIGH RISK OPERATION\n\n"
|
|
720
|
+
f"Command: {translation.command}\n"
|
|
721
|
+
f"Affected: {resources}\n"
|
|
722
|
+
f"Cost: {translation.estimated_cost}\n\n"
|
|
723
|
+
"Type the resource name to confirm:"
|
|
724
|
+
)
|
|
725
|
+
elif risk == "medium":
|
|
726
|
+
return (
|
|
727
|
+
f"Command: {translation.command}\n"
|
|
728
|
+
f"Explanation: {translation.explanation}\n\n"
|
|
729
|
+
"Proceed? [Y/n]:"
|
|
730
|
+
)
|
|
731
|
+
# low — no confirmation needed
|
|
732
|
+
return ""
|
|
733
|
+
|
|
734
|
+
def _estimate_costs(self, translation: Translation) -> dict[str, str]:
|
|
735
|
+
"""Estimate costs for the operation via rule-based matching.
|
|
736
|
+
|
|
737
|
+
Uses simple pattern matching against common resource-creating commands
|
|
738
|
+
to provide a cost breakdown by component.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
translation: The translation to estimate costs for.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Dictionary mapping cost component names to estimated values.
|
|
745
|
+
"""
|
|
746
|
+
cost_map: dict[str, str] = {
|
|
747
|
+
"ec2 run-instances": "EC2 hourly cost",
|
|
748
|
+
"rds create-db-instance": "RDS hourly cost",
|
|
749
|
+
"lambda create-function": "Lambda invocation cost",
|
|
750
|
+
"s3 mb": "S3 storage cost",
|
|
751
|
+
"s3api create-bucket": "S3 storage cost",
|
|
752
|
+
"elasticache create-cluster": "ElastiCache hourly cost",
|
|
753
|
+
"ecs create-service": "ECS task cost",
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
breakdown: dict[str, str] = {}
|
|
757
|
+
cmd_lower = translation.command.lower()
|
|
758
|
+
for pattern, cost_type in cost_map.items():
|
|
759
|
+
if pattern in cmd_lower:
|
|
760
|
+
breakdown[cost_type] = translation.estimated_cost
|
|
761
|
+
|
|
762
|
+
return breakdown
|
|
763
|
+
|
|
764
|
+
# ------------------------------------------------------------------
|
|
765
|
+
# Dry-run injection
|
|
766
|
+
# ------------------------------------------------------------------
|
|
767
|
+
|
|
768
|
+
def inject_dry_run(self, command: str) -> DryRunResult:
|
|
769
|
+
"""Inject the appropriate dry-run mechanism for the given command.
|
|
770
|
+
|
|
771
|
+
Determines the AWS service from the command and applies the correct
|
|
772
|
+
dry-run strategy:
|
|
773
|
+
- EC2: Appends ``--dry-run`` flag (native support).
|
|
774
|
+
- CloudFormation: Transforms ``create-stack`` to ``create-change-set``
|
|
775
|
+
and ``update-stack`` to ``create-change-set --change-set-type UPDATE``.
|
|
776
|
+
- RDS, S3API, IAM, Lambda: No native dry-run — marks as preview_only.
|
|
777
|
+
- Unknown/unsupported services: Also marked as preview_only.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
command: The AWS CLI command string to inject dry-run into.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
DryRunResult with the modified command and metadata.
|
|
784
|
+
"""
|
|
785
|
+
service = self._extract_service(command)
|
|
786
|
+
|
|
787
|
+
if service == "ec2":
|
|
788
|
+
return self._inject_ec2_dry_run(command)
|
|
789
|
+
|
|
790
|
+
if service == "cloudformation":
|
|
791
|
+
return self._inject_cfn_dry_run(command)
|
|
792
|
+
|
|
793
|
+
# Services in DRY_RUN_SERVICES but without native dry-run
|
|
794
|
+
# (rds, s3api, iam, lambda) → preview only
|
|
795
|
+
if service in DRY_RUN_SERVICES:
|
|
796
|
+
notes = self._get_preview_notes(service)
|
|
797
|
+
return DryRunResult(
|
|
798
|
+
command=command,
|
|
799
|
+
is_native_dry_run=False,
|
|
800
|
+
preview_only=True,
|
|
801
|
+
dry_run_notes=notes,
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Service not in DRY_RUN_SERVICES → preview only
|
|
805
|
+
return DryRunResult(
|
|
806
|
+
command=command,
|
|
807
|
+
is_native_dry_run=False,
|
|
808
|
+
preview_only=True,
|
|
809
|
+
dry_run_notes=(
|
|
810
|
+
f"Service '{service}' does not support dry-run. "
|
|
811
|
+
"Command shown for review only — will NOT be executed."
|
|
812
|
+
),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
def _extract_service(self, command: str) -> str:
|
|
816
|
+
"""Extract the AWS service name from a command string.
|
|
817
|
+
|
|
818
|
+
Expects commands in the format ``aws <service> <action> ...``.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
command: The AWS CLI command string.
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
The service name (e.g. "ec2", "s3api"), or empty string if
|
|
825
|
+
the command cannot be parsed.
|
|
826
|
+
"""
|
|
827
|
+
parts = command.strip().split()
|
|
828
|
+
# Expected: ["aws", "<service>", ...]
|
|
829
|
+
if len(parts) >= 2 and parts[0].lower() == "aws":
|
|
830
|
+
return parts[1].lower()
|
|
831
|
+
return ""
|
|
832
|
+
|
|
833
|
+
def _inject_ec2_dry_run(self, command: str) -> DryRunResult:
|
|
834
|
+
"""Inject --dry-run flag for EC2 commands.
|
|
835
|
+
|
|
836
|
+
EC2 supports native --dry-run for most mutating operations. The
|
|
837
|
+
flag causes the API to validate the request without making changes.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
command: The EC2 AWS CLI command.
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
DryRunResult with --dry-run appended.
|
|
844
|
+
"""
|
|
845
|
+
# Avoid duplicate injection if --dry-run already present
|
|
846
|
+
if "--dry-run" in command:
|
|
847
|
+
return DryRunResult(
|
|
848
|
+
command=command,
|
|
849
|
+
is_native_dry_run=True,
|
|
850
|
+
preview_only=False,
|
|
851
|
+
dry_run_notes=(
|
|
852
|
+
"EC2 --dry-run: validates permissions and parameters without making changes."
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
modified_command = f"{command} --dry-run"
|
|
857
|
+
return DryRunResult(
|
|
858
|
+
command=modified_command,
|
|
859
|
+
is_native_dry_run=True,
|
|
860
|
+
preview_only=False,
|
|
861
|
+
dry_run_notes=(
|
|
862
|
+
"EC2 --dry-run: validates permissions and parameters without making changes."
|
|
863
|
+
),
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def _inject_cfn_dry_run(self, command: str) -> DryRunResult:
|
|
867
|
+
"""Inject change set mechanism for CloudFormation commands.
|
|
868
|
+
|
|
869
|
+
CloudFormation uses change sets as the dry-run mechanism:
|
|
870
|
+
- ``create-stack`` is transformed to ``create-change-set``
|
|
871
|
+
- ``update-stack`` is transformed to
|
|
872
|
+
``create-change-set --change-set-type UPDATE``
|
|
873
|
+
|
|
874
|
+
For other CloudFormation commands, preview-only mode is used.
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
command: The CloudFormation AWS CLI command.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
DryRunResult with the appropriate change set transformation.
|
|
881
|
+
"""
|
|
882
|
+
cmd_lower = command.lower()
|
|
883
|
+
|
|
884
|
+
if _CFN_CREATE_PATTERN in cmd_lower:
|
|
885
|
+
modified = command.replace("create-stack", "create-change-set")
|
|
886
|
+
return DryRunResult(
|
|
887
|
+
command=modified,
|
|
888
|
+
is_native_dry_run=True,
|
|
889
|
+
preview_only=False,
|
|
890
|
+
dry_run_notes=(
|
|
891
|
+
"CloudFormation: transformed create-stack to create-change-set. "
|
|
892
|
+
"Review the change set before executing."
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
if _CFN_UPDATE_PATTERN in cmd_lower:
|
|
897
|
+
modified = command.replace("update-stack", "create-change-set")
|
|
898
|
+
modified = f"{modified} --change-set-type UPDATE"
|
|
899
|
+
return DryRunResult(
|
|
900
|
+
command=modified,
|
|
901
|
+
is_native_dry_run=True,
|
|
902
|
+
preview_only=False,
|
|
903
|
+
dry_run_notes=(
|
|
904
|
+
"CloudFormation: transformed update-stack to "
|
|
905
|
+
"create-change-set --change-set-type UPDATE. "
|
|
906
|
+
"Review the change set before executing."
|
|
907
|
+
),
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
# Other CloudFormation commands (delete-stack, etc.) → preview only
|
|
911
|
+
return DryRunResult(
|
|
912
|
+
command=command,
|
|
913
|
+
is_native_dry_run=False,
|
|
914
|
+
preview_only=True,
|
|
915
|
+
dry_run_notes=(
|
|
916
|
+
"CloudFormation: this operation does not support change sets. "
|
|
917
|
+
"Command shown for review only — will NOT be executed."
|
|
918
|
+
),
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
def _get_preview_notes(self, service: str) -> str:
|
|
922
|
+
"""Get human-readable preview notes for services without native dry-run.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
service: The AWS service name (e.g. "rds", "iam").
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
Explanation string for the user.
|
|
929
|
+
"""
|
|
930
|
+
notes_map: dict[str, str] = {
|
|
931
|
+
"rds": (
|
|
932
|
+
"RDS does not support --dry-run. "
|
|
933
|
+
"Command shown for review only — will NOT be executed."
|
|
934
|
+
),
|
|
935
|
+
"s3api": (
|
|
936
|
+
"S3 API does not support --dry-run. "
|
|
937
|
+
"Command shown for review only — will NOT be executed."
|
|
938
|
+
),
|
|
939
|
+
"iam": (
|
|
940
|
+
"IAM does not support --dry-run. "
|
|
941
|
+
"Command shown for review only — will NOT be executed."
|
|
942
|
+
),
|
|
943
|
+
"lambda": (
|
|
944
|
+
"Lambda does not support --dry-run. "
|
|
945
|
+
"Command shown for review only — will NOT be executed."
|
|
946
|
+
),
|
|
947
|
+
}
|
|
948
|
+
return notes_map.get(
|
|
949
|
+
service,
|
|
950
|
+
f"Service '{service}' does not support dry-run. "
|
|
951
|
+
"Command shown for review only — will NOT be executed.",
|
|
952
|
+
)
|