intermesh 0.3.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.
@@ -0,0 +1,235 @@
1
+ import dataclasses
2
+ import re
3
+ import time
4
+ from collections import deque
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Deque, Dict, List, Optional, Set
7
+
8
+
9
+ class PolicyViolationError(PermissionError):
10
+ def __init__(self, rule_name: str, message: str, agent_name: str):
11
+ self.rule_name = rule_name
12
+ self.agent_name = agent_name
13
+ super().__init__(f"ASIMOV_GUARDRAIL_VIOLATION [{rule_name}] ({agent_name}): {message}")
14
+
15
+
16
+ @dataclass
17
+ class GuardrailPolicy:
18
+ name: str = "default_safety_policy"
19
+ max_cascade_depth: int = 4
20
+ max_cost_per_task: float = 100.0
21
+ max_tasks_per_minute: int = 60
22
+ circuit_breaker_threshold: int = 3
23
+ blocked_patterns: List[str] = field(default_factory=lambda: [
24
+ r"rm\s+-rf",
25
+ r"DROP\s+DATABASE",
26
+ r"DROP\s+TABLE",
27
+ r"TRUNCATE\s+TABLE",
28
+ r"format\s+[c-z]:",
29
+ r"eval\(",
30
+ r"exec\(",
31
+ r"os\.system\(",
32
+ r"subprocess\.Popen\(",
33
+ r"sudo\s+",
34
+ r"chmod\s+777",
35
+ r"SELECT\s+.*\s+FROM\s+users.*password"
36
+ ])
37
+
38
+
39
+ class TaskCascadeTracker:
40
+ def __init__(self):
41
+ self._depth_map: Dict[str, int] = {}
42
+ self._parent_map: Dict[str, str] = {}
43
+
44
+ def register_task(self, task_id: str, parent_id: Optional[str] = None) -> int:
45
+ if not parent_id or parent_id not in self._depth_map:
46
+ depth = 1
47
+ else:
48
+ depth = self._depth_map[parent_id] + 1
49
+
50
+ self._depth_map[task_id] = depth
51
+ if parent_id:
52
+ self._parent_map[task_id] = parent_id
53
+
54
+ return depth
55
+
56
+ def get_depth(self, task_id: str) -> int:
57
+ return self._depth_map.get(task_id, 1)
58
+
59
+
60
+ class RateWindowTracker:
61
+ """Fenêtre glissante de 60s : combien de tâches un agent a soumises récemment."""
62
+
63
+ def __init__(self, window_seconds: float = 60.0):
64
+ self.window_seconds = window_seconds
65
+ self._events: Dict[str, Deque[float]] = {}
66
+
67
+ def count_recent(self, agent_name: str, now: Optional[float] = None) -> int:
68
+ now = now if now is not None else time.time()
69
+ window = self._events.get(agent_name)
70
+ if not window:
71
+ return 0
72
+ cutoff = now - self.window_seconds
73
+ while window and window[0] < cutoff:
74
+ window.popleft()
75
+ return len(window)
76
+
77
+ def record(self, agent_name: str, now: Optional[float] = None) -> None:
78
+ now = now if now is not None else time.time()
79
+ self._events.setdefault(agent_name, deque()).append(now)
80
+
81
+
82
+ class CircuitBreaker:
83
+ def __init__(self, threshold: int = 3, cooldown_seconds: float = 60.0):
84
+ self.threshold = threshold
85
+ self.cooldown_seconds = cooldown_seconds
86
+ self._violation_counts: Dict[str, int] = {}
87
+ self._tripped_until: Dict[str, float] = {}
88
+
89
+ def record_violation(self, agent_name: str) -> bool:
90
+ count = self._violation_counts.get(agent_name, 0) + 1
91
+ self._violation_counts[agent_name] = count
92
+
93
+ if count >= self.threshold:
94
+ self._tripped_until[agent_name] = time.time() + self.cooldown_seconds
95
+ print(f"\033[31m⚡ [DISJONCTEUR ISOLATION]\033[0m Agent '{agent_name}' isolé pendant {self.cooldown_seconds}s ({count} violations).")
96
+ return True
97
+ return False
98
+
99
+ def is_tripped(self, agent_name: str) -> bool:
100
+ if agent_name in self._tripped_until:
101
+ if time.time() < self._tripped_until[agent_name]:
102
+ return True
103
+ else:
104
+ del self._tripped_until[agent_name]
105
+ self._violation_counts[agent_name] = 0
106
+ return False
107
+
108
+
109
+ class AsimovGuardrailEngine:
110
+ """
111
+ Moteur de garde-fous appliqué à chaque soumission de tâche.
112
+
113
+ Une policy par défaut s'applique à tout le Hub ; `set_org_policy` permet
114
+ à une organisation de recevoir des limites différentes (plus strictes ou
115
+ plus larges) sans affecter les autres locataires du même Hub.
116
+ """
117
+
118
+ def __init__(self, policy: Optional[GuardrailPolicy] = None):
119
+ self.policy = policy or GuardrailPolicy()
120
+ self.cascade_tracker = TaskCascadeTracker()
121
+ self.circuit_breaker = CircuitBreaker(threshold=self.policy.circuit_breaker_threshold)
122
+ self.rate_tracker = RateWindowTracker()
123
+ self._org_policies: Dict[str, GuardrailPolicy] = {}
124
+ self._compiled_patterns_cache: Dict[int, List[re.Pattern]] = {}
125
+
126
+ def set_org_policy(self, org_id: str, policy: GuardrailPolicy) -> None:
127
+ """Remplace la policy par défaut pour une organisation donnée."""
128
+ self._org_policies[org_id] = policy
129
+
130
+ def get_policy(self, org_id: Optional[str] = None) -> GuardrailPolicy:
131
+ """Policy applicable : celle de l'organisation si elle en a une, sinon la policy par défaut du Hub."""
132
+ if org_id and org_id in self._org_policies:
133
+ return self._org_policies[org_id]
134
+ return self.policy
135
+
136
+ def export_policies(self) -> Dict[str, Any]:
137
+ """
138
+ Policy par défaut du Hub + policies par organisation, sérialisées.
139
+
140
+ Les compteurs volatils (cascade, débit, disjoncteur) ne sont
141
+ volontairement pas exportés : ce sont des fenêtres glissantes de
142
+ quelques secondes, restaurer celles d'hier n'aurait pas de sens et
143
+ pourrait ressusciter un blocage déjà expiré.
144
+ """
145
+ return {
146
+ "default": dataclasses.asdict(self.policy),
147
+ "orgs": {org: dataclasses.asdict(p) for org, p in self._org_policies.items()},
148
+ }
149
+
150
+ def import_policies(self, state: Dict[str, Any]) -> None:
151
+ """Remplace la policy par défaut et toutes les policies d'organisation."""
152
+ default = state.get("default")
153
+ if default:
154
+ self.policy = GuardrailPolicy(**default)
155
+ self.circuit_breaker.threshold = self.policy.circuit_breaker_threshold
156
+ self._org_policies = {
157
+ org: GuardrailPolicy(**p) for org, p in (state.get("orgs") or {}).items()
158
+ }
159
+ # Le cache est indexé par id() d'objet : les policies remplacées ont
160
+ # disparu, leurs motifs compilés doivent disparaître avec elles.
161
+ self._compiled_patterns_cache.clear()
162
+
163
+ def _compiled_patterns(self, policy: GuardrailPolicy) -> List[re.Pattern]:
164
+ key = id(policy)
165
+ cached = self._compiled_patterns_cache.get(key)
166
+ if cached is None:
167
+ cached = [re.compile(pat, re.IGNORECASE) for pat in policy.blocked_patterns]
168
+ self._compiled_patterns_cache[key] = cached
169
+ return cached
170
+
171
+ def inspect_payload(self, agent_name: str, payload_text: str, org_id: Optional[str] = None):
172
+ if self.circuit_breaker.is_tripped(agent_name):
173
+ raise PolicyViolationError(
174
+ "CIRCUIT_BREAKER_TRIPPED",
175
+ f"L'agent '{agent_name}' est temporairement bloqué suite à de multiples violations.",
176
+ agent_name
177
+ )
178
+
179
+ policy = self.get_policy(org_id)
180
+ for pattern in self._compiled_patterns(policy):
181
+ if pattern.search(payload_text):
182
+ self.circuit_breaker.record_violation(agent_name)
183
+ rule_name = "FORBIDDEN_PATTERN_INTERCEPTED"
184
+ msg = f"Commande ou pattern destructeur détecté : '{pattern.pattern}' dans la charge utile."
185
+ raise PolicyViolationError(rule_name, msg, agent_name)
186
+
187
+ def validate_task_submission(
188
+ self,
189
+ agent_name: str,
190
+ task_id: str,
191
+ parent_task_id: Optional[str] = None,
192
+ estimated_cost: float = 0.0,
193
+ payload_text: Optional[str] = None,
194
+ org_id: Optional[str] = None,
195
+ ):
196
+ if self.circuit_breaker.is_tripped(agent_name):
197
+ raise PolicyViolationError(
198
+ "CIRCUIT_BREAKER_TRIPPED",
199
+ f"L'agent '{agent_name}' est isolé suite à de multiples infractions.",
200
+ agent_name
201
+ )
202
+
203
+ policy = self.get_policy(org_id)
204
+
205
+ depth = self.cascade_tracker.register_task(task_id, parent_task_id)
206
+ if depth > policy.max_cascade_depth:
207
+ self.circuit_breaker.record_violation(agent_name)
208
+ raise PolicyViolationError(
209
+ "INFINITE_CASCADE_RECURSION",
210
+ f"Profondeur de récursion maximale dépassée ({depth}/{policy.max_cascade_depth}). Boucle d'agents bloquée.",
211
+ agent_name
212
+ )
213
+
214
+ if estimated_cost > policy.max_cost_per_task:
215
+ self.circuit_breaker.record_violation(agent_name)
216
+ raise PolicyViolationError(
217
+ "TASK_COST_CAP_EXCEEDED",
218
+ f"Coût estimé ({estimated_cost:.2f} $) dépasse le plafond autorisé ({policy.max_cost_per_task:.2f} $).",
219
+ agent_name
220
+ )
221
+
222
+ if self.rate_tracker.count_recent(agent_name) >= policy.max_tasks_per_minute:
223
+ self.circuit_breaker.record_violation(agent_name)
224
+ raise PolicyViolationError(
225
+ "RATE_LIMIT_EXCEEDED",
226
+ f"Plus de {policy.max_tasks_per_minute} tâches/minute pour '{agent_name}'.",
227
+ agent_name
228
+ )
229
+
230
+ if payload_text:
231
+ self.inspect_payload(agent_name, payload_text, org_id=org_id)
232
+
233
+ # Enregistré seulement une fois toutes les vérifications passées : une
234
+ # tâche refusée ne doit pas consommer le quota de débit de l'agent.
235
+ self.rate_tracker.record(agent_name)
intermesh/hardware.py ADDED
@@ -0,0 +1,32 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import platform
5
+ import uuid
6
+
7
+
8
+ def get_machine_fingerprint() -> str:
9
+ """
10
+ Génère une empreinte matérielle SHA-256 unique et déterministe basée sur le PC hôte.
11
+ Combine : Adresse MAC, Processeur, Architecture, Nom d'hôte, Système d'exploitation.
12
+ """
13
+ try:
14
+ mac_addr = hex(uuid.getnode())
15
+ except Exception:
16
+ mac_addr = "unknown_mac"
17
+
18
+ hardware_info = {
19
+ "mac": mac_addr,
20
+ "system": platform.system(),
21
+ "node": platform.node(),
22
+ "machine": platform.machine(),
23
+ "processor": platform.processor() or "generic_cpu",
24
+ }
25
+
26
+ raw = json.dumps(hardware_info, sort_keys=True)
27
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def verify_machine_fingerprint(expected_fingerprint: str) -> bool:
31
+ """Vérifie si l'empreinte de la machine actuelle correspond à l'empreinte attendue."""
32
+ return get_machine_fingerprint() == expected_fingerprint
intermesh/health.py ADDED
@@ -0,0 +1,56 @@
1
+ import json
2
+ from typing import Optional, Tuple, List, Callable, Any
3
+ from intermesh.metrics import metrics
4
+
5
+
6
+ class HealthCheckHandler:
7
+ """
8
+ Gestionnaire universel de requetes HTTP de supervision (/healthz, /readyz, /metrics).
9
+ """
10
+
11
+ def __init__(self, readiness_evaluator: Optional[Callable[[], bool]] = None,
12
+ state_metrics_provider: Optional[Callable[[], dict]] = None):
13
+ self.readiness_evaluator = readiness_evaluator
14
+ self.state_metrics_provider = state_metrics_provider
15
+
16
+ def handle_request(self, path: str) -> Optional[Tuple[int, List[Tuple[str, str]], bytes]]:
17
+ """
18
+ Traite une requete HTTP entrante.
19
+ Retourne (status_code, headers, body) si le chemin correspond a un endpoint gere,
20
+ ou None s'il s'agit d'une requete de mise a niveau WebSocket.
21
+ """
22
+ clean_path = path.split("?")[0].rstrip("/")
23
+
24
+ if clean_path == "/healthz":
25
+ payload = json.dumps({"status": "ok", "service": "nexus-hub"}).encode("utf-8")
26
+ headers = [
27
+ ("Content-Type", "application/json; charset=utf-8"),
28
+ ("Content-Length", str(len(payload))),
29
+ ("Access-Control-Allow-Origin", "*")
30
+ ]
31
+ return 200, headers, payload
32
+
33
+ elif clean_path == "/readyz":
34
+ is_ready = self.readiness_evaluator() if self.readiness_evaluator else True
35
+ status_code = 200 if is_ready else 503
36
+ status_str = "ready" if is_ready else "unavailable"
37
+ payload = json.dumps({"status": status_str, "ready": is_ready}).encode("utf-8")
38
+ headers = [
39
+ ("Content-Type", "application/json; charset=utf-8"),
40
+ ("Content-Length", str(len(payload))),
41
+ ("Access-Control-Allow-Origin", "*")
42
+ ]
43
+ return status_code, headers, payload
44
+
45
+ elif clean_path == "/metrics":
46
+ live_gauges = self.state_metrics_provider() if self.state_metrics_provider else {}
47
+ output = metrics.generate_prometheus_output(live_gauges)
48
+ payload = output.encode("utf-8")
49
+ headers = [
50
+ ("Content-Type", "text/plain; version=0.0.4; charset=utf-8"),
51
+ ("Content-Length", str(len(payload))),
52
+ ("Access-Control-Allow-Origin", "*")
53
+ ]
54
+ return 200, headers, payload
55
+
56
+ return None