iam-policy-validator 1.14.7__py3-none-any.whl → 1.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.
Files changed (42) hide show
  1. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/METADATA +16 -11
  2. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/RECORD +41 -28
  3. iam_policy_validator-1.15.1.dist-info/entry_points.txt +4 -0
  4. iam_validator/__version__.py +1 -1
  5. iam_validator/checks/__init__.py +2 -0
  6. iam_validator/checks/action_validation.py +91 -27
  7. iam_validator/checks/not_action_not_resource.py +163 -0
  8. iam_validator/checks/resource_validation.py +132 -81
  9. iam_validator/checks/wildcard_resource.py +136 -6
  10. iam_validator/commands/__init__.py +3 -0
  11. iam_validator/commands/cache.py +66 -24
  12. iam_validator/commands/completion.py +94 -15
  13. iam_validator/commands/mcp.py +210 -0
  14. iam_validator/commands/query.py +489 -65
  15. iam_validator/core/aws_service/__init__.py +5 -1
  16. iam_validator/core/aws_service/cache.py +20 -0
  17. iam_validator/core/aws_service/fetcher.py +180 -11
  18. iam_validator/core/aws_service/storage.py +14 -6
  19. iam_validator/core/aws_service/validators.py +68 -51
  20. iam_validator/core/check_registry.py +100 -35
  21. iam_validator/core/config/aws_global_conditions.py +18 -9
  22. iam_validator/core/config/check_documentation.py +104 -51
  23. iam_validator/core/config/config_loader.py +39 -3
  24. iam_validator/core/config/defaults.py +6 -0
  25. iam_validator/core/constants.py +11 -4
  26. iam_validator/core/models.py +39 -14
  27. iam_validator/mcp/__init__.py +162 -0
  28. iam_validator/mcp/models.py +118 -0
  29. iam_validator/mcp/server.py +2928 -0
  30. iam_validator/mcp/session_config.py +319 -0
  31. iam_validator/mcp/templates/__init__.py +79 -0
  32. iam_validator/mcp/templates/builtin.py +856 -0
  33. iam_validator/mcp/tools/__init__.py +72 -0
  34. iam_validator/mcp/tools/generation.py +888 -0
  35. iam_validator/mcp/tools/org_config_tools.py +263 -0
  36. iam_validator/mcp/tools/query.py +395 -0
  37. iam_validator/mcp/tools/validation.py +376 -0
  38. iam_validator/sdk/__init__.py +2 -0
  39. iam_validator/sdk/policy_utils.py +31 -5
  40. iam_policy_validator-1.14.7.dist-info/entry_points.txt +0 -2
  41. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/WHEEL +0 -0
  42. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -20,6 +20,103 @@ class ResourceValidationCheck(PolicyCheck):
20
20
  description: ClassVar[str] = "Validates ARN format for resources"
21
21
  default_severity: ClassVar[str] = "error"
22
22
 
