agent_hypervisor 3.1.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.
- agent_hypervisor-3.1.0.dist-info/METADATA +824 -0
- agent_hypervisor-3.1.0.dist-info/RECORD +60 -0
- agent_hypervisor-3.1.0.dist-info/WHEEL +4 -0
- agent_hypervisor-3.1.0.dist-info/entry_points.txt +2 -0
- agent_hypervisor-3.1.0.dist-info/licenses/LICENSE +21 -0
- hypervisor/__init__.py +160 -0
- hypervisor/api/__init__.py +7 -0
- hypervisor/api/models.py +285 -0
- hypervisor/api/server.py +742 -0
- hypervisor/audit/__init__.py +4 -0
- hypervisor/audit/commitment.py +76 -0
- hypervisor/audit/delta.py +135 -0
- hypervisor/audit/gc.py +99 -0
- hypervisor/cli/__init__.py +3 -0
- hypervisor/cli/formatters.py +99 -0
- hypervisor/cli/session_commands.py +200 -0
- hypervisor/constants.py +106 -0
- hypervisor/core.py +352 -0
- hypervisor/integrations/__init__.py +10 -0
- hypervisor/integrations/iatp_adapter.py +142 -0
- hypervisor/integrations/nexus_adapter.py +108 -0
- hypervisor/integrations/verification_adapter.py +122 -0
- hypervisor/liability/__init__.py +142 -0
- hypervisor/liability/attribution.py +86 -0
- hypervisor/liability/ledger.py +121 -0
- hypervisor/liability/quarantine.py +119 -0
- hypervisor/liability/slashing.py +80 -0
- hypervisor/liability/vouching.py +134 -0
- hypervisor/models.py +277 -0
- hypervisor/observability/__init__.py +27 -0
- hypervisor/observability/causal_trace.py +70 -0
- hypervisor/observability/event_bus.py +222 -0
- hypervisor/observability/prometheus_collector.py +248 -0
- hypervisor/observability/saga_span_exporter.py +341 -0
- hypervisor/providers.py +121 -0
- hypervisor/py.typed +0 -0
- hypervisor/reversibility/__init__.py +3 -0
- hypervisor/reversibility/registry.py +108 -0
- hypervisor/rings/__init__.py +21 -0
- hypervisor/rings/breach_detector.py +200 -0
- hypervisor/rings/classifier.py +78 -0
- hypervisor/rings/elevation.py +219 -0
- hypervisor/rings/enforcer.py +97 -0
- hypervisor/saga/__init__.py +22 -0
- hypervisor/saga/checkpoint.py +110 -0
- hypervisor/saga/dsl.py +190 -0
- hypervisor/saga/fan_out.py +126 -0
- hypervisor/saga/orchestrator.py +229 -0
- hypervisor/saga/schema.py +244 -0
- hypervisor/saga/state_machine.py +157 -0
- hypervisor/security/__init__.py +13 -0
- hypervisor/security/kill_switch.py +200 -0
- hypervisor/security/rate_limiter.py +190 -0
- hypervisor/session/__init__.py +194 -0
- hypervisor/session/intent_locks.py +118 -0
- hypervisor/session/isolation.py +37 -0
- hypervisor/session/sso.py +169 -0
- hypervisor/session/vector_clock.py +118 -0
- hypervisor/verification/__init__.py +3 -0
- hypervisor/verification/history.py +173 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
# Public Preview — basic implementation
|
|
4
|
+
"""
|
|
5
|
+
Hash Commitment — stub implementation.
|
|
6
|
+
|
|
7
|
+
Public Preview: stores commitments in-memory only.
|
|
8
|
+
No blockchain anchoring.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CommitmentRecord:
|
|
19
|
+
"""Record of a Summary Hash commitment."""
|
|
20
|
+
|
|
21
|
+
session_id: str
|
|
22
|
+
hash_chain_root: str
|
|
23
|
+
participant_dids: list[str]
|
|
24
|
+
delta_count: int
|
|
25
|
+
committed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
26
|
+
blockchain_tx_id: str | None = None
|
|
27
|
+
committed_to: str = "local"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommitmentEngine:
|
|
31
|
+
"""
|
|
32
|
+
Simple in-memory commitment store.
|
|
33
|
+
|
|
34
|
+
Public Preview: stores commitments locally, no external anchoring.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._commitments: dict[str, CommitmentRecord] = {}
|
|
39
|
+
self._batch_queue: list[CommitmentRecord] = []
|
|
40
|
+
|
|
41
|
+
def commit(
|
|
42
|
+
self,
|
|
43
|
+
session_id: str,
|
|
44
|
+
hash_chain_root: str,
|
|
45
|
+
participant_dids: list[str],
|
|
46
|
+
delta_count: int,
|
|
47
|
+
) -> CommitmentRecord:
|
|
48
|
+
"""Commit a session's Summary Hash."""
|
|
49
|
+
record = CommitmentRecord(
|
|
50
|
+
session_id=session_id,
|
|
51
|
+
hash_chain_root=hash_chain_root,
|
|
52
|
+
participant_dids=participant_dids,
|
|
53
|
+
delta_count=delta_count,
|
|
54
|
+
)
|
|
55
|
+
self._commitments[session_id] = record
|
|
56
|
+
return record
|
|
57
|
+
|
|
58
|
+
def verify(self, session_id: str, expected_root: str) -> bool:
|
|
59
|
+
"""Verify a session's audit log root."""
|
|
60
|
+
record = self._commitments.get(session_id)
|
|
61
|
+
if not record:
|
|
62
|
+
return False
|
|
63
|
+
return record.hash_chain_root == expected_root
|
|
64
|
+
|
|
65
|
+
def queue_for_batch(self, record: CommitmentRecord) -> None:
|
|
66
|
+
"""Queue a commitment (Public Preview: no-op)."""
|
|
67
|
+
self._batch_queue.append(record)
|
|
68
|
+
|
|
69
|
+
def flush_batch(self) -> list[CommitmentRecord]:
|
|
70
|
+
"""Flush the batch queue."""
|
|
71
|
+
batch = list(self._batch_queue)
|
|
72
|
+
self._batch_queue.clear()
|
|
73
|
+
return batch
|
|
74
|
+
|
|
75
|
+
def get_commitment(self, session_id: str) -> CommitmentRecord | None:
|
|
76
|
+
return self._commitments.get(session_id)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""
|
|
4
|
+
Delta Audit Engine — tamper-evident append-only audit log.
|
|
5
|
+
|
|
6
|
+
Records VFS state changes as a SHA-256 hash chain where each entry
|
|
7
|
+
links to its predecessor, providing cryptographic tamper evidence.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VFSChange:
|
|
21
|
+
"""A single change within a delta."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
operation: str
|
|
25
|
+
content_hash: str | None = None
|
|
26
|
+
previous_hash: str | None = None
|
|
27
|
+
agent_did: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SemanticDelta:
|
|
32
|
+
"""A delta capturing VFS state changes at a single turn."""
|
|
33
|
+
|
|
34
|
+
delta_id: str
|
|
35
|
+
turn_id: int
|
|
36
|
+
session_id: str
|
|
37
|
+
agent_did: str
|
|
38
|
+
timestamp: datetime
|
|
39
|
+
changes: list[VFSChange]
|
|
40
|
+
parent_hash: str | None
|
|
41
|
+
delta_hash: str = ""
|
|
42
|
+
|
|
43
|
+
def _build_hash_input(self) -> str:
|
|
44
|
+
changes_data = [
|
|
45
|
+
{
|
|
46
|
+
"path": c.path,
|
|
47
|
+
"operation": c.operation,
|
|
48
|
+
"content_hash": c.content_hash,
|
|
49
|
+
"previous_hash": c.previous_hash,
|
|
50
|
+
"agent_did": c.agent_did,
|
|
51
|
+
}
|
|
52
|
+
for c in self.changes
|
|
53
|
+
]
|
|
54
|
+
return json.dumps(
|
|
55
|
+
{
|
|
56
|
+
"delta_id": self.delta_id,
|
|
57
|
+
"turn_id": self.turn_id,
|
|
58
|
+
"session_id": self.session_id,
|
|
59
|
+
"agent_did": self.agent_did,
|
|
60
|
+
"timestamp": self.timestamp.isoformat(),
|
|
61
|
+
"parent_hash": self.parent_hash or "",
|
|
62
|
+
"changes": changes_data,
|
|
63
|
+
},
|
|
64
|
+
sort_keys=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def compute_hash(self) -> str:
|
|
68
|
+
"""Compute SHA-256 hash covering all fields including changes and parent linkage."""
|
|
69
|
+
self.delta_hash = hashlib.sha256(self._build_hash_input().encode()).hexdigest()
|
|
70
|
+
return self.delta_hash
|
|
71
|
+
|
|
72
|
+
def verify_hash(self) -> bool:
|
|
73
|
+
"""Recompute hash and compare to stored value without mutation."""
|
|
74
|
+
expected = hashlib.sha256(self._build_hash_input().encode()).hexdigest()
|
|
75
|
+
return expected == self.delta_hash
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DeltaEngine:
|
|
79
|
+
"""Tamper-evident append-only audit log with SHA-256 hash chain verification."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, session_id: str) -> None:
|
|
82
|
+
self.session_id = session_id
|
|
83
|
+
self._deltas: list[SemanticDelta] = []
|
|
84
|
+
self._turn_counter = 0
|
|
85
|
+
|
|
86
|
+
def capture(
|
|
87
|
+
self,
|
|
88
|
+
agent_did: str,
|
|
89
|
+
changes: list[VFSChange],
|
|
90
|
+
delta_id: str | None = None,
|
|
91
|
+
) -> SemanticDelta:
|
|
92
|
+
"""Capture a delta for a turn, chaining to previous entry."""
|
|
93
|
+
self._turn_counter += 1
|
|
94
|
+
parent_hash = self._deltas[-1].delta_hash if self._deltas else None
|
|
95
|
+
delta = SemanticDelta(
|
|
96
|
+
delta_id=delta_id or f"delta:{self._turn_counter}",
|
|
97
|
+
turn_id=self._turn_counter,
|
|
98
|
+
session_id=self.session_id,
|
|
99
|
+
agent_did=agent_did,
|
|
100
|
+
timestamp=datetime.now(UTC),
|
|
101
|
+
changes=changes,
|
|
102
|
+
parent_hash=parent_hash,
|
|
103
|
+
)
|
|
104
|
+
delta.compute_hash()
|
|
105
|
+
self._deltas.append(delta)
|
|
106
|
+
return delta
|
|
107
|
+
|
|
108
|
+
def compute_hash_chain_root(self) -> str | None:
|
|
109
|
+
"""Return hash of last delta in the chain."""
|
|
110
|
+
if not self._deltas:
|
|
111
|
+
return None
|
|
112
|
+
return self._deltas[-1].delta_hash
|
|
113
|
+
|
|
114
|
+
def verify_chain(self) -> tuple[bool, Optional[str]]:
|
|
115
|
+
"""Verify full chain integrity: hash correctness and parent linkage."""
|
|
116
|
+
if not self._deltas:
|
|
117
|
+
return True, None
|
|
118
|
+
|
|
119
|
+
previous_hash = None
|
|
120
|
+
for i, delta in enumerate(self._deltas):
|
|
121
|
+
if not delta.verify_hash():
|
|
122
|
+
return False, f"Entry {i} hash mismatch"
|
|
123
|
+
if delta.parent_hash != previous_hash:
|
|
124
|
+
return False, f"Entry {i} chain broken"
|
|
125
|
+
previous_hash = delta.delta_hash
|
|
126
|
+
|
|
127
|
+
return True, None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def deltas(self) -> list[SemanticDelta]:
|
|
131
|
+
return list(self._deltas)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def turn_count(self) -> int:
|
|
135
|
+
return self._turn_counter
|
hypervisor/audit/gc.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
# Public Preview — basic implementation
|
|
4
|
+
"""
|
|
5
|
+
Ephemeral Session Data Garbage Collection — stub implementation.
|
|
6
|
+
|
|
7
|
+
Public Preview: GC is a no-op. Data is retained in-memory for
|
|
8
|
+
session lifetime only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class GCResult:
|
|
20
|
+
"""Result of a garbage collection run."""
|
|
21
|
+
|
|
22
|
+
session_id: str
|
|
23
|
+
retained_deltas: int
|
|
24
|
+
retained_hash: bool
|
|
25
|
+
purged_vfs_files: int
|
|
26
|
+
purged_caches: int
|
|
27
|
+
storage_before_bytes: int
|
|
28
|
+
storage_after_bytes: int
|
|
29
|
+
gc_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def storage_saved_bytes(self) -> int:
|
|
33
|
+
return self.storage_before_bytes - self.storage_after_bytes
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def savings_pct(self) -> float:
|
|
37
|
+
if self.storage_before_bytes == 0:
|
|
38
|
+
return 0.0
|
|
39
|
+
return (self.storage_saved_bytes / self.storage_before_bytes) * 100
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RetentionPolicy:
|
|
44
|
+
"""Configuration for what to retain after GC."""
|
|
45
|
+
|
|
46
|
+
delta_retention_days: int = 180
|
|
47
|
+
hash_retention: str = "permanent"
|
|
48
|
+
liability_snapshot: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EphemeralGC:
|
|
52
|
+
"""
|
|
53
|
+
GC stub (Public Preview: logs collection requests, no actual purge).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, policy: RetentionPolicy | None = None) -> None:
|
|
57
|
+
self.policy = policy or RetentionPolicy()
|
|
58
|
+
self._gc_history: list[GCResult] = []
|
|
59
|
+
self._purged_sessions: set[str] = set()
|
|
60
|
+
|
|
61
|
+
def collect(
|
|
62
|
+
self,
|
|
63
|
+
session_id: str,
|
|
64
|
+
vfs: Any = None,
|
|
65
|
+
delta_engine: Any = None,
|
|
66
|
+
vfs_file_count: int = 0,
|
|
67
|
+
cache_count: int = 0,
|
|
68
|
+
delta_count: int = 0,
|
|
69
|
+
estimated_vfs_bytes: int = 0,
|
|
70
|
+
estimated_cache_bytes: int = 0,
|
|
71
|
+
estimated_delta_bytes: int = 0,
|
|
72
|
+
) -> GCResult:
|
|
73
|
+
"""Log a GC request (Public Preview: no actual purge)."""
|
|
74
|
+
result = GCResult(
|
|
75
|
+
session_id=session_id,
|
|
76
|
+
retained_deltas=delta_count,
|
|
77
|
+
retained_hash=True,
|
|
78
|
+
purged_vfs_files=0,
|
|
79
|
+
purged_caches=0,
|
|
80
|
+
storage_before_bytes=estimated_vfs_bytes + estimated_cache_bytes + estimated_delta_bytes,
|
|
81
|
+
storage_after_bytes=estimated_vfs_bytes + estimated_cache_bytes + estimated_delta_bytes,
|
|
82
|
+
)
|
|
83
|
+
self._gc_history.append(result)
|
|
84
|
+
self._purged_sessions.add(session_id)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
def is_purged(self, session_id: str) -> bool:
|
|
88
|
+
return session_id in self._purged_sessions
|
|
89
|
+
|
|
90
|
+
def should_expire_deltas(self, delta_timestamp: datetime) -> bool:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def history(self) -> list[GCResult]:
|
|
95
|
+
return list(self._gc_history)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def purged_session_count(self) -> int:
|
|
99
|
+
return len(self._purged_sessions)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Output formatters for CLI commands."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_output(data: Any, fmt: str) -> str:
|
|
12
|
+
"""Format data as table, json, or yaml."""
|
|
13
|
+
if fmt == "json":
|
|
14
|
+
return json.dumps(data, indent=2, default=str)
|
|
15
|
+
if fmt == "yaml":
|
|
16
|
+
return _to_yaml(data)
|
|
17
|
+
# Default: table
|
|
18
|
+
if isinstance(data, list):
|
|
19
|
+
return _format_table(data)
|
|
20
|
+
if isinstance(data, dict):
|
|
21
|
+
return _format_dict(data)
|
|
22
|
+
return str(data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_table(rows: list[dict[str, Any]]) -> str:
|
|
26
|
+
"""Render a list of dicts as an aligned text table."""
|
|
27
|
+
if not rows:
|
|
28
|
+
return "No results."
|
|
29
|
+
keys = list(rows[0].keys())
|
|
30
|
+
widths = {k: max(len(k), *(len(str(r.get(k, ""))) for r in rows)) for k in keys}
|
|
31
|
+
header = " ".join(k.upper().ljust(widths[k]) for k in keys)
|
|
32
|
+
sep = " ".join("-" * widths[k] for k in keys)
|
|
33
|
+
lines = [header, sep]
|
|
34
|
+
for row in rows:
|
|
35
|
+
lines.append(" ".join(str(row.get(k, "")).ljust(widths[k]) for k in keys))
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_dict(d: dict[str, Any]) -> str:
|
|
40
|
+
"""Render a dict as key-value pairs."""
|
|
41
|
+
if not d:
|
|
42
|
+
return "No data."
|
|
43
|
+
width = max(len(str(k)) for k in d)
|
|
44
|
+
lines = []
|
|
45
|
+
for k, v in d.items():
|
|
46
|
+
if isinstance(v, list):
|
|
47
|
+
lines.append(f"{str(k).ljust(width)}: ({len(v)} items)")
|
|
48
|
+
for item in v:
|
|
49
|
+
if isinstance(item, dict):
|
|
50
|
+
parts = ", ".join(f"{ik}={iv}" for ik, iv in item.items())
|
|
51
|
+
lines.append(f" - {parts}")
|
|
52
|
+
else:
|
|
53
|
+
lines.append(f" - {item}")
|
|
54
|
+
else:
|
|
55
|
+
lines.append(f"{str(k).ljust(width)}: {v}")
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_yaml(data: Any, indent: int = 0) -> str:
|
|
60
|
+
"""Minimal YAML serializer (no external dependency)."""
|
|
61
|
+
prefix = " " * indent
|
|
62
|
+
if isinstance(data, dict):
|
|
63
|
+
if not data:
|
|
64
|
+
return f"{prefix}{{}}"
|
|
65
|
+
lines = []
|
|
66
|
+
for k, v in data.items():
|
|
67
|
+
if isinstance(v, (dict, list)):
|
|
68
|
+
lines.append(f"{prefix}{k}:")
|
|
69
|
+
lines.append(_to_yaml(v, indent + 1))
|
|
70
|
+
else:
|
|
71
|
+
lines.append(f"{prefix}{k}: {_yaml_scalar(v)}")
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
if isinstance(data, list):
|
|
74
|
+
if not data:
|
|
75
|
+
return f"{prefix}[]"
|
|
76
|
+
lines = []
|
|
77
|
+
for item in data:
|
|
78
|
+
if isinstance(item, dict):
|
|
79
|
+
first = True
|
|
80
|
+
for k, v in item.items():
|
|
81
|
+
tag = "- " if first else " "
|
|
82
|
+
first = False
|
|
83
|
+
if isinstance(v, (dict, list)):
|
|
84
|
+
lines.append(f"{prefix}{tag}{k}:")
|
|
85
|
+
lines.append(_to_yaml(v, indent + 2))
|
|
86
|
+
else:
|
|
87
|
+
lines.append(f"{prefix}{tag}{k}: {_yaml_scalar(v)}")
|
|
88
|
+
else:
|
|
89
|
+
lines.append(f"{prefix}- {_yaml_scalar(item)}")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
return f"{prefix}{_yaml_scalar(data)}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _yaml_scalar(v: Any) -> str:
|
|
95
|
+
if v is None:
|
|
96
|
+
return "null"
|
|
97
|
+
if isinstance(v, bool):
|
|
98
|
+
return "true" if v else "false"
|
|
99
|
+
return str(v)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Session inspection CLI commands."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from hypervisor.cli.formatters import format_output
|
|
12
|
+
from hypervisor.core import Hypervisor, ManagedSession
|
|
13
|
+
from hypervisor.security.kill_switch import KillReason, KillSwitch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_session_summary(managed: ManagedSession) -> dict[str, Any]:
|
|
17
|
+
"""Build a summary dict for a single session."""
|
|
18
|
+
sso = managed.sso
|
|
19
|
+
return {
|
|
20
|
+
"session_id": sso.session_id,
|
|
21
|
+
"state": sso.state.value,
|
|
22
|
+
"participants": sso.participant_count,
|
|
23
|
+
"consistency": sso.consistency_mode.value,
|
|
24
|
+
"created_at": sso.created_at.isoformat(),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_session_detail(managed: ManagedSession) -> dict[str, Any]:
|
|
29
|
+
"""Build a detailed inspection dict for a session."""
|
|
30
|
+
sso = managed.sso
|
|
31
|
+
|
|
32
|
+
participants = [
|
|
33
|
+
{
|
|
34
|
+
"agent_did": p.agent_did,
|
|
35
|
+
"ring": p.ring.value,
|
|
36
|
+
"eff_score": round(p.eff_score, 4),
|
|
37
|
+
"sigma_raw": round(p.sigma_raw, 4),
|
|
38
|
+
"is_active": p.is_active,
|
|
39
|
+
}
|
|
40
|
+
for p in sso.participants
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Saga steps
|
|
44
|
+
saga_steps: list[dict[str, Any]] = []
|
|
45
|
+
for saga in managed.saga._sagas.values():
|
|
46
|
+
for step in saga.steps:
|
|
47
|
+
saga_steps.append({
|
|
48
|
+
"saga_id": saga.saga_id,
|
|
49
|
+
"step_id": step.step_id,
|
|
50
|
+
"action_id": step.action_id,
|
|
51
|
+
"agent_did": step.agent_did,
|
|
52
|
+
"state": step.state.value,
|
|
53
|
+
"error": step.error,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
# Audit deltas
|
|
57
|
+
audit_entries = [
|
|
58
|
+
{
|
|
59
|
+
"delta_id": d.delta_id,
|
|
60
|
+
"turn_id": d.turn_id,
|
|
61
|
+
"agent_did": d.agent_did,
|
|
62
|
+
"timestamp": d.timestamp.isoformat(),
|
|
63
|
+
"changes": len(d.changes),
|
|
64
|
+
}
|
|
65
|
+
for d in managed.delta_engine.deltas
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Resource usage
|
|
69
|
+
resource_usage = {
|
|
70
|
+
"vfs_files": sso.vfs.file_count,
|
|
71
|
+
"vfs_snapshots": sso.vfs.snapshot_count,
|
|
72
|
+
"audit_turns": managed.delta_engine.turn_count,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"session_id": sso.session_id,
|
|
77
|
+
"state": sso.state.value,
|
|
78
|
+
"consistency_mode": sso.consistency_mode.value,
|
|
79
|
+
"created_at": sso.created_at.isoformat(),
|
|
80
|
+
"terminated_at": sso.terminated_at.isoformat() if sso.terminated_at else None,
|
|
81
|
+
"participants": participants,
|
|
82
|
+
"saga_steps": saga_steps,
|
|
83
|
+
"resource_usage": resource_usage,
|
|
84
|
+
"audit_log": audit_entries,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_list(hv: Hypervisor, fmt: str) -> str:
|
|
89
|
+
"""List all active sessions."""
|
|
90
|
+
sessions = hv.active_sessions
|
|
91
|
+
if not sessions:
|
|
92
|
+
return "No active sessions."
|
|
93
|
+
rows = [_build_session_summary(m) for m in sessions]
|
|
94
|
+
return format_output(rows, fmt)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_inspect(hv: Hypervisor, session_id: str, fmt: str) -> str:
|
|
98
|
+
"""Inspect a single session in detail."""
|
|
99
|
+
managed = hv.get_session(session_id)
|
|
100
|
+
if managed is None:
|
|
101
|
+
return f"Error: session '{session_id}' not found."
|
|
102
|
+
detail = _build_session_detail(managed)
|
|
103
|
+
return format_output(detail, fmt)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_kill(
|
|
107
|
+
hv: Hypervisor,
|
|
108
|
+
session_id: str,
|
|
109
|
+
fmt: str,
|
|
110
|
+
kill_switch: KillSwitch | None = None,
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Trigger kill switch on all agents in a session."""
|
|
113
|
+
managed = hv.get_session(session_id)
|
|
114
|
+
if managed is None:
|
|
115
|
+
return f"Error: session '{session_id}' not found."
|
|
116
|
+
|
|
117
|
+
ks = kill_switch or KillSwitch()
|
|
118
|
+
results = []
|
|
119
|
+
for p in managed.sso.participants:
|
|
120
|
+
result = ks.kill(
|
|
121
|
+
agent_did=p.agent_did,
|
|
122
|
+
session_id=session_id,
|
|
123
|
+
reason=KillReason.MANUAL,
|
|
124
|
+
details="Killed via CLI",
|
|
125
|
+
)
|
|
126
|
+
managed.sso.leave(p.agent_did)
|
|
127
|
+
results.append({
|
|
128
|
+
"kill_id": result.kill_id,
|
|
129
|
+
"agent_did": result.agent_did,
|
|
130
|
+
"reason": result.reason.value,
|
|
131
|
+
"timestamp": result.timestamp.isoformat(),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return format_output(results, fmt)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_parser(
|
|
138
|
+
parent: argparse._SubParsersAction | None = None,
|
|
139
|
+
) -> argparse.ArgumentParser:
|
|
140
|
+
"""Build the 'session' sub-command parser."""
|
|
141
|
+
if parent is not None:
|
|
142
|
+
parser = parent.add_parser("session", help="Inspect session state")
|
|
143
|
+
else:
|
|
144
|
+
parser = argparse.ArgumentParser(prog="hypervisor session")
|
|
145
|
+
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--format",
|
|
148
|
+
choices=["table", "json", "yaml"],
|
|
149
|
+
default="table",
|
|
150
|
+
dest="output_format",
|
|
151
|
+
help="Output format (default: table)",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
sub = parser.add_subparsers(dest="session_command")
|
|
155
|
+
|
|
156
|
+
sub.add_parser("list", help="List all active sessions")
|
|
157
|
+
|
|
158
|
+
inspect_p = sub.add_parser("inspect", help="Show detailed session state")
|
|
159
|
+
inspect_p.add_argument("session_id", help="Session ID to inspect")
|
|
160
|
+
|
|
161
|
+
kill_p = sub.add_parser("kill", help="Trigger kill switch on a session")
|
|
162
|
+
kill_p.add_argument("session_id", help="Session ID to kill")
|
|
163
|
+
|
|
164
|
+
return parser
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dispatch(
|
|
168
|
+
args: argparse.Namespace,
|
|
169
|
+
hv: Hypervisor,
|
|
170
|
+
kill_switch: KillSwitch | None = None,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Dispatch a parsed session command to the appropriate handler."""
|
|
173
|
+
fmt = getattr(args, "output_format", "table")
|
|
174
|
+
cmd = args.session_command
|
|
175
|
+
|
|
176
|
+
if cmd == "list":
|
|
177
|
+
return cmd_list(hv, fmt)
|
|
178
|
+
elif cmd == "inspect":
|
|
179
|
+
return cmd_inspect(hv, args.session_id, fmt)
|
|
180
|
+
elif cmd == "kill":
|
|
181
|
+
return cmd_kill(hv, args.session_id, fmt, kill_switch)
|
|
182
|
+
else:
|
|
183
|
+
return "Error: specify a sub-command (list, inspect, kill)."
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> None:
|
|
187
|
+
"""Entry point for the CLI."""
|
|
188
|
+
top = argparse.ArgumentParser(prog="hypervisor")
|
|
189
|
+
sub = top.add_subparsers(dest="command")
|
|
190
|
+
build_parser(sub)
|
|
191
|
+
|
|
192
|
+
args = top.parse_args(argv)
|
|
193
|
+
if args.command != "session" or not args.session_command:
|
|
194
|
+
top.print_help()
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
# In standalone mode, create an empty hypervisor (useful for testing the parser).
|
|
198
|
+
hv = Hypervisor()
|
|
199
|
+
output = dispatch(args, hv)
|
|
200
|
+
print(output)
|