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.
- intermesh/__init__.py +56 -0
- intermesh/adapters.py +90 -0
- intermesh/admin.py +733 -0
- intermesh/agent.py +520 -0
- intermesh/apikeys.py +273 -0
- intermesh/audit.py +153 -0
- intermesh/bridge.py +181 -0
- intermesh/cli.py +453 -0
- intermesh/config.py +145 -0
- intermesh/crypto.py +150 -0
- intermesh/egress.py +180 -0
- intermesh/escrow.py +208 -0
- intermesh/guardrails.py +235 -0
- intermesh/hardware.py +32 -0
- intermesh/health.py +56 -0
- intermesh/hub.py +996 -0
- intermesh/identity.py +94 -0
- intermesh/logger.py +90 -0
- intermesh/message.py +152 -0
- intermesh/metrics.py +49 -0
- intermesh/peering.py +103 -0
- intermesh/pipeline.py +218 -0
- intermesh/policy.py +35 -0
- intermesh/ratelimit.py +42 -0
- intermesh/schema.py +125 -0
- intermesh/secret.py +115 -0
- intermesh/signing.py +64 -0
- intermesh/snapshot.py +342 -0
- intermesh/store.py +177 -0
- intermesh/task.py +122 -0
- intermesh-0.3.0.dist-info/METADATA +198 -0
- intermesh-0.3.0.dist-info/RECORD +35 -0
- intermesh-0.3.0.dist-info/WHEEL +5 -0
- intermesh-0.3.0.dist-info/entry_points.txt +2 -0
- intermesh-0.3.0.dist-info/top_level.txt +1 -0
intermesh/guardrails.py
ADDED
|
@@ -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
|