mcp-hangar 0.2.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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,710 @@
1
+ """
2
+ Input validation for the MCP Registry.
3
+
4
+ Provides comprehensive validation for all inputs at API boundaries.
5
+ Validation happens early to prevent invalid data from propagating through the system.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ import re
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ class ValidationSeverity(Enum):
15
+ """Severity level for validation issues."""
16
+
17
+ ERROR = "error"
18
+ WARNING = "warning"
19
+ INFO = "info"
20
+
21
+
22
+ @dataclass
23
+ class ValidationIssue:
24
+ """A single validation issue."""
25
+
26
+ field: str
27
+ message: str
28
+ severity: ValidationSeverity = ValidationSeverity.ERROR
29
+ value: Optional[Any] = None
30
+
31
+ def to_dict(self) -> Dict[str, Any]:
32
+ """Convert to dictionary."""
33
+ result = {
34
+ "field": self.field,
35
+ "message": self.message,
36
+ "severity": self.severity.value,
37
+ }
38
+ if self.value is not None:
39
+ # Truncate long values
40
+ str_val = str(self.value)
41
+ if len(str_val) > 100:
42
+ str_val = str_val[:100] + "..."
43
+ result["value"] = str_val
44
+ return result
45
+
46
+
47
+ @dataclass
48
+ class ValidationResult:
49
+ """Result of validation operation."""
50
+
51
+ valid: bool
52
+ issues: List[ValidationIssue] = field(default_factory=list)
53
+
54
+ def add_error(self, field: str, message: str, value: Any = None) -> None:
55
+ """Add an error issue."""
56
+ self.issues.append(
57
+ ValidationIssue(
58
+ field=field,
59
+ message=message,
60
+ severity=ValidationSeverity.ERROR,
61
+ value=value,
62
+ )
63
+ )
64
+ self.valid = False
65
+
66
+ def add_warning(self, field: str, message: str, value: Any = None) -> None:
67
+ """Add a warning issue."""
68
+ self.issues.append(
69
+ ValidationIssue(
70
+ field=field,
71
+ message=message,
72
+ severity=ValidationSeverity.WARNING,
73
+ value=value,
74
+ )
75
+ )
76
+
77
+ def merge(self, other: "ValidationResult") -> None:
78
+ """Merge another validation result into this one."""
79
+ self.issues.extend(other.issues)
80
+ if not other.valid:
81
+ self.valid = False
82
+
83
+ @property
84
+ def errors(self) -> List[ValidationIssue]:
85
+ """Get only error issues."""
86
+ return [i for i in self.issues if i.severity == ValidationSeverity.ERROR]
87
+
88
+ @property
89
+ def warnings(self) -> List[ValidationIssue]:
90
+ """Get only warning issues."""
91
+ return [i for i in self.issues if i.severity == ValidationSeverity.WARNING]
92
+
93
+ def to_dict(self) -> Dict[str, Any]:
94
+ """Convert to dictionary."""
95
+ return {
96
+ "valid": self.valid,
97
+ "issues": [i.to_dict() for i in self.issues],
98
+ "error_count": len(self.errors),
99
+ "warning_count": len(self.warnings),
100
+ }
101
+
102
+
103
+ # --- Validation Patterns ---
104
+
105
+ # Provider ID: alphanumeric, hyphens, underscores, 1-64 chars
106
+ PROVIDER_ID_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
107
+
108
+ # Tool name: alphanumeric, underscores, dots, slashes (for namespacing)
109
+ TOOL_NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_./-]{0,127}$")
110
+
111
+ # Docker image: standard docker image pattern
112
+ DOCKER_IMAGE_PATTERN = re.compile(
113
+ r"^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?::[0-9]+)?/)?"
114
+ r"[a-z0-9]+(?:[._-][a-z0-9]+)*"
115
+ r"(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*"
116
+ r"(?::[a-zA-Z0-9][a-zA-Z0-9._-]{0,127})?"
117
+ r"(?:@sha256:[a-fA-F0-9]{64})?$"
118
+ )
119
+
120
+ # Environment variable key: standard env var pattern
121
+ ENV_KEY_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
122
+
123
+ # Dangerous command patterns (potential injection)
124
+ DANGEROUS_PATTERNS = [
125
+ re.compile(r";\s*"), # Command chaining
126
+ re.compile(r"\|\s*"), # Pipe
127
+ re.compile(r"`"), # Backtick execution
128
+ re.compile(r"\$\("), # Command substitution
129
+ re.compile(r"\$\{"), # Variable expansion
130
+ re.compile(r"&&"), # AND chaining
131
+ re.compile(r"\|\|"), # OR chaining
132
+ re.compile(r">\s*"), # Redirect
133
+ re.compile(r"<\s*"), # Input redirect
134
+ re.compile(r"\n"), # Newline injection
135
+ re.compile(r"\r"), # Carriage return injection
136
+ re.compile(r"\x00"), # Null byte injection
137
+ ]
138
+
139
+ # Dangerous path patterns
140
+ DANGEROUS_PATH_PATTERNS = [
141
+ re.compile(r"\.\."), # Path traversal
142
+ re.compile(r"^/"), # Absolute path (in some contexts)
143
+ re.compile(r"~"), # Home directory expansion
144
+ ]
145
+
146
+
147
+ class InputValidator:
148
+ """
149
+ Comprehensive input validator for the MCP Registry.
150
+
151
+ Validates all inputs at API boundaries to prevent:
152
+ - Injection attacks
153
+ - Buffer overflow attempts
154
+ - Invalid data from propagating through the system
155
+ """
156
+
157
+ # Configurable limits
158
+ MAX_PROVIDER_ID_LENGTH = 64
159
+ MAX_TOOL_NAME_LENGTH = 128
160
+ MAX_ARGUMENT_SIZE_BYTES = 1_000_000 # 1MB
161
+ MAX_ARGUMENT_DEPTH = 10
162
+ MAX_COMMAND_ARGS = 100
163
+ MAX_ENV_VARS = 100
164
+ MAX_ENV_KEY_LENGTH = 256
165
+ MAX_ENV_VALUE_LENGTH = 32_768 # 32KB
166
+ MIN_TIMEOUT = 0.1
167
+ MAX_TIMEOUT = 3600.0 # 1 hour
168
+
169
+ def __init__(
170
+ self,
171
+ allow_absolute_paths: bool = False,
172
+ allowed_commands: Optional[List[str]] = None,
173
+ blocked_commands: Optional[List[str]] = None,
174
+ ):
175
+ """
176
+ Initialize validator with configuration.
177
+
178
+ Args:
179
+ allow_absolute_paths: Whether to allow absolute paths in commands
180
+ allowed_commands: Whitelist of allowed command executables (if set, only these are allowed)
181
+ blocked_commands: Blacklist of blocked command executables
182
+ """
183
+ self.allow_absolute_paths = allow_absolute_paths
184
+ self.allowed_commands = set(allowed_commands) if allowed_commands else None
185
+ self.blocked_commands = set(
186
+ blocked_commands
187
+ or [
188
+ "rm",
189
+ "rmdir",
190
+ "del",
191
+ "format", # Destructive
192
+ "sudo",
193
+ "su",
194
+ "doas", # Privilege escalation
195
+ "curl",
196
+ "wget",
197
+ "nc",
198
+ "netcat", # Network tools (potential exfiltration)
199
+ "bash",
200
+ "sh",
201
+ "zsh",
202
+ "fish", # Shells (unless explicitly allowed)
203
+ "eval",
204
+ "exec", # Dangerous builtins
205
+ ]
206
+ )
207
+
208
+ def validate_provider_id(self, provider_id: Any) -> ValidationResult:
209
+ """
210
+ Validate a provider ID.
211
+
212
+ Rules:
213
+ - Must be a non-empty string
214
+ - Must start with a letter
215
+ - Only alphanumeric, hyphens, underscores allowed
216
+ - Max 64 characters
217
+ """
218
+ result = ValidationResult(valid=True)
219
+
220
+ if provider_id is None:
221
+ result.add_error("provider_id", "Provider ID is required")
222
+ return result
223
+
224
+ if not isinstance(provider_id, str):
225
+ result.add_error("provider_id", "Provider ID must be a string", provider_id)
226
+ return result
227
+
228
+ if not provider_id:
229
+ result.add_error("provider_id", "Provider ID cannot be empty")
230
+ return result
231
+
232
+ if len(provider_id) > self.MAX_PROVIDER_ID_LENGTH:
233
+ result.add_error(
234
+ "provider_id",
235
+ f"Provider ID exceeds maximum length ({len(provider_id)} > {self.MAX_PROVIDER_ID_LENGTH})",
236
+ provider_id,
237
+ )
238
+ return result
239
+
240
+ if not PROVIDER_ID_PATTERN.match(provider_id):
241
+ result.add_error(
242
+ "provider_id",
243
+ "Provider ID must start with a letter and contain only alphanumeric characters, hyphens, and underscores",
244
+ provider_id,
245
+ )
246
+
247
+ # Check for potential injection
248
+ for pattern in DANGEROUS_PATTERNS:
249
+ if pattern.search(provider_id):
250
+ result.add_error(
251
+ "provider_id",
252
+ "Provider ID contains potentially dangerous characters",
253
+ provider_id,
254
+ )
255
+ break
256
+
257
+ return result
258
+
259
+ def validate_tool_name(self, tool_name: Any) -> ValidationResult:
260
+ """
261
+ Validate a tool name.
262
+
263
+ Rules:
264
+ - Must be a non-empty string
265
+ - Must start with a letter
266
+ - Only alphanumeric, underscores, dots, slashes allowed
267
+ - Max 128 characters
268
+ """
269
+ result = ValidationResult(valid=True)
270
+
271
+ if tool_name is None:
272
+ result.add_error("tool_name", "Tool name is required")
273
+ return result
274
+
275
+ if not isinstance(tool_name, str):
276
+ result.add_error("tool_name", "Tool name must be a string", tool_name)
277
+ return result
278
+
279
+ if not tool_name:
280
+ result.add_error("tool_name", "Tool name cannot be empty")
281
+ return result
282
+
283
+ if len(tool_name) > self.MAX_TOOL_NAME_LENGTH:
284
+ result.add_error(
285
+ "tool_name",
286
+ f"Tool name exceeds maximum length ({len(tool_name)} > {self.MAX_TOOL_NAME_LENGTH})",
287
+ tool_name,
288
+ )
289
+ return result
290
+
291
+ if not TOOL_NAME_PATTERN.match(tool_name):
292
+ result.add_error(
293
+ "tool_name",
294
+ "Tool name must start with a letter and contain only alphanumeric characters, underscores, dots, and slashes",
295
+ tool_name,
296
+ )
297
+
298
+ # Check for path traversal
299
+ if ".." in tool_name:
300
+ result.add_error(
301
+ "tool_name",
302
+ "Tool name cannot contain path traversal sequences",
303
+ tool_name,
304
+ )
305
+
306
+ return result
307
+
308
+ def validate_arguments(
309
+ self,
310
+ arguments: Any,
311
+ max_size: Optional[int] = None,
312
+ max_depth: Optional[int] = None,
313
+ ) -> ValidationResult:
314
+ """
315
+ Validate tool arguments.
316
+
317
+ Rules:
318
+ - Must be a dictionary
319
+ - Keys must be strings
320
+ - Total size must be within limit
321
+ - Nesting depth must be within limit
322
+ """
323
+ result = ValidationResult(valid=True)
324
+ max_size = max_size or self.MAX_ARGUMENT_SIZE_BYTES
325
+ max_depth = max_depth or self.MAX_ARGUMENT_DEPTH
326
+
327
+ if arguments is None:
328
+ # None is acceptable, will be treated as empty dict
329
+ return result
330
+
331
+ if not isinstance(arguments, dict):
332
+ result.add_error("arguments", "Arguments must be a dictionary", type(arguments).__name__)
333
+ return result
334
+
335
+ # Validate size
336
+ try:
337
+ import json
338
+
339
+ serialized = json.dumps(arguments)
340
+ size = len(serialized.encode("utf-8"))
341
+ if size > max_size:
342
+ result.add_error(
343
+ "arguments",
344
+ f"Arguments exceed maximum size ({size} > {max_size} bytes)",
345
+ )
346
+ return result
347
+ except (TypeError, ValueError) as e:
348
+ result.add_error("arguments", f"Arguments must be JSON-serializable: {e}")
349
+ return result
350
+
351
+ # Validate structure recursively
352
+ self._validate_argument_structure(arguments, result, "arguments", 0, max_depth)
353
+
354
+ return result
355
+
356
+ def _validate_argument_structure(
357
+ self, obj: Any, result: ValidationResult, path: str, depth: int, max_depth: int
358
+ ) -> None:
359
+ """Recursively validate argument structure."""
360
+ if depth > max_depth:
361
+ result.add_error(path, f"Arguments exceed maximum nesting depth ({max_depth})")
362
+ return
363
+
364
+ if isinstance(obj, dict):
365
+ for key, value in obj.items():
366
+ if not isinstance(key, str):
367
+ result.add_error(
368
+ f"{path}.{key}",
369
+ "Argument keys must be strings",
370
+ type(key).__name__,
371
+ )
372
+ continue
373
+
374
+ # Check for empty keys
375
+ if not key:
376
+ result.add_error(path, "Argument keys cannot be empty")
377
+ continue
378
+
379
+ self._validate_argument_structure(value, result, f"{path}.{key}", depth + 1, max_depth)
380
+
381
+ elif isinstance(obj, list):
382
+ for i, item in enumerate(obj):
383
+ self._validate_argument_structure(item, result, f"{path}[{i}]", depth + 1, max_depth)
384
+
385
+ elif isinstance(obj, str):
386
+ # Check for very long strings that might be DoS attempts
387
+ if len(obj) > 1_000_000: # 1MB string
388
+ result.add_error(path, f"String value exceeds maximum length ({len(obj)} > 1000000)")
389
+
390
+ def validate_timeout(self, timeout: Any) -> ValidationResult:
391
+ """
392
+ Validate a timeout value.
393
+
394
+ Rules:
395
+ - Must be a number (int or float)
396
+ - Must be positive
397
+ - Must be within reasonable bounds
398
+ """
399
+ result = ValidationResult(valid=True)
400
+
401
+ if timeout is None:
402
+ # Default will be used
403
+ return result
404
+
405
+ if not isinstance(timeout, (int, float)):
406
+ result.add_error("timeout", "Timeout must be a number", type(timeout).__name__)
407
+ return result
408
+
409
+ if timeout < self.MIN_TIMEOUT:
410
+ result.add_error(
411
+ "timeout",
412
+ f"Timeout must be at least {self.MIN_TIMEOUT} seconds",
413
+ timeout,
414
+ )
415
+
416
+ if timeout > self.MAX_TIMEOUT:
417
+ result.add_error("timeout", f"Timeout cannot exceed {self.MAX_TIMEOUT} seconds", timeout)
418
+
419
+ return result
420
+
421
+ def validate_command(self, command: Any) -> ValidationResult:
422
+ """
423
+ Validate a command for subprocess execution.
424
+
425
+ Rules:
426
+ - Must be a non-empty list of strings
427
+ - First element is the executable
428
+ - No dangerous patterns in arguments
429
+ - Executable must not be in blocklist
430
+ """
431
+ result = ValidationResult(valid=True)
432
+
433
+ if command is None:
434
+ result.add_error("command", "Command is required")
435
+ return result
436
+
437
+ if not isinstance(command, list):
438
+ result.add_error("command", "Command must be a list of strings", type(command).__name__)
439
+ return result
440
+
441
+ if not command:
442
+ result.add_error("command", "Command list cannot be empty")
443
+ return result
444
+
445
+ if len(command) > self.MAX_COMMAND_ARGS:
446
+ result.add_error(
447
+ "command",
448
+ f"Command has too many arguments ({len(command)} > {self.MAX_COMMAND_ARGS})",
449
+ )
450
+ return result
451
+
452
+ # Validate each element
453
+ for i, arg in enumerate(command):
454
+ if not isinstance(arg, str):
455
+ result.add_error(
456
+ f"command[{i}]",
457
+ "Command arguments must be strings",
458
+ type(arg).__name__,
459
+ )
460
+ continue
461
+
462
+ # Check for injection patterns
463
+ for pattern in DANGEROUS_PATTERNS:
464
+ if pattern.search(arg):
465
+ result.add_error(
466
+ f"command[{i}]",
467
+ "Command argument contains potentially dangerous characters",
468
+ arg,
469
+ )
470
+ break
471
+
472
+ # Validate executable (first element)
473
+ if command and isinstance(command[0], str):
474
+ executable = command[0]
475
+
476
+ # Extract base name for checking
477
+ import os
478
+
479
+ base_name = os.path.basename(executable)
480
+
481
+ # Check against blocklist
482
+ if base_name in self.blocked_commands:
483
+ result.add_error("command[0]", f"Executable '{base_name}' is not allowed", executable)
484
+
485
+ # Check against allowlist if set
486
+ if self.allowed_commands is not None:
487
+ if base_name not in self.allowed_commands:
488
+ result.add_error(
489
+ "command[0]",
490
+ f"Executable '{base_name}' is not in the allowed list",
491
+ executable,
492
+ )
493
+
494
+ # Check for absolute paths if not allowed
495
+ if not self.allow_absolute_paths and executable.startswith("/"):
496
+ result.add_warning("command[0]", "Using absolute paths is discouraged", executable)
497
+
498
+ return result
499
+
500
+ def validate_docker_image(self, image: Any) -> ValidationResult:
501
+ """
502
+ Validate a Docker image name.
503
+
504
+ Rules:
505
+ - Must be a non-empty string
506
+ - Must match Docker image naming conventions
507
+ - No dangerous patterns
508
+ """
509
+ result = ValidationResult(valid=True)
510
+
511
+ if image is None:
512
+ result.add_error("image", "Docker image is required")
513
+ return result
514
+
515
+ if not isinstance(image, str):
516
+ result.add_error("image", "Docker image must be a string", type(image).__name__)
517
+ return result
518
+
519
+ if not image:
520
+ result.add_error("image", "Docker image cannot be empty")
521
+ return result
522
+
523
+ if len(image) > 255:
524
+ result.add_error(
525
+ "image",
526
+ f"Docker image name exceeds maximum length ({len(image)} > 255)",
527
+ image,
528
+ )
529
+ return result
530
+
531
+ # Check for injection patterns
532
+ for pattern in DANGEROUS_PATTERNS:
533
+ if pattern.search(image):
534
+ result.add_error(
535
+ "image",
536
+ "Docker image contains potentially dangerous characters",
537
+ image,
538
+ )
539
+ return result
540
+
541
+ # Validate format (relaxed pattern for flexibility)
542
+ if not DOCKER_IMAGE_PATTERN.match(image):
543
+ # Try a more lenient check
544
+ if not re.match(r"^[\w.\-/:@]+$", image):
545
+ result.add_error("image", "Docker image has invalid format", image)
546
+
547
+ return result
548
+
549
+ def validate_environment_variables(self, env: Any) -> ValidationResult:
550
+ """
551
+ Validate environment variables.
552
+
553
+ Rules:
554
+ - Must be a dictionary (or None)
555
+ - Keys must be valid env var names
556
+ - Values must be strings
557
+ - Limited number of variables
558
+ """
559
+ result = ValidationResult(valid=True)
560
+
561
+ if env is None:
562
+ return result
563
+
564
+ if not isinstance(env, dict):
565
+ result.add_error("env", "Environment variables must be a dictionary", type(env).__name__)
566
+ return result
567
+
568
+ if len(env) > self.MAX_ENV_VARS:
569
+ result.add_error(
570
+ "env",
571
+ f"Too many environment variables ({len(env)} > {self.MAX_ENV_VARS})",
572
+ )
573
+ return result
574
+
575
+ for key, value in env.items():
576
+ # Validate key
577
+ if not isinstance(key, str):
578
+ result.add_error(
579
+ f"env[{key}]",
580
+ "Environment variable key must be a string",
581
+ type(key).__name__,
582
+ )
583
+ continue
584
+
585
+ if not key:
586
+ result.add_error("env", "Environment variable key cannot be empty")
587
+ continue
588
+
589
+ if len(key) > self.MAX_ENV_KEY_LENGTH:
590
+ result.add_error(
591
+ f"env[{key}]",
592
+ f"Environment variable key exceeds maximum length ({len(key)} > {self.MAX_ENV_KEY_LENGTH})",
593
+ )
594
+ continue
595
+
596
+ if not ENV_KEY_PATTERN.match(key):
597
+ result.add_error(
598
+ f"env[{key}]",
599
+ "Environment variable key has invalid format (must match [A-Za-z_][A-Za-z0-9_]*)",
600
+ key,
601
+ )
602
+
603
+ # Validate value
604
+ if not isinstance(value, str):
605
+ result.add_error(
606
+ f"env[{key}]",
607
+ "Environment variable value must be a string",
608
+ type(value).__name__,
609
+ )
610
+ continue
611
+
612
+ if len(value) > self.MAX_ENV_VALUE_LENGTH:
613
+ result.add_error(
614
+ f"env[{key}]",
615
+ f"Environment variable value exceeds maximum length ({len(value)} > {self.MAX_ENV_VALUE_LENGTH})",
616
+ )
617
+
618
+ # Check for dangerous patterns in values
619
+ for pattern in DANGEROUS_PATTERNS[:3]: # Only check most dangerous
620
+ if pattern.search(value):
621
+ result.add_warning(
622
+ f"env[{key}]",
623
+ "Environment variable value contains potentially dangerous characters",
624
+ )
625
+ break
626
+
627
+ return result
628
+
629
+ def validate_all(
630
+ self,
631
+ provider_id: Optional[str] = None,
632
+ tool_name: Optional[str] = None,
633
+ arguments: Optional[Dict[str, Any]] = None,
634
+ timeout: Optional[float] = None,
635
+ command: Optional[List[str]] = None,
636
+ image: Optional[str] = None,
637
+ env: Optional[Dict[str, str]] = None,
638
+ ) -> ValidationResult:
639
+ """
640
+ Validate multiple inputs at once.
641
+
642
+ Only validates non-None inputs.
643
+ """
644
+ result = ValidationResult(valid=True)
645
+
646
+ if provider_id is not None:
647
+ result.merge(self.validate_provider_id(provider_id))
648
+
649
+ if tool_name is not None:
650
+ result.merge(self.validate_tool_name(tool_name))
651
+
652
+ if arguments is not None:
653
+ result.merge(self.validate_arguments(arguments))
654
+
655
+ if timeout is not None:
656
+ result.merge(self.validate_timeout(timeout))
657
+
658
+ if command is not None:
659
+ result.merge(self.validate_command(command))
660
+
661
+ if image is not None:
662
+ result.merge(self.validate_docker_image(image))
663
+
664
+ if env is not None:
665
+ result.merge(self.validate_environment_variables(env))
666
+
667
+ return result
668
+
669
+
670
+ # --- Convenience Functions ---
671
+
672
+ # Global validator instance with default settings
673
+ _default_validator = InputValidator()
674
+
675
+
676
+ def validate_provider_id(provider_id: Any) -> ValidationResult:
677
+ """Validate a provider ID using default validator."""
678
+ return _default_validator.validate_provider_id(provider_id)
679
+
680
+
681
+ def validate_tool_name(tool_name: Any) -> ValidationResult:
682
+ """Validate a tool name using default validator."""
683
+ return _default_validator.validate_tool_name(tool_name)
684
+
685
+
686
+ def validate_arguments(
687
+ arguments: Any, max_size: Optional[int] = None, max_depth: Optional[int] = None
688
+ ) -> ValidationResult:
689
+ """Validate tool arguments using default validator."""
690
+ return _default_validator.validate_arguments(arguments, max_size, max_depth)
691
+
692
+
693
+ def validate_timeout(timeout: Any) -> ValidationResult:
694
+ """Validate a timeout value using default validator."""
695
+ return _default_validator.validate_timeout(timeout)
696
+
697
+
698
+ def validate_command(command: Any) -> ValidationResult:
699
+ """Validate a command using default validator."""
700
+ return _default_validator.validate_command(command)
701
+
702
+
703
+ def validate_docker_image(image: Any) -> ValidationResult:
704
+ """Validate a Docker image using default validator."""
705
+ return _default_validator.validate_docker_image(image)
706
+
707
+
708
+ def validate_environment_variables(env: Any) -> ValidationResult:
709
+ """Validate environment variables using default validator."""
710
+ return _default_validator.validate_environment_variables(env)