23
+ def _validate_resource(
24
+ self,
25
+ resource: str,
26
+ arn_pattern: re.Pattern[str],
27
+ allow_template_variables: bool,
28
+ config: CheckConfig,
29
+ statement_sid: str | None,
30
+ statement_idx: int,
31
+ line_number: int | None,
32
+ field_name: str,
33
+ ) -> ValidationIssue | None:
34
+ """Validate a single resource ARN and return an issue if invalid.
35
+
36
+ Args:
37
+ resource: The resource ARN string to validate
38
+ arn_pattern: Compiled regex pattern for ARN validation
39
+ allow_template_variables: Whether to allow template variables
40
+ config: Check configuration
41
+ statement_sid: Statement ID for error reporting
42
+ statement_idx: Statement index for error reporting
43
+ line_number: Line number for error reporting
44
+ field_name: Field name ("resource" or "not_resource") for error reporting
45
+
46
+ Returns:
47
+ ValidationIssue if resource is invalid, None otherwise
48
+ """
49
+ # Skip wildcard resources (handled by security checks)
50
+ if resource == "*":
51
+ return None
52
+
53
+ # Validate ARN length to prevent ReDoS attacks
54
+ if len(resource) > MAX_ARN_LENGTH:
55
+ return ValidationIssue(
56
+ severity=self.get_severity(config),
57
+ statement_sid=statement_sid,
58
+ statement_index=statement_idx,
59
+ issue_type="invalid_resource",
60
+ message=f"Resource ARN exceeds maximum length ({len(resource)} > {MAX_ARN_LENGTH}): {resource[:100]}...",
61
+ resource=resource[:100] + "...",
62
+ suggestion="`ARN` is too long and may be invalid",
63
+ line_number=line_number,
64
+ field_name=field_name,
65
+ )
66
+
67
+ # Check if resource contains template variables
68
+ has_templates = has_template_variables(resource)
69
+
70
+ # If template variables are found and allowed, normalize them for validation
71
+ validation_resource = resource
72
+ if has_templates and allow_template_variables:
73
+ validation_resource = normalize_template_variables(resource)
74
+
75
+ # Validate ARN format
76
+ try:
77
+ if not arn_pattern.match(validation_resource):
78
+ # If original resource had templates and normalization didn't help,
79
+ # provide a more informative message
80
+ if has_templates and allow_template_variables:
81
+ return ValidationIssue(
82
+ severity=self.get_severity(config),
83
+ statement_sid=statement_sid,
84
+ statement_index=statement_idx,
85
+ issue_type="invalid_resource",
86
+ message=f"Invalid `ARN` format even after normalizing template variables: `{resource}`",
87
+ resource=resource,
88
+ suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource` (template variables like `${aws_account_id}` are supported)",
89
+ line_number=line_number,
90
+ field_name=field_name,
91
+ )
92
+ else:
93
+ return ValidationIssue(
94
+ severity=self.get_severity(config),
95
+ statement_sid=statement_sid,
96
+ statement_index=statement_idx,
97
+ issue_type="invalid_resource",
98
+ message=f"Invalid `ARN` format: `{resource}`",
99
+ resource=resource,
100
+ suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource`",
101
+ line_number=line_number,
102
+ field_name=field_name,
103
+ )
104
+ except Exception: # pylint: disable=broad-exception-caught
105
+ # If regex matching fails (shouldn't happen with length check), treat as invalid
106
+ return ValidationIssue(
107
+ severity=self.get_severity(config),
108
+ statement_sid=statement_sid,
109
+ statement_index=statement_idx,
110
+ issue_type="invalid_resource",
111
+ message=f"Could not validate `ARN` format: `{resource}`",
112
+ resource=resource,
113
+ suggestion="`ARN` validation failed - may contain unexpected characters",
114
+ line_number=line_number,
115
+ field_name=field_name,
116
+ )
117
+
118
+ return None
119
+
23
120
  async def execute(
24
121
  self,
25
122
  statement: Statement,
@@ -27,11 +124,14 @@ class ResourceValidationCheck(PolicyCheck):
27
124
  fetcher: AWSServiceFetcher,
28
125
  config: CheckConfig,
29
126
  ) -> list[ValidationIssue]:
30
- """Execute resource ARN validation on a statement."""
127
+ """Execute resource ARN validation on a statement.
128
+
129
+ Validates both Resource and NotResource fields to ensure all specified
130
+ ARNs follow the correct format.
131
+ """
132
+ del fetcher # Unused
31
133
  issues = []
32
134
 
33
- # Get resources from statement
34
- resources = statement.get_resources()
35
135
  statement_sid = statement.sid
36
136
  line_number = statement.line_number
37
137
 
@@ -53,83 +153,34 @@ class ResourceValidationCheck(PolicyCheck):
53
153
  config.config.get("allow_template_variables", True),
54
154
  )
55
155
 
