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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- 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)
|