aeonic-agentguard-sdk-python 0.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.
@@ -0,0 +1,209 @@
1
+ """
2
+ Agent Registry - Global in-memory registry for declared agents.
3
+
4
+ This module tracks all agents declared via SDK APIs (with_agent() / @with_agent)
5
+ at declaration time, NOT via static code scanning.
6
+ """
7
+
8
+ import threading
9
+ from time import time
10
+ from typing import Dict, List, Optional
11
+
12
+ from aeonic.core.types import AgentDescriptor
13
+
14
+ # Global registry storage
15
+ _REGISTRY: Dict[str, AgentDescriptor] = {}
16
+ _REGISTRY_LOCK = threading.Lock()
17
+ _INVENTORY_EMITTED = False
18
+ _INVENTORY_LOCK = threading.Lock()
19
+
20
+
21
+ def register_agent(
22
+ name: str,
23
+ agent_type: Optional[str],
24
+ model: Optional[List[str]],
25
+ route_path: Optional[str],
26
+ http_method: Optional[str],
27
+ framework: str,
28
+ service_name: Optional[str],
29
+ ) -> None:
30
+ """
31
+ Register an agent in the global registry.
32
+
33
+ This function is idempotent and never throws exceptions.
34
+ Called at agent declaration time (when with_agent() is invoked or @with_agent is applied).
35
+
36
+ If an agent with the same name already exists but has None route_path/http_method,
37
+ this will update that registration with the actual values.
38
+
39
+ Args:
40
+ name: Agent name (unique identifier)
41
+ agent_type: Type of agent (e.g., "conversational", "task")
42
+ model: List of model identifiers
43
+ route_path: HTTP route path (if applicable)
44
+ http_method: HTTP method (if applicable)
45
+ framework: Framework identifier ("fastapi" or "django")
46
+ service_name: Service name from SDK config
47
+ """
48
+ try:
49
+ agent_key = _generate_agent_key(name, route_path, http_method, framework)
50
+
51
+ with _REGISTRY_LOCK:
52
+ # Idempotent: skip if already registered with same key
53
+ if agent_key in _REGISTRY:
54
+ return
55
+
56
+ # If we have actual route_path/http_method, check if there's an existing
57
+ # registration with the same name but None values (from early registration)
58
+ if route_path and http_method:
59
+ # Look for existing registration with same name but None path/method
60
+ existing_key = _generate_agent_key(name, None, None, framework)
61
+ if existing_key in _REGISTRY:
62
+ # Update the existing registration
63
+ existing = _REGISTRY[existing_key]
64
+ # Preserve original declared_at
65
+ updated_descriptor: AgentDescriptor = {
66
+ "name": name,
67
+ "type": agent_type or existing.get("type"),
68
+ "model": model or existing.get("model"),
69
+ "route_path": route_path,
70
+ "http_method": http_method,
71
+ "framework": framework,
72
+ "service_name": service_name or existing.get("service_name"),
73
+ "declared_at": existing.get("declared_at", time()),
74
+ }
75
+ # Remove old key and add new one
76
+ del _REGISTRY[existing_key]
77
+ _REGISTRY[agent_key] = updated_descriptor
78
+ return
79
+
80
+ # Also check all existing agents for same name (in case key format differs)
81
+ for key, existing_agent in list(_REGISTRY.items()):
82
+ if (
83
+ existing_agent.get("name") == name
84
+ and existing_agent.get("framework") == framework
85
+ and existing_agent.get("route_path") is None
86
+ and existing_agent.get("http_method") is None
87
+ ):
88
+ # Found a match - update it
89
+ matched_descriptor: AgentDescriptor = {
90
+ "name": name,
91
+ "type": agent_type or existing_agent.get("type"),
92
+ "model": model or existing_agent.get("model"),
93
+ "route_path": route_path,
94
+ "http_method": http_method,
95
+ "framework": framework,
96
+ "service_name": service_name or existing_agent.get("service_name"),
97
+ "declared_at": existing_agent.get("declared_at", time()),
98
+ }
99
+ # Remove old and add new
100
+ del _REGISTRY[key]
101
+ _REGISTRY[agent_key] = matched_descriptor
102
+ return
103
+
104
+ # Create new registration
105
+ new_descriptor: AgentDescriptor = {
106
+ "name": name,
107
+ "type": agent_type,
108
+ "model": model,
109
+ "route_path": route_path,
110
+ "http_method": http_method,
111
+ "framework": framework,
112
+ "service_name": service_name,
113
+ "declared_at": time(),
114
+ }
115
+
116
+ _REGISTRY[agent_key] = new_descriptor
117
+
118
+ except Exception:
119
+ # Never throw - silent failure
120
+ pass
121
+
122
+
123
+ def get_all_agents() -> List[AgentDescriptor]:
124
+ """
125
+ Retrieve all registered agents.
126
+
127
+ Returns:
128
+ List of agent descriptors
129
+ """
130
+ try:
131
+ with _REGISTRY_LOCK:
132
+ return list(_REGISTRY.values())
133
+ except Exception:
134
+ return []
135
+
136
+
137
+ def should_emit_inventory() -> bool:
138
+ """
139
+ Check if inventory should be emitted.
140
+
141
+ This checks if inventory has been emitted, but does NOT set the flag.
142
+ The flag is only set when the API call succeeds (via mark_inventory_emitted()).
143
+
144
+ Returns:
145
+ True if inventory should be emitted (hasn't been emitted yet), False otherwise
146
+ """
147
+ global _INVENTORY_EMITTED
148
+
149
+ try:
150
+ with _INVENTORY_LOCK:
151
+ return not _INVENTORY_EMITTED
152
+ except Exception:
153
+ return False
154
+
155
+
156
+ def mark_inventory_emitted() -> None:
157
+ """
158
+ Mark inventory as successfully emitted.
159
+
160
+ This should only be called after a successful API response.
161
+ """
162
+ global _INVENTORY_EMITTED
163
+
164
+ try:
165
+ with _INVENTORY_LOCK:
166
+ _INVENTORY_EMITTED = True
167
+ except Exception:
168
+ pass
169
+
170
+
171
+ def reset_inventory_emission() -> None:
172
+ """
173
+ Reset the inventory emission flag.
174
+
175
+ This is primarily used for testing purposes.
176
+ """
177
+ global _INVENTORY_EMITTED
178
+
179
+ try:
180
+ with _INVENTORY_LOCK:
181
+ _INVENTORY_EMITTED = False
182
+ except Exception:
183
+ pass
184
+
185
+
186
+ def _generate_agent_key(
187
+ name: str,
188
+ route_path: Optional[str],
189
+ http_method: Optional[str],
190
+ framework: str,
191
+ ) -> str:
192
+ """
193
+ Generate a unique key for agent registration.
194
+
195
+ Args:
196
+ name: Agent name
197
+ route_path: HTTP route path
198
+ http_method: HTTP method
199
+ framework: Framework identifier
200
+
201
+ Returns:
202
+ Unique agent key
203
+ """
204
+ parts = [framework, name]
205
+ if route_path:
206
+ parts.append(route_path)
207
+ if http_method:
208
+ parts.append(http_method)
209
+ return ":".join(parts)
@@ -0,0 +1,28 @@
1
+ """Agent status cache for blocking/quarantining agents."""
2
+
3
+ import threading
4
+ from typing import Dict, Optional
5
+
6
+ _AGENT_STATUS_CACHE: Dict[str, str] = {}
7
+ _CACHE_LOCK = threading.Lock()
8
+
9
+
10
+ def get_agent_status(agent_key: str) -> Optional[str]:
11
+ """Get agent status from cache."""
12
+ with _CACHE_LOCK:
13
+ return _AGENT_STATUS_CACHE.get(agent_key)
14
+
15
+
16
+ def set_agent_status(agent_key: str, status: str) -> None:
17
+ """Set agent status in cache."""
18
+ with _CACHE_LOCK:
19
+ _AGENT_STATUS_CACHE[agent_key] = status
20
+
21
+
22
+ def should_collect_samples(agent_key: str) -> bool:
23
+ """Check if samples should be collected for agent."""
24
+ status = get_agent_status(agent_key)
25
+ # If no status in cache, assume healthy (collect samples)
26
+ if status is None:
27
+ return True
28
+ return status not in ("blocked", "quarantined")
@@ -0,0 +1,8 @@
1
+ """AgentGuard registration and management module."""
2
+
3
+ from aeonic.core.agentguard.registration import (
4
+ get_agent_guard_credentials,
5
+ register_agent_guard,
6
+ )
7
+
8
+ __all__ = ["register_agent_guard", "get_agent_guard_credentials"]
@@ -0,0 +1,93 @@
1
+ """AgentGuard registration with T-LAIOR backend."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import threading
7
+ import urllib.request
8
+ from typing import Any, Dict, Optional
9
+
10
+ from aeonic.core.agentguard.sse_client import start_sse_connection, stop_sse_connection
11
+
12
+ logger = logging.getLogger("aeonic")
13
+
14
+ _AGENT_GUARD_CREDENTIALS: Optional[Dict[str, Any]] = None
15
+ _CREDENTIALS_LOCK = threading.Lock()
16
+
17
+
18
+ def _get_endpoint() -> str:
19
+ """Get the Aeonic endpoint from environment variable."""
20
+ return os.environ.get("AEONIC_ENDPOINT", "http://45.79.111.106:3291")
21
+
22
+
23
+ def register_agent_guard() -> Dict[str, Any]:
24
+ """
25
+ Register SDK as AgentGuard instance with T-LAIOR.
26
+
27
+ Returns:
28
+ Dictionary containing agentguard_id, auth_token, and intervals.
29
+
30
+ Raises:
31
+ Exception: If registration fails.
32
+ """
33
+ global _AGENT_GUARD_CREDENTIALS
34
+
35
+ from aeonic.core.init import get_config
36
+
37
+ config = get_config()
38
+ endpoint = _get_endpoint()
39
+
40
+ payload = {
41
+ "api_key": config["api_key"],
42
+ "agentguard_version": "0.1.0",
43
+ "capabilities": ["red-team", "leakage", "safety"],
44
+ "service_name": config.get("service_name"),
45
+ }
46
+
47
+ try:
48
+ url = f"{endpoint}/agentguard/register"
49
+ data = json.dumps(payload).encode("utf-8")
50
+ req = urllib.request.Request(
51
+ url,
52
+ data=data,
53
+ headers={"Content-Type": "application/json"},
54
+ method="POST",
55
+ )
56
+
57
+ with urllib.request.urlopen(req, timeout=10) as response:
58
+ response_data = json.loads(response.read())
59
+
60
+ with _CREDENTIALS_LOCK:
61
+ _AGENT_GUARD_CREDENTIALS = {
62
+ "agentguard_id": response_data["agentguard_id"],
63
+ "auth_token": response_data["auth_token"],
64
+ "heartbeat_interval": response_data.get("heartbeat_interval", 30),
65
+ "polling_interval": response_data.get("polling_interval", 5),
66
+ }
67
+
68
+ # Start SSE connection for real-time events
69
+ start_sse_connection()
70
+
71
+ logger.info("[Aeonic] AgentGuard registered successfully")
72
+ return _AGENT_GUARD_CREDENTIALS
73
+
74
+ except Exception as error:
75
+ logger.error(f"[Aeonic] Failed to register AgentGuard: {error}")
76
+ raise
77
+
78
+
79
+ def get_agent_guard_credentials() -> Optional[Dict[str, Any]]:
80
+ """Get current AgentGuard credentials."""
81
+ with _CREDENTIALS_LOCK:
82
+ return _AGENT_GUARD_CREDENTIALS.copy() if _AGENT_GUARD_CREDENTIALS else None
83
+
84
+
85
+ def stop_agent_guard() -> None:
86
+ """Stop AgentGuard and SSE connection."""
87
+ global _AGENT_GUARD_CREDENTIALS
88
+
89
+ # Stop SSE connection
90
+ stop_sse_connection()
91
+
92
+ with _CREDENTIALS_LOCK:
93
+ _AGENT_GUARD_CREDENTIALS = None
@@ -0,0 +1,210 @@
1
+ """SSE client for real-time event streaming."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import threading
7
+ from typing import Any, Dict, Optional
8
+
9
+ import requests
10
+
11
+ from aeonic.core.agent_status_cache import set_agent_status
12
+
13
+ logger = logging.getLogger("aeonic")
14
+
15
+ _SSE_CONNECTION: Optional[threading.Thread] = None
16
+ _STOP_SSE = threading.Event()
17
+ _RECONNECT_ATTEMPTS = 0
18
+ _MAX_RECONNECT_ATTEMPTS = 10
19
+ _INITIAL_RECONNECT_DELAY = 1.0 # 1 second
20
+
21
+
22
+ def _get_endpoint() -> str:
23
+ """Get the Aeonic endpoint from environment variable."""
24
+ return os.environ.get("AEONIC_ENDPOINT", "http://45.79.111.106:3291")
25
+
26
+
27
+ def start_sse_connection() -> None:
28
+ """Start SSE connection for real-time events."""
29
+ global _SSE_CONNECTION, _RECONNECT_ATTEMPTS
30
+
31
+ from aeonic.core.agentguard.registration import get_agent_guard_credentials
32
+
33
+ credentials = get_agent_guard_credentials()
34
+ if not credentials:
35
+ logger.warning("[Aeonic] Cannot start SSE: AgentGuard not registered")
36
+ return
37
+
38
+ if _SSE_CONNECTION and _SSE_CONNECTION.is_alive():
39
+ # Already connected
40
+ return
41
+
42
+ _STOP_SSE.clear()
43
+ _RECONNECT_ATTEMPTS = 0
44
+
45
+ def sse_worker() -> None:
46
+ """SSE connection worker thread."""
47
+ global _RECONNECT_ATTEMPTS
48
+
49
+ endpoint = _get_endpoint()
50
+ url = f"{endpoint}/api/sdk/events/stream"
51
+
52
+ while not _STOP_SSE.is_set():
53
+ try:
54
+ headers = {
55
+ "Authorization": f"Bearer {credentials['auth_token']}",
56
+ "Accept": "text/event-stream",
57
+ "Cache-Control": "no-cache",
58
+ }
59
+
60
+ response = requests.get(
61
+ url,
62
+ headers=headers,
63
+ stream=True,
64
+ timeout=None, # No timeout for SSE
65
+ )
66
+
67
+ response.raise_for_status()
68
+
69
+ logger.info("[Aeonic] SSE connection established")
70
+ _RECONNECT_ATTEMPTS = 0
71
+
72
+ # Process SSE stream
73
+ for line in response.iter_lines(decode_unicode=True):
74
+ if _STOP_SSE.is_set():
75
+ break
76
+
77
+ if not line:
78
+ continue
79
+
80
+ if line.startswith("data: "):
81
+ try:
82
+ # Remove "data: " prefix and parse JSON
83
+ event_data = json.loads(line[6:])
84
+ _handle_sse_event(event_data)
85
+ except json.JSONDecodeError as e:
86
+ logger.error(f"[Aeonic] Failed to parse SSE event: {e}")
87
+
88
+ except requests.exceptions.RequestException as e:
89
+ if _STOP_SSE.is_set():
90
+ break
91
+
92
+ logger.warning(f"[Aeonic] SSE connection error: {e}")
93
+
94
+ # Reconnect with exponential backoff
95
+ if _RECONNECT_ATTEMPTS < _MAX_RECONNECT_ATTEMPTS:
96
+ delay = _INITIAL_RECONNECT_DELAY * (2**_RECONNECT_ATTEMPTS)
97
+ _RECONNECT_ATTEMPTS += 1
98
+ logger.info(
99
+ f"[Aeonic] Reconnecting SSE in {delay}s (attempt {_RECONNECT_ATTEMPTS})"
100
+ )
101
+ if _STOP_SSE.wait(delay):
102
+ break
103
+ else:
104
+ logger.error("[Aeonic] Max SSE reconnection attempts reached")
105
+ break
106
+ except Exception as e:
107
+ logger.error(f"[Aeonic] Unexpected SSE error: {e}")
108
+ if _STOP_SSE.wait(5): # Wait a bit before retrying unexpected errors
109
+ break
110
+
111
+ _SSE_CONNECTION = threading.Thread(target=sse_worker, name="AeonicSSEThread", daemon=True)
112
+ _SSE_CONNECTION.start()
113
+
114
+
115
+ def _handle_sse_event(data: Dict[str, Any]) -> None:
116
+ """Handle SSE event."""
117
+ event_type = data.get("type")
118
+
119
+ if event_type == "connected":
120
+ logger.info(f"[Aeonic] SSE connected: {data.get('message')}")
121
+
122
+ elif event_type == "heartbeat":
123
+ # Heartbeat received - connection is alive
124
+ logger.debug("[Aeonic] SSE heartbeat received")
125
+
126
+ elif event_type == "agent.status_changed":
127
+ _handle_agent_status_change(data)
128
+
129
+ elif event_type == "assessment.job_available":
130
+ _handle_assessment_job(data)
131
+
132
+ else:
133
+ logger.debug(f"[Aeonic] Unknown SSE event type: {event_type}")
134
+
135
+
136
+ def _handle_agent_status_change(data: Dict[str, Any]) -> None:
137
+ """Handle agent status change event."""
138
+ agent_id = data.get("agent_id")
139
+ agent_key = data.get("agent_key")
140
+ agent_name = data.get("agent_name")
141
+ new_status = data.get("new_status")
142
+ previous_status = data.get("previous_status")
143
+
144
+ logger.info(
145
+ f"[Aeonic] Agent status changed: {agent_name or agent_id} "
146
+ f"({agent_key or agent_id}) {previous_status} -> {new_status}"
147
+ )
148
+
149
+ if not any([agent_key, agent_id, agent_name]):
150
+ logger.warning("[Aeonic] Received agent.status_changed event without any identifiers")
151
+ return
152
+
153
+ # Update agent status cache for all identifiers provided
154
+ identifiers: list[str] = [x for x in (agent_key, agent_id, agent_name) if x]
155
+ for identifier in identifiers:
156
+ if new_status:
157
+ set_agent_status(identifier, new_status)
158
+
159
+ # Logging based on status
160
+ display_name = agent_name or agent_key or agent_id
161
+ if new_status in ("blocked", "quarantined"):
162
+ logger.warning(
163
+ f"[Aeonic] Agent {display_name} is {new_status} - stopping sample collection"
164
+ )
165
+ elif new_status == "healthy" and previous_status in ("blocked", "quarantined"):
166
+ # Agent was unblocked - resume sample collection
167
+ logger.info(f"[Aeonic] Agent {display_name} is now healthy - resuming sample collection")
168
+
169
+
170
+ def _handle_assessment_job(data: Dict[str, Any]) -> None:
171
+ """Handle assessment job available event."""
172
+ from aeonic.core.assessment.executor import execute_assessment
173
+
174
+ job = {
175
+ "job_id": data.get("job_id"),
176
+ "agent_id": data.get("agent_id"),
177
+ "scenario_id": data.get("scenario_id"),
178
+ "scenario_steps": data.get("scenario_steps"),
179
+ "playbook": data.get("playbook"),
180
+ "agent_endpoint": data.get("agent_endpoint"),
181
+ }
182
+
183
+ logger.info(f"[Aeonic] Assessment job received via SSE: {job.get('job_id')}")
184
+
185
+ # Execute assessment asynchronously (non-blocking) in a new thread
186
+ threading.Thread(
187
+ target=lambda: _run_assessment_safely(job, execute_assessment),
188
+ name=f"AeonicAssessment-{job.get('job_id')}",
189
+ daemon=True,
190
+ ).start()
191
+
192
+
193
+ def _run_assessment_safely(job: Dict[str, Any], executor_func: Any) -> None:
194
+ """Run assessment and log errors."""
195
+ try:
196
+ executor_func(job)
197
+ except Exception as e:
198
+ logger.error(f"[Aeonic] Assessment {job.get('job_id', 'unknown')} failed: {e}")
199
+
200
+
201
+ def stop_sse_connection() -> None:
202
+ """Stop SSE connection."""
203
+ global _SSE_CONNECTION
204
+
205
+ _STOP_SSE.set()
206
+
207
+ if _SSE_CONNECTION and _SSE_CONNECTION.is_alive():
208
+ # Try to join briefly
209
+ _SSE_CONNECTION.join(timeout=2)
210
+ _SSE_CONNECTION = None
@@ -0,0 +1,5 @@
1
+ """Assessment execution module."""
2
+
3
+ from aeonic.core.assessment.executor import execute_assessment
4
+
5
+ __all__ = ["execute_assessment"]