56
- for resource in resources:
57
- # Skip wildcard resources (handled by security checks)
58
- if resource == "*":
59
- continue
60
-
61
- # Validate ARN length to prevent ReDoS attacks
62
- if len(resource) > MAX_ARN_LENGTH:
63
- issues.append(
64
- ValidationIssue(
65
- severity=self.get_severity(config),
66
- statement_sid=statement_sid,
67
- statement_index=statement_idx,
68
- issue_type="invalid_resource",
69
- message=f"Resource ARN exceeds maximum length ({len(resource)} > {MAX_ARN_LENGTH}): {resource[:100]}...",
70
- resource=resource[:100] + "...",
71
- suggestion="`ARN` is too long and may be invalid",
72
- line_number=line_number,
73
- field_name="resource",
74
- )
75
- )
76
- continue
77
-
78
- # Check if resource contains template variables
79
- has_templates = has_template_variables(resource)
80
-
81
- # If template variables are found and allowed, normalize them for validation
82
- validation_resource = resource
83
- if has_templates and allow_template_variables:
84
- validation_resource = normalize_template_variables(resource)
85
-
86
- # Validate ARN format
87
- try:
88
- if not arn_pattern.match(validation_resource):
89
- # If original resource had templates and normalization didn't help,
90
- # provide a more informative message
91
- if has_templates and allow_template_variables:
92
- issues.append(
93
- ValidationIssue(
94
- severity=self.get_severity(config),
95
- statement_sid=statement_sid,
96
- statement_index=statement_idx,
97
- issue_type="invalid_resource",
98
- message=f"Invalid `ARN` format even after normalizing template variables: `{resource}`",
99
- resource=resource,
100
- suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource` (template variables like `${aws_account_id}` are supported)",
101
- line_number=line_number,
102
- field_name="resource",
103
- )
104
- )
105
- else:
106
- issues.append(
107
- ValidationIssue(
108
- severity=self.get_severity(config),
109
- statement_sid=statement_sid,
110
- statement_index=statement_idx,
111
- issue_type="invalid_resource",
112
- message=f"Invalid `ARN` format: `{resource}`",
113
- resource=resource,
114
- suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource`",
115
- line_number=line_number,
116
- field_name="resource",
117
- )
118
- )
119
- except Exception: # pylint: disable=broad-exception-caught
120
- # If regex matching fails (shouldn't happen with length check), treat as invalid
121
- issues.append(
122
- ValidationIssue(
123
- severity=self.get_severity(config),
124
- statement_sid=statement_sid,
125
- statement_index=statement_idx,
126
- issue_type="invalid_resource",
127
- message=f"Could not validate `ARN` format: `{resource}`",
128
- resource=resource,
129
- suggestion="`ARN` validation failed - may contain unexpected characters",
130
- line_number=line_number,
131
- field_name="resource",
132
- )
133
- )
156
+ # Validate Resource field
157
+ for resource in statement.get_resources():
158
+ issue = self._validate_resource(
159
+ resource=resource,
160
+ arn_pattern=arn_pattern,
161
+ allow_template_variables=allow_template_variables,
162
+ config=config,
163
+ statement_sid=statement_sid,
164
+ statement_idx=statement_idx,
165
+ line_number=line_number,
166
+ field_name="resource",
167
+ )
168
+ if issue:
169
+ issues.append(issue)
170
+
171
+ # Validate NotResource field (same validation - typos in NotResource are equally problematic)
172
+ for resource in statement.get_not_resources():
173
+ issue = self._validate_resource(
174
+ resource=resource,
175
+ arn_pattern=arn_pattern,
176
+ allow_template_variables=allow_template_variables,
177
+ config=config,
178
+ statement_sid=statement_sid,
179
+ statement_idx=statement_idx,
180
+ line_number=line_number,
181
+ field_name="not_resource",
182
+ )
183
+ if issue:
184
+ issues.append(issue)
134
185
 
135
186
  return issues
@@ -1,4 +1,13 @@
1
- """Wildcard resource check - detects Resource: '*' in IAM policies."""
1
+ """Wildcard resource check - detects Resource: '*' in IAM policies.
2
+
3
+ This check detects statements with Resource: '*' that could grant overly broad access.
4
+ It intelligently adjusts severity based on conditions that restrict resource scope:
5
+
6
+ - Global resource-scoping conditions (aws:ResourceAccount, aws:ResourceOrgID, aws:ResourceOrgPaths)
7
+ always lower severity since they apply to all services.
8
+ - Resource tag conditions (aws:ResourceTag/*) lower severity only if ALL actions in the
9
+ statement support the condition (validated against AWS service definitions).
10
+ """
2
11
 
3
12
  import asyncio
4
13
  import logging
@@ -8,7 +17,9 @@ from iam_validator.checks.utils.action_parser import get_action_case_insensitive
8
17
  from iam_validator.checks.utils.wildcard_expansion import expand_wildcard_actions
9
18
  from iam_validator.core.aws_service import AWSServiceFetcher
10
19
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
20
+ from iam_validator.core.config.aws_global_conditions import GLOBAL_RESOURCE_SCOPING_CONDITION_KEYS
11
21
  from iam_validator.core.models import ActionDetail, ServiceDetail, Statement, ValidationIssue
22
+ from iam_validator.sdk.policy_utils import extract_condition_keys_from_statement
12
23
 
13
24
  logger = logging.getLogger(__name__)
14
25
 
@@ -70,6 +81,54 @@ def clear_resource_support_cache() -> None:
70
81
  _action_access_level_cache.clear()
71
82
 
72
83
 
