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/admin.py ADDED
@@ -0,0 +1,733 @@
1
+ """
2
+ Moteur de commandes de la console d'administration.
3
+
4
+ Séparé du Hub pour rester testable sans serveur en marche, et pour que
5
+ la surface d'administration soit lisible d'un seul tenant — c'est elle
6
+ qui concentre le risque.
7
+
8
+ MODÈLE D'AUTORISATION
9
+ ---------------------
10
+ À l'enregistrement, un agent sans clé d'API choisit lui-même ses rôles :
11
+
12
+ roles = d.get("roles", ["standard"])
13
+
14
+ C'est acceptable pour un maillage où les agents s'échangent des messages,
15
+ et inacceptable pour une console capable de révoquer des clés ou de
16
+ déconnecter des agents. Toute commande d'administration exige donc :
17
+
18
+ 1. une identité authentifiée par CLÉ D'API — jamais des rôles déclarés
19
+ par le client. La preuve est portée par le JWT signé par le Hub,
20
+ donc infalsifiable ;
21
+ 2. le rôle `admin` ou `org_admin`.
22
+
23
+ Les deux conditions sont vérifiées ici, à un seul endroit.
24
+
25
+ CLOISONNEMENT PAR ORGANISATION
26
+ -------------------------------
27
+ `admin` voit et administre tout le Hub, organisations confondues : c'est
28
+ le rôle de l'opérateur du Hub lui-même, inchangé depuis la première
29
+ version de cette console.
30
+
31
+ `org_admin` est plus étroit : il ne voit et n'agit que sur l'organisation
32
+ de sa propre clé d'API (`org_id`, porté par le JWT, donc lui aussi
33
+ infalsifiable). C'est le rôle à distribuer à chaque entreprise qui
34
+ partage un Hub avec d'autres — indispensable dès qu'un portail web laisse
35
+ plusieurs organisations générer leurs propres clés sur la même instance :
36
+ sans ce cloisonnement, la clé admin d'une entreprise verrait les agents,
37
+ tâches et journaux d'audit de toutes les autres.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import dataclasses
43
+ import time
44
+ from typing import Any, Callable, Optional
45
+
46
+ from intermesh import snapshot as snapshot_store
47
+ from intermesh.escrow import EscrowError
48
+ from intermesh.guardrails import GuardrailPolicy
49
+ from intermesh.snapshot import SnapshotError
50
+ from intermesh.task import InterMeshTask, TaskStatus
51
+
52
+ # Commandes qui modifient l'état. Isolées pour être journalisées
53
+ # distinctement des simples lectures.
54
+ MUTATING = {
55
+ "agent.disconnect",
56
+ "task.cancel",
57
+ "task.retry",
58
+ "apikey.create",
59
+ "apikey.revoke",
60
+ "guardrails.set_policy",
61
+ "escrow.grant",
62
+ "escrow.release",
63
+ "escrow.refund",
64
+ "snapshot.create",
65
+ "snapshot.restore",
66
+ "snapshot.delete",
67
+ }
68
+
69
+
70
+ class AdminError(Exception):
71
+ """Commande refusée ou impossible à exécuter."""
72
+
73
+
74
+ class AdminContext:
75
+ """Vue sur l'état du Hub fournie au moteur de commandes."""
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ agents: dict,
81
+ identity_registry: dict,
82
+ task_registry: dict,
83
+ audit_log,
84
+ api_keys,
85
+ peered_hubs: dict,
86
+ store,
87
+ my_org: str,
88
+ remember_task: Callable,
89
+ send_to_agent: Callable,
90
+ hub_version: str = "1.5",
91
+ caller_org: str = "default",
92
+ scoped: bool = False,
93
+ asimov_engine: Any = None,
94
+ escrow_manager: Any = None,
95
+ snapshot_dir: Any = None,
96
+ ):
97
+ self.snapshot_dir = snapshot_dir
98
+ self.agents = agents
99
+ self.identity_registry = identity_registry
100
+ self.task_registry = task_registry
101
+ self.audit_log = audit_log
102
+ self.api_keys = api_keys
103
+ self.peered_hubs = peered_hubs
104
+ self.store = store
105
+ self.asimov_engine = asimov_engine
106
+ self.escrow_manager = escrow_manager
107
+ self.my_org = my_org
108
+ self.remember_task = remember_task
109
+ self.send_to_agent = send_to_agent
110
+ self.hub_version = hub_version
111
+ # Organisation du porteur du token, et `True` si son rôle est
112
+ # `org_admin` — auquel cas chaque commande doit filtrer sa vue et
113
+ # ses actions à cette seule organisation. `admin` laisse `scoped`
114
+ # à `False` : comportement hub-wide inchangé.
115
+ self.caller_org = caller_org
116
+ self.scoped = scoped
117
+
118
+
119
+ def authorize(token_payload: dict) -> None:
120
+ """
121
+ Vérifie qu'un porteur de token a le droit d'administrer.
122
+
123
+ Raises:
124
+ AdminError: si l'identité n'est pas issue d'une clé d'API, ou si
125
+ ni le rôle `admin` ni `org_admin` ne sont présents.
126
+ """
127
+ if token_payload.get("auth_method") != "api_key":
128
+ raise AdminError(
129
+ "ADMIN_DENIED: l'administration exige une identité authentifiée "
130
+ "par clé d'API. Les rôles déclarés à l'enregistrement ne suffisent pas."
131
+ )
132
+ roles = token_payload.get("roles", [])
133
+ if "admin" not in roles and "org_admin" not in roles:
134
+ raise AdminError("ADMIN_DENIED: rôle 'admin' ou 'org_admin' requis.")
135
+
136
+
137
+ def caller_scope(token_payload: dict) -> tuple[str, bool]:
138
+ """
139
+ (organisation du porteur du token, `True` s'il doit être cloisonné).
140
+
141
+ `admin` l'emporte sur `org_admin` si un token portait les deux : un
142
+ opérateur de Hub qui s'octroie aussi `org_admin` reste hub-wide.
143
+ """
144
+ roles = token_payload.get("roles", [])
145
+ scoped = "admin" not in roles and "org_admin" in roles
146
+ return token_payload.get("org_id", "default"), scoped
147
+
148
+
149
+ def _org_of(qualified_name: Optional[str]) -> str:
150
+ """
151
+ Organisation porteuse d'un nom qualifié InterMesh (`"acme/bot"` -> `"acme"`).
152
+
153
+ Un nom sans préfixe appartient à l'organisation par défaut du Hub —
154
+ la même convention que `AgentIdentity.qualified_name`.
155
+ """
156
+ if not qualified_name:
157
+ return "default"
158
+ return qualified_name.split("/")[0] if "/" in qualified_name else "default"
159
+
160
+
161
+ # Évènements qui décrivent le Hub lui-même ou sa fédération, pas une
162
+ # organisation en particulier : un `org_admin` ne doit jamais les voir,
163
+ # même quand leur `sender`/`target` ressemble par accident à son org_id.
164
+ _HUB_LEVEL_EVENTS = {"GENESIS", "PEERING_ESTABLISHED", "PEERING_ACCEPTED"}
165
+
166
+
167
+ # ----------------------------------------------------------------------
168
+ # Commandes
169
+ # ----------------------------------------------------------------------
170
+
171
+ def _hub_info(ctx: AdminContext, params: dict) -> dict:
172
+ if ctx.scoped:
173
+ identities = [i for i in ctx.identity_registry.values() if i.org_id == ctx.caller_org]
174
+ tasks = [t for t in ctx.task_registry.values()
175
+ if ctx.caller_org in (_org_of(t.orchestrator), _org_of(t.assignee))]
176
+ audit_entries = sum(
177
+ 1 for e in ctx.audit_log.chain
178
+ if e.event_type not in _HUB_LEVEL_EVENTS
179
+ and ctx.caller_org in (_org_of(e.sender), _org_of(e.target))
180
+ )
181
+ api_keys_count = sum(1 for k in ctx.api_keys.describe() if k["org_id"] == ctx.caller_org)
182
+ federation_peers: list[str] = [] # information hub-wide, réservée à `admin`
183
+ else:
184
+ identities = list(ctx.identity_registry.values())
185
+ tasks = list(ctx.task_registry.values())
186
+ audit_entries = len(ctx.audit_log.chain)
187
+ api_keys_count = len(ctx.api_keys)
188
+ federation_peers = sorted(ctx.peered_hubs.keys())
189
+
190
+ by_status: dict[str, int] = {}
191
+ for task in tasks:
192
+ by_status[task.status.value] = by_status.get(task.status.value, 0) + 1
193
+ agents_online = sum(1 for i in identities if i.qualified_name in ctx.agents)
194
+
195
+ return {
196
+ "org": ctx.caller_org if ctx.scoped else ctx.my_org,
197
+ "version": ctx.hub_version,
198
+ "agents_online": agents_online,
199
+ "agents_known": len(identities),
200
+ "tasks_total": len(tasks),
201
+ "tasks_by_status": by_status,
202
+ "audit_entries": audit_entries,
203
+ "audit_intact": ctx.audit_log.verify_integrity(),
204
+ "federation_peers": federation_peers,
205
+ "state_backend": getattr(ctx.store, "description", "inconnu"),
206
+ "state_ephemeral": getattr(ctx.store, "ephemeral", True),
207
+ "api_keys": api_keys_count,
208
+ "api_keys_source": ctx.api_keys.source,
209
+ "api_keys_mutable": ctx.api_keys.mutable,
210
+ "server_time": time.time(),
211
+ }
212
+
213
+
214
+ def _agents_list(ctx: AdminContext, params: dict) -> dict:
215
+ """
216
+ Inventaire complet : les agents connectés, mais aussi ceux connus et
217
+ actuellement hors ligne — invisibles jusqu'ici faute de persistance.
218
+ """
219
+ out = []
220
+ for name, identity in sorted(ctx.identity_registry.items()):
221
+ if ctx.scoped and identity.org_id != ctx.caller_org:
222
+ continue
223
+ pending = sum(
224
+ 1 for t in ctx.task_registry.values()
225
+ if t.assignee == name and t.status in (TaskStatus.PENDING, TaskStatus.RUNNING)
226
+ )
227
+ out.append({
228
+ "name": name,
229
+ "agent_id": identity.agent_id,
230
+ "org_id": identity.org_id,
231
+ "capabilities": identity.capabilities,
232
+ "roles": identity.roles,
233
+ "permissions": identity.permissions,
234
+ "metadata": identity.metadata,
235
+ "fingerprint": identity.fingerprint,
236
+ "encrypted": bool(identity.public_key),
237
+ "online": name in ctx.agents,
238
+ "pending_tasks": pending,
239
+ })
240
+ online = sum(1 for a in out if a["online"])
241
+ return {"agents": out, "online": online, "known": len(out)}
242
+
243
+
244
+ async def _agent_disconnect(ctx: AdminContext, params: dict) -> dict:
245
+ name = params.get("name")
246
+ if ctx.scoped:
247
+ identity = ctx.identity_registry.get(name)
248
+ if identity is None or identity.org_id != ctx.caller_org:
249
+ raise AdminError(f"'{name}' est hors de votre organisation.")
250
+ ws = ctx.agents.get(name)
251
+ if ws is None:
252
+ raise AdminError(f"'{name}' n'est pas connecté.")
253
+ await ws.close()
254
+ return {"disconnected": name}
255
+
256
+
257
+ def _tasks_list(ctx: AdminContext, params: dict) -> dict:
258
+ status = params.get("status")
259
+ assignee = params.get("assignee")
260
+ limit = min(int(params.get("limit", 100)), 500)
261
+
262
+ tasks = list(ctx.task_registry.values())
263
+ if ctx.scoped:
264
+ tasks = [t for t in tasks
265
+ if ctx.caller_org in (_org_of(t.orchestrator), _org_of(t.assignee))]
266
+ if status:
267
+ tasks = [t for t in tasks if t.status.value == status]
268
+ if assignee:
269
+ tasks = [t for t in tasks if t.assignee == assignee]
270
+ tasks.sort(key=lambda t: t.updated_at, reverse=True)
271
+
272
+ return {
273
+ "tasks": [
274
+ {
275
+ "task_id": t.task_id,
276
+ "title": t.title,
277
+ "orchestrator": t.orchestrator,
278
+ "assignee": t.assignee,
279
+ "status": t.status.value,
280
+ "error_message": t.error_message,
281
+ "created_at": t.created_at,
282
+ "updated_at": t.updated_at,
283
+ # input_data et output_data sont chiffrés de bout en bout :
284
+ # le Hub ne peut pas les lire, la console non plus.
285
+ "encrypted_payload": isinstance(t.input_data, str),
286
+ }
287
+ for t in tasks[:limit]
288
+ ],
289
+ "total": len(tasks),
290
+ }
291
+
292
+
293
+ def _check_task_in_scope(ctx: AdminContext, task) -> None:
294
+ if ctx.scoped and ctx.caller_org not in (_org_of(task.orchestrator), _org_of(task.assignee)):
295
+ raise AdminError("Cette tâche est hors de votre organisation.")
296
+
297
+
298
+ def _task_cancel(ctx: AdminContext, params: dict) -> dict:
299
+ task = ctx.task_registry.get(params.get("task_id"))
300
+ if task is None:
301
+ raise AdminError("Tâche inconnue.")
302
+ _check_task_in_scope(ctx, task)
303
+ if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
304
+ raise AdminError(f"Tâche déjà terminée ({task.status.value}).")
305
+
306
+ task.update_status(TaskStatus.FAILED, error_message="Annulée par un administrateur.")
307
+ ctx.remember_task(task)
308
+ return {"task_id": task.task_id, "status": task.status.value}
309
+
310
+
311
+ async def _task_retry(ctx: AdminContext, params: dict) -> dict:
312
+ task = ctx.task_registry.get(params.get("task_id"))
313
+ if task is None:
314
+ raise AdminError("Tâche inconnue.")
315
+ _check_task_in_scope(ctx, task)
316
+
317
+ task.update_status(TaskStatus.PENDING)
318
+ ctx.remember_task(task)
319
+
320
+ delivered = await ctx.send_to_agent(task)
321
+ return {
322
+ "task_id": task.task_id,
323
+ "status": task.status.value,
324
+ # Si l'exécutant est absent, la tâche reste en attente : elle lui
325
+ # sera repoussée à sa reconnexion par le mécanisme de reprise.
326
+ "delivered": delivered,
327
+ }
328
+
329
+
330
+ def _audit_list(ctx: AdminContext, params: dict) -> dict:
331
+ limit = min(int(params.get("limit", 100)), 1000)
332
+ offset = int(params.get("offset", 0))
333
+ event_type = params.get("event_type")
334
+
335
+ chain = ctx.audit_log.chain
336
+ entries = [e.to_dict() for e in chain]
337
+ if ctx.scoped:
338
+ entries = [
339
+ e for e in entries
340
+ if e["event_type"] not in _HUB_LEVEL_EVENTS
341
+ and ctx.caller_org in (_org_of(e["sender"]), _org_of(e.get("target")))
342
+ ]
343
+ if event_type:
344
+ entries = [e for e in entries if e["event_type"] == event_type]
345
+ entries.reverse()
346
+
347
+ return {
348
+ "entries": entries[offset:offset + limit],
349
+ "total": len(entries),
350
+ "chain_length": len(chain),
351
+ "intact": ctx.audit_log.verify_integrity(),
352
+ }
353
+
354
+
355
+ def _audit_verify(ctx: AdminContext, params: dict) -> dict:
356
+ """
357
+ Revérifie la chaîne et localise la première rupture s'il y en a une.
358
+ """
359
+ chain = ctx.audit_log.chain
360
+ broken_at = None
361
+ for i in range(1, len(chain)):
362
+ if chain[i].prev_hash != chain[i - 1].hash or chain[i].hash != chain[i].compute_hash():
363
+ broken_at = i
364
+ break
365
+
366
+ return {
367
+ "intact": broken_at is None,
368
+ "chain_length": len(chain),
369
+ "broken_at_index": broken_at,
370
+ "verified_at": time.time(),
371
+ }
372
+
373
+
374
+ def _apikeys_list(ctx: AdminContext, params: dict) -> dict:
375
+ keys = ctx.api_keys.describe()
376
+ if ctx.scoped:
377
+ keys = [k for k in keys if k["org_id"] == ctx.caller_org]
378
+ return {
379
+ "keys": keys,
380
+ "source": ctx.api_keys.source,
381
+ "mutable": ctx.api_keys.mutable,
382
+ }
383
+
384
+
385
+ def _apikey_create(ctx: AdminContext, params: dict) -> dict:
386
+ org_id = params.get("org_id")
387
+ if ctx.scoped:
388
+ # Un `org_admin` ne crée que pour sa propre organisation : accepter
389
+ # un `org_id` différent laisserait une entreprise émettre des clés
390
+ # au nom d'une autre.
391
+ if org_id and org_id != ctx.caller_org:
392
+ raise AdminError("Un org_admin ne peut créer de clé que pour sa propre organisation.")
393
+ org_id = ctx.caller_org
394
+ elif not org_id:
395
+ raise AdminError("org_id requis.")
396
+ try:
397
+ raw = ctx.api_keys.create(
398
+ org_id=org_id,
399
+ roles=params.get("roles") or ["service_account"],
400
+ permissions=params.get("permissions") or [],
401
+ label=params.get("label", ""),
402
+ )
403
+ except PermissionError as exc:
404
+ raise AdminError(str(exc)) from exc
405
+
406
+ # Unique occasion où la valeur en clair transite. Elle n'est pas
407
+ # journalisée et le Hub ne peut plus la retrouver ensuite.
408
+ return {"key": raw, "warning": "Cette clé ne sera plus jamais affichée."}
409
+
410
+
411
+ def _apikey_revoke(ctx: AdminContext, params: dict) -> dict:
412
+ fingerprint = params.get("fingerprint")
413
+ if not fingerprint:
414
+ raise AdminError("fingerprint requis.")
415
+ if ctx.scoped:
416
+ matches = [k for k in ctx.api_keys.describe() if k["fingerprint"].startswith(fingerprint)]
417
+ if len(matches) != 1 or matches[0]["org_id"] != ctx.caller_org:
418
+ raise AdminError("Empreinte inconnue ou hors de votre organisation.")
419
+ try:
420
+ ok = ctx.api_keys.revoke(fingerprint)
421
+ except PermissionError as exc:
422
+ raise AdminError(str(exc)) from exc
423
+ if not ok:
424
+ raise AdminError("Empreinte inconnue ou ambiguë.")
425
+ return {"revoked": fingerprint}
426
+
427
+
428
+ def _policy_to_dict(policy: GuardrailPolicy) -> dict:
429
+ return dataclasses.asdict(policy)
430
+
431
+
432
+ def _resolve_target_org(ctx: AdminContext, params: dict) -> str:
433
+ """
434
+ Organisation ciblée par une commande de garde-fous.
435
+
436
+ Un `org_admin` ne peut lire/modifier que sa propre organisation : un
437
+ `org_id` différent dans les paramètres est refusé plutôt qu'ignoré, pour
438
+ ne jamais laisser croire qu'une action a porté sur la bonne cible.
439
+ """
440
+ org_id = params.get("org_id")
441
+ if ctx.scoped:
442
+ if org_id and org_id != ctx.caller_org:
443
+ raise AdminError("Un org_admin ne peut consulter/modifier que sa propre organisation.")
444
+ return ctx.caller_org
445
+ return org_id or ctx.my_org
446
+
447
+
448
+ def _guardrails_policy(ctx: AdminContext, params: dict) -> dict:
449
+ if ctx.asimov_engine is None:
450
+ raise AdminError("Moteur de garde-fous indisponible sur ce Hub.")
451
+ org_id = _resolve_target_org(ctx, params)
452
+ return {"org_id": org_id, "policy": _policy_to_dict(ctx.asimov_engine.get_policy(org_id))}
453
+
454
+
455
+ def _guardrails_set_policy(ctx: AdminContext, params: dict) -> dict:
456
+ """
457
+ Met à jour la policy d'une organisation en ne remplaçant que les champs
458
+ fournis — les autres restent hérités de la policy actuelle de cette
459
+ organisation (ou de la policy par défaut du Hub si elle n'en a pas encore).
460
+ """
461
+ if ctx.asimov_engine is None:
462
+ raise AdminError("Moteur de garde-fous indisponible sur ce Hub.")
463
+ org_id = _resolve_target_org(ctx, params)
464
+
465
+ current = _policy_to_dict(ctx.asimov_engine.get_policy(org_id))
466
+ editable = {f.name for f in dataclasses.fields(GuardrailPolicy)} - {"name"}
467
+ for key, value in params.items():
468
+ if key in editable:
469
+ current[key] = value
470
+
471
+ new_policy = GuardrailPolicy(**current)
472
+ ctx.asimov_engine.set_org_policy(org_id, new_policy)
473
+ return {"org_id": org_id, "policy": _policy_to_dict(new_policy)}
474
+
475
+
476
+ def _require_escrow(ctx: AdminContext):
477
+ if ctx.escrow_manager is None:
478
+ raise AdminError("Le module Escrow n'est pas actif sur ce Hub.")
479
+ return ctx.escrow_manager
480
+
481
+
482
+ def _escrow_balance(ctx: AdminContext, params: dict) -> dict:
483
+ manager = _require_escrow(ctx)
484
+ org_id = _resolve_target_org(ctx, params)
485
+ currency = params.get("currency", "USD")
486
+ return {"org_id": org_id, "currency": currency, "balance": manager.ledger.balance(org_id, currency)}
487
+
488
+
489
+ def _escrow_grant(ctx: AdminContext, params: dict) -> dict:
490
+ """
491
+ Crédite un solde SIMULÉ pour une organisation — un robinet de démo/test,
492
+ jamais un vrai mouvement d'argent (voir le docstring de `escrow.py`).
493
+ """
494
+ manager = _require_escrow(ctx)
495
+ org_id = _resolve_target_org(ctx, params)
496
+ amount = params.get("amount")
497
+ if not amount:
498
+ raise AdminError("amount requis.")
499
+ currency = params.get("currency", "USD")
500
+ try:
501
+ new_balance = manager.ledger.grant(org_id, float(amount), currency)
502
+ except EscrowError as exc:
503
+ raise AdminError(str(exc)) from exc
504
+ return {"org_id": org_id, "currency": currency, "balance": new_balance, "simulated": True}
505
+
506
+
507
+ def _escrow_list(ctx: AdminContext, params: dict) -> dict:
508
+ manager = _require_escrow(ctx)
509
+ org_id = ctx.caller_org if ctx.scoped else params.get("org_id")
510
+ holds = manager.list_holds(org_id)
511
+ return {"holds": [h.to_dict() for h in holds], "total": len(holds)}
512
+
513
+
514
+ def _check_hold_in_scope(ctx: AdminContext, hold) -> None:
515
+ if ctx.scoped and ctx.caller_org not in (hold.payer_org, hold.payee_org):
516
+ raise AdminError("Ce séquestre est hors de votre organisation.")
517
+
518
+
519
+ def _escrow_release(ctx: AdminContext, params: dict) -> dict:
520
+ manager = _require_escrow(ctx)
521
+ task_id = params.get("task_id")
522
+ hold = manager.get(task_id) if task_id else None
523
+ if hold is None:
524
+ raise AdminError("Aucun séquestre pour cette tâche.")
525
+ _check_hold_in_scope(ctx, hold)
526
+ try:
527
+ released = manager.release(task_id)
528
+ except EscrowError as exc:
529
+ raise AdminError(str(exc)) from exc
530
+ return released.to_dict()
531
+
532
+
533
+ def _escrow_refund(ctx: AdminContext, params: dict) -> dict:
534
+ manager = _require_escrow(ctx)
535
+ task_id = params.get("task_id")
536
+ hold = manager.get(task_id) if task_id else None
537
+ if hold is None:
538
+ raise AdminError("Aucun séquestre pour cette tâche.")
539
+ _check_hold_in_scope(ctx, hold)
540
+ try:
541
+ refunded = manager.refund(task_id)
542
+ except EscrowError as exc:
543
+ raise AdminError(str(exc)) from exc
544
+ return refunded.to_dict()
545
+
546
+
547
+ # ----------------------------------------------------------------------
548
+ # Instantanés
549
+ # ----------------------------------------------------------------------
550
+
551
+ def _require_hub_admin(ctx: AdminContext, action: str) -> None:
552
+ """
553
+ Un instantané est hub-wide par nature : il contient le registre, les
554
+ clés et les séquestres de TOUTES les organisations. Un `org_admin` qui
555
+ pourrait le créer lirait ses voisins ; s'il pouvait le restaurer, il
556
+ écraserait leur état. Ces commandes sont donc réservées à `admin`.
557
+ """
558
+ if ctx.scoped:
559
+ raise AdminError(
560
+ f"'{action}' porte sur l'ensemble du Hub, toutes organisations "
561
+ f"confondues : le rôle 'admin' est requis, 'org_admin' ne suffit pas."
562
+ )
563
+
564
+
565
+ def _audit_head(ctx: AdminContext) -> dict:
566
+ chain = getattr(ctx.audit_log, "chain", None)
567
+ if not chain:
568
+ return {}
569
+ return {"index": chain[-1].index, "hash": chain[-1].hash}
570
+
571
+
572
+ def _capture(ctx: AdminContext) -> dict:
573
+ return snapshot_store.capture_state(
574
+ identity_registry=ctx.identity_registry,
575
+ task_registry=ctx.task_registry,
576
+ api_keys=ctx.api_keys,
577
+ asimov_engine=ctx.asimov_engine,
578
+ escrow_manager=ctx.escrow_manager,
579
+ )
580
+
581
+
582
+ def _snapshot_create(ctx: AdminContext, params: dict) -> dict:
583
+ _require_hub_admin(ctx, "snapshot.create")
584
+ name = params.get("name")
585
+ if not name:
586
+ raise AdminError("name requis.")
587
+ try:
588
+ manifest = snapshot_store.save(
589
+ name,
590
+ _capture(ctx),
591
+ hub_org=ctx.my_org,
592
+ audit_head=_audit_head(ctx),
593
+ directory=ctx.snapshot_dir,
594
+ passphrase=params.get("passphrase"),
595
+ overwrite=bool(params.get("overwrite", True)),
596
+ )
597
+ except SnapshotError as exc:
598
+ raise AdminError(str(exc)) from exc
599
+
600
+ if not manifest["encrypted"]:
601
+ manifest["warning"] = (
602
+ "Instantané non chiffré. Le fichier est en 0600 mais contient les "
603
+ "empreintes de clés d'API et l'inventaire de toutes les organisations : "
604
+ "fournissez `passphrase` avant de le sortir de cette machine."
605
+ )
606
+ return manifest
607
+
608
+
609
+ def _snapshot_list(ctx: AdminContext, params: dict) -> dict:
610
+ _require_hub_admin(ctx, "snapshot.list")
611
+ snapshots = snapshot_store.list_snapshots(ctx.snapshot_dir)
612
+ return {"snapshots": snapshots, "total": len(snapshots)}
613
+
614
+
615
+ def _snapshot_delete(ctx: AdminContext, params: dict) -> dict:
616
+ _require_hub_admin(ctx, "snapshot.delete")
617
+ name = params.get("name")
618
+ if not name:
619
+ raise AdminError("name requis.")
620
+ try:
621
+ deleted = snapshot_store.delete(name, ctx.snapshot_dir)
622
+ except SnapshotError as exc:
623
+ raise AdminError(str(exc)) from exc
624
+ if not deleted:
625
+ raise AdminError(f"Instantané inconnu : '{name}'.")
626
+ return {"deleted": name}
627
+
628
+
629
+ def _snapshot_restore(ctx: AdminContext, params: dict) -> dict:
630
+ """
631
+ Réinstalle un instantané, après en avoir pris un de sécurité.
632
+
633
+ Le filet de sécurité est pris AVANT toute écriture, avec la même
634
+ passphrase que celle fournie pour la lecture : une restauration ratée
635
+ reste elle-même réversible. `safety_snapshot=False` le désactive.
636
+ """
637
+ _require_hub_admin(ctx, "snapshot.restore")
638
+ name = params.get("name")
639
+ if not name:
640
+ raise AdminError("name requis.")
641
+ passphrase = params.get("passphrase")
642
+
643
+ try:
644
+ manifest, state = snapshot_store.load(
645
+ name, directory=ctx.snapshot_dir, passphrase=passphrase,
646
+ )
647
+ except SnapshotError as exc:
648
+ raise AdminError(str(exc)) from exc
649
+
650
+ safety_name = None
651
+ if params.get("safety_snapshot", True):
652
+ safety_name = f"pre-restore-{int(time.time())}"
653
+ try:
654
+ snapshot_store.save(
655
+ safety_name, _capture(ctx), hub_org=ctx.my_org,
656
+ audit_head=_audit_head(ctx), directory=ctx.snapshot_dir,
657
+ passphrase=passphrase,
658
+ )
659
+ except SnapshotError as exc:
660
+ raise AdminError(
661
+ f"Filet de sécurité impossible à écrire ({exc}) : restauration "
662
+ f"abandonnée. Passez `safety_snapshot: false` pour forcer."
663
+ ) from exc
664
+
665
+ report = snapshot_store.apply_state(
666
+ state,
667
+ identity_registry=ctx.identity_registry,
668
+ task_registry=ctx.task_registry,
669
+ connected_agents=ctx.agents,
670
+ store=ctx.store,
671
+ api_keys=ctx.api_keys,
672
+ asimov_engine=ctx.asimov_engine,
673
+ escrow_manager=ctx.escrow_manager,
674
+ )
675
+
676
+ # La chaîne d'audit vivante n'est jamais remplacée : elle est
677
+ # prolongée. Un instantané ne peut donc pas effacer la trace de sa
678
+ # propre restauration.
679
+ if ctx.audit_log is not None:
680
+ ctx.audit_log.log("SNAPSHOT_RESTORED", "hub", None, {
681
+ "snapshot": name,
682
+ "snapshot_created_at": manifest.get("created_at"),
683
+ "safety_snapshot": safety_name,
684
+ "restored": report.get("restored"),
685
+ "skipped": [s["what"] for s in report.get("skipped", [])],
686
+ })
687
+
688
+ return {
689
+ "restored_from": name,
690
+ "manifest": manifest,
691
+ "safety_snapshot": safety_name,
692
+ "audit_preserved": True,
693
+ **report,
694
+ }
695
+
696
+
697
+ HANDLERS: dict[str, Callable[..., Any]] = {
698
+ "hub.info": _hub_info,
699
+ "agents.list": _agents_list,
700
+ "agent.disconnect": _agent_disconnect,
701
+ "tasks.list": _tasks_list,
702
+ "task.cancel": _task_cancel,
703
+ "task.retry": _task_retry,
704
+ "audit.list": _audit_list,
705
+ "audit.verify": _audit_verify,
706
+ "apikeys.list": _apikeys_list,
707
+ "apikey.create": _apikey_create,
708
+ "apikey.revoke": _apikey_revoke,
709
+ "guardrails.policy": _guardrails_policy,
710
+ "guardrails.set_policy": _guardrails_set_policy,
711
+ "escrow.balance": _escrow_balance,
712
+ "escrow.grant": _escrow_grant,
713
+ "escrow.list": _escrow_list,
714
+ "escrow.release": _escrow_release,
715
+ "escrow.refund": _escrow_refund,
716
+ "snapshot.create": _snapshot_create,
717
+ "snapshot.list": _snapshot_list,
718
+ "snapshot.restore": _snapshot_restore,
719
+ "snapshot.delete": _snapshot_delete,
720
+ }
721
+
722
+
723
+ async def execute(command: str, params: dict, ctx: AdminContext) -> dict:
724
+ """Exécute une commande d'administration déjà autorisée."""
725
+ handler = HANDLERS.get(command)
726
+ if handler is None:
727
+ raise AdminError(f"Commande inconnue : '{command}'. "
728
+ f"Disponibles : {', '.join(sorted(HANDLERS))}")
729
+
730
+ result = handler(ctx, params or {})
731
+ if hasattr(result, "__await__"):
732
+ result = await result
733
+ return result