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,604 @@
1
+ """
2
+ Security event handler for the MCP Registry.
3
+
4
+ Provides dedicated security audit logging for:
5
+ - Authentication and authorization events
6
+ - Access control violations
7
+ - Rate limit violations
8
+ - Suspicious activity detection
9
+ - Input validation failures
10
+ - Command injection attempts
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from enum import Enum
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import threading
21
+ import time
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from ...domain.events import (
25
+ DomainEvent,
26
+ HealthCheckFailed,
27
+ ProviderDegraded,
28
+ ProviderStarted,
29
+ ProviderStopped,
30
+ ToolInvocationCompleted,
31
+ ToolInvocationFailed,
32
+ )
33
+ from ...logging_config import get_logger
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ class SecurityEventType(Enum):
39
+ """Types of security events."""
40
+
41
+ # Access events
42
+ ACCESS_GRANTED = "access_granted"
43
+ ACCESS_DENIED = "access_denied"
44
+
45
+ # Rate limiting
46
+ RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
47
+ RATE_LIMIT_WARNING = "rate_limit_warning"
48
+
49
+ # Validation
50
+ VALIDATION_FAILED = "validation_failed"
51
+ INJECTION_ATTEMPT = "injection_attempt"
52
+
53
+ # Provider security
54
+ PROVIDER_START_BLOCKED = "provider_start_blocked"
55
+ SUSPICIOUS_COMMAND = "suspicious_command"
56
+ UNAUTHORIZED_TOOL = "unauthorized_tool"
57
+
58
+ # Health and availability
59
+ REPEATED_FAILURES = "repeated_failures"
60
+ PROVIDER_COMPROMISE_SUSPECTED = "provider_compromise_suspected"
61
+
62
+ # Configuration
63
+ CONFIG_CHANGE = "config_change"
64
+ SENSITIVE_DATA_ACCESS = "sensitive_data_access"
65
+
66
+
67
+ class SecuritySeverity(Enum):
68
+ """Severity levels for security events."""
69
+
70
+ INFO = "info"
71
+ LOW = "low"
72
+ MEDIUM = "medium"
73
+ HIGH = "high"
74
+ CRITICAL = "critical"
75
+
76
+
77
+ @dataclass
78
+ class SecurityEvent:
79
+ """Represents a security-related event."""
80
+
81
+ event_type: SecurityEventType
82
+ severity: SecuritySeverity
83
+ message: str
84
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
85
+
86
+ # Context
87
+ provider_id: Optional[str] = None
88
+ tool_name: Optional[str] = None
89
+ source_ip: Optional[str] = None
90
+ user_id: Optional[str] = None
91
+
92
+ # Details
93
+ details: Dict[str, Any] = field(default_factory=dict)
94
+
95
+ # Tracking
96
+ event_id: str = field(default_factory=lambda: "")
97
+ correlation_id: Optional[str] = None
98
+
99
+ def __post_init__(self):
100
+ """Generate event ID if not provided."""
101
+ if not self.event_id:
102
+ # Generate deterministic ID from content
103
+ content = f"{self.event_type.value}:{self.timestamp.isoformat()}:{self.message}"
104
+ self.event_id = hashlib.sha256(content.encode()).hexdigest()[:16]
105
+
106
+ def to_dict(self) -> Dict[str, Any]:
107
+ """Convert to dictionary for serialization."""
108
+ return {
109
+ "event_id": self.event_id,
110
+ "event_type": self.event_type.value,
111
+ "severity": self.severity.value,
112
+ "message": self.message,
113
+ "timestamp": self.timestamp.isoformat(),
114
+ "provider_id": self.provider_id,
115
+ "tool_name": self.tool_name,
116
+ "source_ip": self.source_ip,
117
+ "user_id": self.user_id,
118
+ "details": self.details,
119
+ "correlation_id": self.correlation_id,
120
+ }
121
+
122
+ def to_json(self) -> str:
123
+ """Convert to JSON string."""
124
+ return json.dumps(self.to_dict())
125
+
126
+
127
+ class SecurityEventSink(ABC):
128
+ """Abstract interface for security event destinations."""
129
+
130
+ @abstractmethod
131
+ def emit(self, event: SecurityEvent) -> None:
132
+ """Emit a security event."""
133
+ pass
134
+
135
+
136
+ class LogSecuritySink(SecurityEventSink):
137
+ """Security sink that writes to structured logs."""
138
+
139
+ def __init__(self, logger_name: str = "security"):
140
+ self._logger = logging.getLogger(logger_name)
141
+
142
+ def emit(self, event: SecurityEvent) -> None:
143
+ """Log the security event with appropriate level."""
144
+ log_data = {"security_event": event.to_dict()}
145
+
146
+ # Map severity to log level
147
+ if event.severity == SecuritySeverity.CRITICAL:
148
+ self._logger.critical(json.dumps(log_data))
149
+ elif event.severity == SecuritySeverity.HIGH:
150
+ self._logger.error(json.dumps(log_data))
151
+ elif event.severity == SecuritySeverity.MEDIUM:
152
+ self._logger.warning(json.dumps(log_data))
153
+ elif event.severity == SecuritySeverity.LOW:
154
+ self._logger.info(json.dumps(log_data))
155
+ else:
156
+ self._logger.debug(json.dumps(log_data))
157
+
158
+
159
+ class InMemorySecuritySink(SecurityEventSink):
160
+ """In-memory security sink for testing and recent event queries."""
161
+
162
+ def __init__(self, max_events: int = 10000):
163
+ self._events: List[SecurityEvent] = []
164
+ self._max_events = max_events
165
+ self._lock = threading.Lock()
166
+
167
+ def emit(self, event: SecurityEvent) -> None:
168
+ """Store the security event."""
169
+ with self._lock:
170
+ self._events.append(event)
171
+ if len(self._events) > self._max_events:
172
+ self._events = self._events[-self._max_events :]
173
+
174
+ def query(
175
+ self,
176
+ event_type: Optional[SecurityEventType] = None,
177
+ severity: Optional[SecuritySeverity] = None,
178
+ provider_id: Optional[str] = None,
179
+ since: Optional[datetime] = None,
180
+ limit: int = 100,
181
+ ) -> List[SecurityEvent]:
182
+ """Query stored security events."""
183
+ with self._lock:
184
+ results = []
185
+ for event in reversed(self._events):
186
+ if len(results) >= limit:
187
+ break
188
+
189
+ if event_type and event.event_type != event_type:
190
+ continue
191
+ if severity and event.severity != severity:
192
+ continue
193
+ if provider_id and event.provider_id != provider_id:
194
+ continue
195
+ if since and event.timestamp < since:
196
+ continue
197
+
198
+ results.append(event)
199
+
200
+ return results
201
+
202
+ def get_severity_counts(self) -> Dict[str, int]:
203
+ """Get counts by severity level."""
204
+ with self._lock:
205
+ counts = {s.value: 0 for s in SecuritySeverity}
206
+ for event in self._events:
207
+ counts[event.severity.value] += 1
208
+ return counts
209
+
210
+ def clear(self) -> None:
211
+ """Clear all stored events."""
212
+ with self._lock:
213
+ self._events.clear()
214
+
215
+ @property
216
+ def count(self) -> int:
217
+ """Get total event count."""
218
+ with self._lock:
219
+ return len(self._events)
220
+
221
+
222
+ class CallbackSecuritySink(SecurityEventSink):
223
+ """Security sink that calls a callback function."""
224
+
225
+ def __init__(self, callback):
226
+ self._callback = callback
227
+
228
+ def emit(self, event: SecurityEvent) -> None:
229
+ """Call the callback with the event."""
230
+ try:
231
+ self._callback(event)
232
+ except Exception as e:
233
+ logger.error(f"Security callback failed: {e}")
234
+
235
+
236
+ class CompositeSecuritySink(SecurityEventSink):
237
+ """Security sink that emits to multiple sinks."""
238
+
239
+ def __init__(self, sinks: List[SecurityEventSink]):
240
+ self._sinks = sinks
241
+
242
+ def emit(self, event: SecurityEvent) -> None:
243
+ """Emit to all configured sinks."""
244
+ for sink in self._sinks:
245
+ try:
246
+ sink.emit(event)
247
+ except Exception as e:
248
+ logger.error(f"Security sink {type(sink).__name__} failed: {e}")
249
+
250
+ def add_sink(self, sink: SecurityEventSink) -> None:
251
+ """Add a sink."""
252
+ self._sinks.append(sink)
253
+
254
+ def remove_sink(self, sink: SecurityEventSink) -> None:
255
+ """Remove a sink."""
256
+ if sink in self._sinks:
257
+ self._sinks.remove(sink)
258
+
259
+
260
+ class SecurityEventHandler:
261
+ """
262
+ Handler for domain events that detects and logs security-relevant activity.
263
+
264
+ Monitors for:
265
+ - Repeated failures (potential attacks)
266
+ - Unusual patterns
267
+ - Rate limit violations
268
+ - Validation failures
269
+ """
270
+
271
+ # Thresholds for anomaly detection
272
+ FAILURE_THRESHOLD = 5 # Failures before warning
273
+ CRITICAL_FAILURE_THRESHOLD = 10 # Failures before critical alert
274
+ TIME_WINDOW_S = 300 # 5 minute window for tracking
275
+
276
+ def __init__(
277
+ self,
278
+ sink: Optional[SecurityEventSink] = None,
279
+ enable_anomaly_detection: bool = True,
280
+ ):
281
+ """
282
+ Initialize the security handler.
283
+
284
+ Args:
285
+ sink: Where to emit security events (defaults to log sink)
286
+ enable_anomaly_detection: Whether to detect anomalies in event patterns
287
+ """
288
+ self._sink = sink or LogSecuritySink()
289
+ self._enable_anomaly_detection = enable_anomaly_detection
290
+
291
+ # Tracking for anomaly detection
292
+ self._failure_counts: Dict[str, List[float]] = {} # provider_id -> timestamps
293
+ self._lock = threading.Lock()
294
+
295
+ def handle(self, event: DomainEvent) -> None:
296
+ """
297
+ Handle a domain event, checking for security implications.
298
+
299
+ Args:
300
+ event: The domain event to process
301
+ """
302
+ # Dispatch to specific handlers
303
+ handlers = {
304
+ ProviderStarted: self._handle_provider_started,
305
+ ProviderStopped: self._handle_provider_stopped,
306
+ ProviderDegraded: self._handle_provider_degraded,
307
+ ToolInvocationCompleted: self._handle_tool_invocation_completed,
308
+ ToolInvocationFailed: self._handle_tool_invocation_failed,
309
+ HealthCheckFailed: self._handle_health_check_failed,
310
+ }
311
+
312
+ handler = handlers.get(type(event))
313
+ if handler:
314
+ handler(event)
315
+
316
+ # Run anomaly detection
317
+ if self._enable_anomaly_detection:
318
+ self._check_anomalies(event)
319
+
320
+ def _handle_provider_started(self, event: ProviderStarted) -> None:
321
+ """Handle provider start event."""
322
+ # Log provider starts for audit trail
323
+ self._emit(
324
+ SecurityEvent(
325
+ event_type=SecurityEventType.ACCESS_GRANTED,
326
+ severity=SecuritySeverity.INFO,
327
+ message=f"Provider started: {event.provider_id}",
328
+ provider_id=event.provider_id,
329
+ details={
330
+ "mode": event.mode,
331
+ "tools_count": event.tools_count,
332
+ "startup_duration_ms": event.startup_duration_ms,
333
+ },
334
+ correlation_id=event.event_id,
335
+ )
336
+ )
337
+
338
+ def _handle_provider_stopped(self, event: ProviderStopped) -> None:
339
+ """Handle provider stop event."""
340
+ # Clear failure tracking for this provider
341
+ with self._lock:
342
+ self._failure_counts.pop(event.provider_id, None)
343
+
344
+ def _handle_provider_degraded(self, event: ProviderDegraded) -> None:
345
+ """Handle provider degradation event."""
346
+ severity = SecuritySeverity.MEDIUM
347
+ if event.consecutive_failures >= self.CRITICAL_FAILURE_THRESHOLD:
348
+ severity = SecuritySeverity.HIGH
349
+
350
+ self._emit(
351
+ SecurityEvent(
352
+ event_type=SecurityEventType.REPEATED_FAILURES,
353
+ severity=severity,
354
+ message=f"Provider degraded after {event.consecutive_failures} consecutive failures",
355
+ provider_id=event.provider_id,
356
+ details={
357
+ "consecutive_failures": event.consecutive_failures,
358
+ "total_failures": event.total_failures,
359
+ "reason": event.reason,
360
+ },
361
+ correlation_id=event.event_id,
362
+ )
363
+ )
364
+
365
+ def _handle_tool_invocation_completed(self, event: ToolInvocationCompleted) -> None:
366
+ """Handle successful tool invocation."""
367
+ # Only log if unusually slow (potential DoS or resource exhaustion)
368
+ if event.duration_ms > 10000: # > 10 seconds
369
+ self._emit(
370
+ SecurityEvent(
371
+ event_type=SecurityEventType.ACCESS_GRANTED,
372
+ severity=SecuritySeverity.LOW,
373
+ message=f"Slow tool invocation: {event.tool_name}",
374
+ provider_id=event.provider_id,
375
+ tool_name=event.tool_name,
376
+ details={
377
+ "duration_ms": event.duration_ms,
378
+ },
379
+ correlation_id=event.event_id,
380
+ )
381
+ )
382
+
383
+ def _handle_tool_invocation_failed(self, event: ToolInvocationFailed) -> None:
384
+ """Handle failed tool invocation."""
385
+ self._record_failure(event.provider_id)
386
+
387
+ self._emit(
388
+ SecurityEvent(
389
+ event_type=SecurityEventType.ACCESS_DENIED,
390
+ severity=SecuritySeverity.LOW,
391
+ message=f"Tool invocation failed: {event.tool_name}",
392
+ provider_id=event.provider_id,
393
+ tool_name=event.tool_name,
394
+ details={
395
+ "error": event.error_message,
396
+ },
397
+ correlation_id=event.event_id,
398
+ )
399
+ )
400
+
401
+ def _handle_health_check_failed(self, event: HealthCheckFailed) -> None:
402
+ """Handle health check failure."""
403
+ self._record_failure(event.provider_id)
404
+
405
+ if event.consecutive_failures >= self.FAILURE_THRESHOLD:
406
+ self._emit(
407
+ SecurityEvent(
408
+ event_type=SecurityEventType.REPEATED_FAILURES,
409
+ severity=SecuritySeverity.MEDIUM,
410
+ message="Multiple health check failures for provider",
411
+ provider_id=event.provider_id,
412
+ details={
413
+ "consecutive_failures": event.consecutive_failures,
414
+ "error": event.error_message,
415
+ },
416
+ correlation_id=event.event_id,
417
+ )
418
+ )
419
+
420
+ def _record_failure(self, provider_id: str) -> None:
421
+ """Record a failure for anomaly detection."""
422
+ now = time.time()
423
+ with self._lock:
424
+ if provider_id not in self._failure_counts:
425
+ self._failure_counts[provider_id] = []
426
+
427
+ # Add current failure
428
+ self._failure_counts[provider_id].append(now)
429
+
430
+ # Clean old entries
431
+ cutoff = now - self.TIME_WINDOW_S
432
+ self._failure_counts[provider_id] = [t for t in self._failure_counts[provider_id] if t > cutoff]
433
+
434
+ def _check_anomalies(self, event: DomainEvent) -> None:
435
+ """Check for anomalous patterns across events."""
436
+ provider_id = getattr(event, "provider_id", None)
437
+ if not provider_id:
438
+ return
439
+
440
+ with self._lock:
441
+ failures = self._failure_counts.get(provider_id, [])
442
+
443
+ if len(failures) >= self.CRITICAL_FAILURE_THRESHOLD:
444
+ self._emit(
445
+ SecurityEvent(
446
+ event_type=SecurityEventType.PROVIDER_COMPROMISE_SUSPECTED,
447
+ severity=SecuritySeverity.HIGH,
448
+ message="High failure rate detected for provider (possible attack or compromise)",
449
+ provider_id=provider_id,
450
+ details={
451
+ "failures_in_window": len(failures),
452
+ "window_seconds": self.TIME_WINDOW_S,
453
+ },
454
+ )
455
+ )
456
+
457
+ def _emit(self, event: SecurityEvent) -> None:
458
+ """Emit a security event to the sink."""
459
+ try:
460
+ self._sink.emit(event)
461
+ except Exception as e:
462
+ logger.error(f"Failed to emit security event: {e}")
463
+
464
+ # --- Public API for direct security event emission ---
465
+
466
+ def log_rate_limit_exceeded(
467
+ self,
468
+ provider_id: Optional[str] = None,
469
+ limit: int = 0,
470
+ window_seconds: int = 0,
471
+ source_ip: Optional[str] = None,
472
+ ) -> None:
473
+ """Log a rate limit violation."""
474
+ self._emit(
475
+ SecurityEvent(
476
+ event_type=SecurityEventType.RATE_LIMIT_EXCEEDED,
477
+ severity=SecuritySeverity.MEDIUM,
478
+ message="Rate limit exceeded",
479
+ provider_id=provider_id,
480
+ source_ip=source_ip,
481
+ details={
482
+ "limit": limit,
483
+ "window_seconds": window_seconds,
484
+ },
485
+ )
486
+ )
487
+
488
+ def log_validation_failed(
489
+ self,
490
+ field: str,
491
+ message: str,
492
+ provider_id: Optional[str] = None,
493
+ value: Optional[str] = None,
494
+ ) -> None:
495
+ """Log a validation failure."""
496
+ # Determine severity based on field
497
+ severity = SecuritySeverity.LOW
498
+ if field in ("command", "image"):
499
+ severity = SecuritySeverity.MEDIUM
500
+
501
+ details = {"field": field}
502
+ if value:
503
+ # Truncate value for safety
504
+ details["value"] = value[:50] if len(value) > 50 else value
505
+
506
+ self._emit(
507
+ SecurityEvent(
508
+ event_type=SecurityEventType.VALIDATION_FAILED,
509
+ severity=severity,
510
+ message=f"Validation failed: {message}",
511
+ provider_id=provider_id,
512
+ details=details,
513
+ )
514
+ )
515
+
516
+ def log_injection_attempt(
517
+ self,
518
+ field: str,
519
+ pattern: str,
520
+ provider_id: Optional[str] = None,
521
+ source_ip: Optional[str] = None,
522
+ ) -> None:
523
+ """Log a potential injection attempt."""
524
+ self._emit(
525
+ SecurityEvent(
526
+ event_type=SecurityEventType.INJECTION_ATTEMPT,
527
+ severity=SecuritySeverity.HIGH,
528
+ message=f"Potential injection attempt detected in {field}",
529
+ provider_id=provider_id,
530
+ source_ip=source_ip,
531
+ details={
532
+ "field": field,
533
+ "pattern_detected": pattern,
534
+ },
535
+ )
536
+ )
537
+
538
+ def log_suspicious_command(
539
+ self,
540
+ command: List[str],
541
+ provider_id: Optional[str] = None,
542
+ reason: str = "",
543
+ ) -> None:
544
+ """Log a suspicious command execution attempt."""
545
+ # Sanitize command for logging (don't log full values)
546
+ safe_command = [c[:20] + "..." if len(c) > 20 else c for c in command[:5]]
547
+
548
+ self._emit(
549
+ SecurityEvent(
550
+ event_type=SecurityEventType.SUSPICIOUS_COMMAND,
551
+ severity=SecuritySeverity.HIGH,
552
+ message=f"Suspicious command blocked: {reason}",
553
+ provider_id=provider_id,
554
+ details={
555
+ "command_preview": safe_command,
556
+ "reason": reason,
557
+ },
558
+ )
559
+ )
560
+
561
+ def log_config_change(
562
+ self,
563
+ change_type: str,
564
+ provider_id: Optional[str] = None,
565
+ user_id: Optional[str] = None,
566
+ details: Optional[Dict[str, Any]] = None,
567
+ ) -> None:
568
+ """Log a configuration change."""
569
+ self._emit(
570
+ SecurityEvent(
571
+ event_type=SecurityEventType.CONFIG_CHANGE,
572
+ severity=SecuritySeverity.INFO,
573
+ message=f"Configuration changed: {change_type}",
574
+ provider_id=provider_id,
575
+ user_id=user_id,
576
+ details=details or {},
577
+ )
578
+ )
579
+
580
+ @property
581
+ def sink(self) -> SecurityEventSink:
582
+ """Get the security event sink."""
583
+ return self._sink
584
+
585
+
586
+ # --- Global security handler instance ---
587
+
588
+ _security_handler: Optional[SecurityEventHandler] = None
589
+
590
+
591
+ def get_security_handler(
592
+ sink: Optional[SecurityEventSink] = None,
593
+ ) -> SecurityEventHandler:
594
+ """Get or create the global security handler instance."""
595
+ global _security_handler
596
+ if _security_handler is None:
597
+ _security_handler = SecurityEventHandler(sink=sink)
598
+ return _security_handler
599
+
600
+
601
+ def reset_security_handler() -> None:
602
+ """Reset the global security handler (for testing)."""
603
+ global _security_handler
604
+ _security_handler = None