84
+ def _has_global_resource_scoping(condition_keys: set[str]) -> bool:
85
+ """Check if any global resource-scoping conditions are present.
86
+
87
+ Args:
88
+ condition_keys: Set of condition keys from the statement
89
+
90
+ Returns:
91
+ True if any global resource-scoping condition is present
92
+ """
93
+ return bool(condition_keys & GLOBAL_RESOURCE_SCOPING_CONDITION_KEYS)
94
+
95
+
96
+ async def _validate_condition_key_support(
97
+ actions: list[str],
98
+ condition_key: str,
99
+ fetcher: AWSServiceFetcher,
100
+ ) -> tuple[bool, list[str]]:
101
+ """Validate if all actions support a specific condition key.
102
+
103
+ This is a generic function that works for any condition key,
104
+ including aws:ResourceTag/*, service-specific tags, etc.
105
+
106
+ Uses parallel execution for performance when validating multiple actions.
107
+
108
+ Args:
109
+ actions: List of actions to validate
110
+ condition_key: The condition key to check support for
111
+ fetcher: AWS service fetcher for looking up service definitions
112
+
113
+ Returns:
114
+ Tuple of (all_support, unsupported_actions) where all_support is True
115
+ if all actions support the condition key
116
+ """
117
+ # Validate all actions in parallel for performance using centralized fetcher method
118
+ results = await asyncio.gather(
119
+ *[fetcher.is_condition_key_supported(action, condition_key) for action in actions],
120
+ return_exceptions=True,
121
+ )
122
+
123
+ unsupported = []
124
+ for action, result in zip(actions, results):
125
+ # Treat exceptions as unsupported (conservative)
126
+ if isinstance(result, BaseException) or not result:
127
+ unsupported.append(action)
128
+
129
+ return (len(unsupported) == 0, unsupported)
130
+
131
+
73
132
  class WildcardResourceCheck(PolicyCheck):
74
133
  """Checks for wildcard resources (Resource: '*') which grant access to all resources."""
75
134
 
@@ -152,6 +211,15 @@ class WildcardResourceCheck(PolicyCheck):
152
211
  return issues
153
212
 
154
213
  # Flag the issue if actions are not all allowed or no allowed_wildcards configured
214
+ # First, determine if severity should be adjusted based on conditions
215
+ base_severity = self.get_severity(config)
216
+ adjusted_severity, adjustment_reason = await self._determine_severity_adjustment(
217
+ statement,
218
+ actions_requiring_specific_resources,
219
+ fetcher,
220
+ base_severity,
221
+ )
222
+
155
223
  # Build a helpful message showing which actions require specific resources
156
224
  custom_message = config.config.get("message")
157
225
  if custom_message:
@@ -166,10 +234,11 @@ class WildcardResourceCheck(PolicyCheck):
166
234
  else:
167
235
  action_list = ", ".join(f"`{a}`" for a in sorted_actions[:5])
168
236
  action_list += f" (+{len(sorted_actions) - 5} more)"
169
- message = (
170
- f'Statement applies to all resources `"*"`. '
171
- f"Actions that support resource-level permissions: {action_list}"
172
- )
237
+ message = 'Statement applies to all resources (`"*"`)'
238
+
239
+ # Add adjustment reason if present
240
+ if adjustment_reason:
241
+ message += f". {adjustment_reason}"
173
242
 
174
243
  suggestion = config.config.get(
175
244
  "suggestion", "Replace wildcard with specific resource ARNs"
@@ -178,7 +247,7 @@ class WildcardResourceCheck(PolicyCheck):
178
247
 
179
248
  issues.append(
180
249
  ValidationIssue(
181
- severity=self.get_severity(config),
250
+ severity=adjusted_severity,
182
251
  statement_sid=statement.sid,
183
252
  statement_index=statement_idx,
184
253
  issue_type="overly_permissive",
@@ -237,6 +306,67 @@ class WildcardResourceCheck(PolicyCheck):
237
306
 
238
307
  return frozenset(expanded_actions)
239
308
 
309
+ async def _determine_severity_adjustment(
310
+ self,
311
+ statement: Statement,
312
+ actions: list[str],
313
+ fetcher: AWSServiceFetcher,
314
+ base_severity: str,
315
+ ) -> tuple[str, str | None]:
316
+ """Determine if severity should be adjusted based on resource-scoping conditions.
317
+
318
+ This method checks if the statement has conditions that meaningfully restrict
319
+ resource scope:
320
+ 1. Global resource-scoping conditions (aws:ResourceAccount, etc.) always lower severity
321
+ 2. Resource tag conditions (aws:ResourceTag/*) lower severity only if ALL actions support them
322
+
323
+ Args:
324
+ statement: The policy statement being checked
325
+ actions: List of actions that require specific resources
326
+ fetcher: AWS service fetcher for validating condition key support
327
+ base_severity: The default severity level
328
+
329
+ Returns:
330
+ Tuple of (adjusted_severity, reason) where reason explains the adjustment
331
+ """
332
+ condition_keys = extract_condition_keys_from_statement(statement)
333
+ if not condition_keys:
334
+ return (base_severity, None)
335
+
336
+ # Check for global resource-scoping conditions (always valid for all services)
337
+ if _has_global_resource_scoping(condition_keys):
338
+ global_keys = condition_keys & GLOBAL_RESOURCE_SCOPING_CONDITION_KEYS
339
+ return (
340
+ "low",
341
+ f"Severity lowered: resource scope restricted by `{', '.join(sorted(global_keys))}`",
342
+ )
343
+
344
+ # Check for aws:ResourceTag conditions (must validate per-action support)
345
+ resource_tag_keys = {k for k in condition_keys if k.startswith("aws:ResourceTag/")}
346
+ if resource_tag_keys:
347
+ # Use the first tag key for validation (all should have same support pattern)
348
+ tag_key = next(iter(resource_tag_keys))
349
+ all_support, unsupported = await _validate_condition_key_support(
350
+ actions, tag_key, fetcher
351
+ )
352
+ if all_support:
353
+ return (
354
+ "low",
355
+ f"Severity lowered: resource scope restricted by `{', '.join(sorted(resource_tag_keys))}`",
356
+ )
357
+ else:
358
+ # Tag condition present but not all actions support it
359
+ unsupported_display = unsupported[:3]
360
+ more = f" (+{len(unsupported) - 3} more)" if len(unsupported) > 3 else ""
361
+ return (
362
+ base_severity,
363
+ f"Note: `aws:ResourceTag` condition found but these actions don't support "
364
+ f"resource tags: `{', '.join(unsupported_display)}`{more}",
365
+ )
366
+
367
+ # Has conditions but none that scope resources
368
+ return (base_severity, None)
369
+
240
370
  async def _filter_actions_requiring_resources(
241
371
  self, actions: list[str], fetcher: AWSServiceFetcher
242
372
  ) -> list[str]:
@@ -4,6 +4,7 @@ from .analyze import AnalyzeCommand
4
4
  from .cache import CacheCommand
5
5
  from .completion import CompletionCommand
6
6
  from .download_services import DownloadServicesCommand
7
+ from .mcp import MCPCommand
7
8
  from .post_to_pr import PostToPRCommand
8
9
  from .query import QueryCommand
9
10
  from .validate import ValidateCommand
@@ -17,6 +18,7 @@ ALL_COMMANDS = [
17
18
  DownloadServicesCommand(),
18
19
  QueryCommand(),
19
20
  CompletionCommand(),
21
+ MCPCommand(),
20
22
  ]
21
23
 
22
24
  __all__ = [
@@ -27,5 +29,6 @@ __all__ = [
27
29
  "DownloadServicesCommand",
28
30
  "QueryCommand",
29
31
  "CompletionCommand",
32
+ "MCPCommand",
30
33
  "ALL_COMMANDS",
31
34
  ]
@@ -40,7 +40,7 @@ Examples:
40
40
  # Clear all cached AWS service definitions
41
41
  iam-validator cache clear
42
42
 
43
- # Refresh cache (clear and pre-fetch common services)
43
+ # Refresh all cached services with fresh data
44
44
  iam-validator cache refresh
45
45
 
46
46
  # Pre-fetch common AWS services
@@ -88,7 +88,7 @@ Examples:
88
88
 
89
89
  # Refresh subcommand
90
90
  refresh_parser = subparsers.add_parser(
91
- "refresh", help="Clear cache and pre-fetch common AWS services"
91
+ "refresh", help="Refresh all cached AWS services with fresh data"
92
92
  )
93
93
  refresh_parser.add_argument(
94
94
  "--config",
@@ -314,44 +314,86 @@ Examples:
314
314
  async def _refresh_cache(
315
315
  self, cache_enabled: bool, cache_ttl_seconds: int, cache_directory: str | None
316
316
  ) -> int:
317
- """Clear cache and pre-fetch common services."""
317
+ """Refresh all cached services with fresh data from AWS."""
318
318
  if not cache_enabled:
319
319
  console.print("[red]Error:[/red] Cache is disabled in config")
320
320
  console.print("Enable cache by setting 'cache_enabled: true' in your config")
321
321
  return 1
322
322
 
323
- console.print("[cyan]Refreshing cache...[/cyan]")
323
+ # Get cache directory
324
+ cache_dir = (
325
+ Path(cache_directory) if cache_directory else ServiceFileStorage.get_cache_directory()
326
+ )
327
+
328
+ if not cache_dir.exists():
329
+ console.print("[yellow]Cache directory does not exist, nothing to refresh[/yellow]")
330
+ console.print("Run 'iam-validator cache prefetch' to populate the cache first")
331
+ return 0
332
+
333
+ # Get list of cached services from cache files
334
+ cache_files = list(cache_dir.glob("*.json"))
335
+ if not cache_files:
336
+ console.print("[yellow]No services cached yet, nothing to refresh[/yellow]")
337
+ console.print("Run 'iam-validator cache prefetch' to populate the cache first")
338
+ return 0
339
+
340
+ # Extract service names from cache files
341
+ cached_services: list[str] = []
342
+ for f in cache_files:
343
+ if f.stem == "services_list":
344
+ continue # Skip the services list file, we'll refresh it separately
345
+ # Extract service name (before underscore or full name)
346
+ name = f.stem.split("_")[0] if "_" in f.stem else f.stem
347
+ cached_services.append(name)
348
+
349
+ cached_services.sort()
350
+ console.print(f"[cyan]Refreshing {len(cached_services)} cached services...[/cyan]")
324
351
 
325
- # Create fetcher and clear cache
326
352
  async with AWSServiceFetcher(
327
353
  enable_cache=cache_enabled,
328
354
  cache_ttl=cache_ttl_seconds,
329
355
  cache_dir=cache_directory,
330
- prefetch_common=False, # Don't prefetch yet, we'll do it after clearing
356
+ prefetch_common=False, # We'll refresh manually
331
357
  ) as fetcher:
332
- # Clear existing cache
333
- console.print("Clearing old cache...")
334
- await fetcher.clear_caches()
335
-
336
- # Prefetch common services
337
- console.print("Fetching fresh AWS service definitions...")
358
+ # First refresh the services list
359
+ console.print("Refreshing AWS services list...")
338
360
  services = await fetcher.fetch_services()
339
- console.print(f"[green]✓[/green] Fetched list of {len(services)} AWS services")
361
+ console.print(f"[green]✓[/green] Refreshed services list ({len(services)} services)")
362
+
363
+ # Build a set of valid service names for validation
364
+ valid_services = {svc.service for svc in services}
365
+
366
+ # Refresh each cached service
367
+ console.print(f"Refreshing {len(cached_services)} cached service definitions...")
368
+ refreshed = 0
369
+ failed = 0
370
+ skipped = 0
371
+
372
+ for service_name in cached_services:
373
+ # Skip services that no longer exist in AWS
374
+ if service_name not in valid_services:
375
+ logger.warning(f"Service '{service_name}' no longer exists, skipping")
376
+ skipped += 1
377
+ continue
340
378
 
341
- # Prefetch common services
342
- console.print("Pre-fetching common services...")
343
- prefetched = 0
344
- for service_name in fetcher.COMMON_SERVICES:
345
379
  try:
346
380
  await fetcher.fetch_service_by_name(service_name)
347
- prefetched += 1
381
+ refreshed += 1
348
382
  except Exception as e:
349
- logger.warning(f"Failed to prefetch {service_name}: {e}")
350
-
351
- console.print(f"[green]✓[/green] Pre-fetched {prefetched} common services")
352
-
353
- console.print("[green]✓[/green] Cache refreshed successfully")
354
- return 0
383
+ logger.warning(f"Failed to refresh {service_name}: {e}")
384
+ failed += 1
385
+
386
+ # Print summary
387
+ if failed == 0 and skipped == 0:
388
+ console.print(f"[green]✓[/green] Refreshed {refreshed} services successfully")
389
+ else:
390
+ console.print(
391
+ f"[yellow]![/yellow] Refreshed {refreshed} services, "
392
+ f"{failed} failed, {skipped} skipped (no longer exist)"
393
+ )
394
+
395
+ console.print("[green]✓[/green] Cache refresh complete")
396
+ return 0 if failed == 0 else 1
355
397
 
356
398
  async def _prefetch_services(
357
399
  self, cache_enabled: bool, cache_ttl_seconds: int, cache_directory: str | None