agenticdome-python-sdk 1.0.2__tar.gz → 1.0.4__tar.gz

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.
Files changed (27) hide show
  1. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/PKG-INFO +1 -1
  2. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_python_sdk.egg-info/PKG-INFO +1 -1
  3. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_python_sdk.egg-info/SOURCES.txt +3 -1
  4. agenticdome_python_sdk-1.0.4/agenticdome_sdk/pydantic.py +944 -0
  5. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/pyproject.toml +1 -1
  6. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/setup.py +10 -0
  7. agenticdome_python_sdk-1.0.4/tests/test_pydanticai_integration.py +22 -0
  8. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/MANIFEST.in +0 -0
  9. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/README.md +0 -0
  10. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_python_sdk.egg-info/dependency_links.txt +0 -0
  11. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_python_sdk.egg-info/requires.txt +0 -0
  12. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_python_sdk.egg-info/top_level.txt +0 -0
  13. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_sdk/__init__.py +0 -0
  14. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_sdk/client.py +0 -0
  15. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_sdk/crewai.py +0 -0
  16. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/agenticdome_sdk/scenarios.py +0 -0
  17. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/agno_lateral_refund.py +0 -0
  18. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/ai_foundry_callback_export.py +0 -0
  19. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/crewai_agenticdome_firewall.py +0 -0
  20. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/crewai_lateral_refund.py +0 -0
  21. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/mcp_remote_patch.py +0 -0
  22. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/mesh_output_redaction.py +0 -0
  23. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/microsoft_metadata_exfil.py +0 -0
  24. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/salesforce_hidden_bcc.py +0 -0
  25. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/examples/servicenow_delete_logs.py +0 -0
  26. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/setup.cfg +0 -0
  27. {agenticdome_python_sdk-1.0.2 → agenticdome_python_sdk-1.0.4}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenticdome-python-sdk
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Official Python SDK for AgentGuard Intelligence Engine and Action Firewall.
5
5
  Home-page: https://github.com/agenticdome/agenticdome-python-sdk-python
6
6
  Author: AgenticDome
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agenticdome-python-sdk
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Official Python SDK for AgentGuard Intelligence Engine and Action Firewall.
5
5
  Home-page: https://github.com/agenticdome/agenticdome-python-sdk-python
6
6
  Author: AgenticDome
@@ -10,6 +10,7 @@ agenticdome_python_sdk.egg-info/top_level.txt
10
10
  agenticdome_sdk/__init__.py
11
11
  agenticdome_sdk/client.py
12
12
  agenticdome_sdk/crewai.py
13
+ agenticdome_sdk/pydantic.py
13
14
  agenticdome_sdk/scenarios.py
14
15
  examples/agno_lateral_refund.py
15
16
  examples/ai_foundry_callback_export.py
@@ -20,4 +21,5 @@ examples/mesh_output_redaction.py
20
21
  examples/microsoft_metadata_exfil.py
21
22
  examples/salesforce_hidden_bcc.py
22
23
  examples/servicenow_delete_logs.py
23
- tests/test_client.py
24
+ tests/test_client.py
25
+ tests/test_pydanticai_integration.py
@@ -0,0 +1,944 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import functools
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from threading import Lock
12
+ from typing import Any, Callable, Dict, Optional, Tuple, TypeVar
13
+
14
+ import anyio
15
+
16
+ try:
17
+ from pydantic_ai import Agent, RunContext
18
+ from pydantic_ai.models import ModelResponse
19
+ except Exception as exc: # pragma: no cover
20
+ raise ImportError(
21
+ "AgenticDome PydanticAI integration requires pydantic-ai. "
22
+ "Install with: pip install 'agenticdome-python-sdk[pydanticai]'"
23
+ ) from exc
24
+
25
+ from agenticdome_sdk.client import AgentGuardClient
26
+
27
+ try:
28
+ from agenticdome_sdk.exceptions import AgentGuardHTTPError
29
+ except Exception: # pragma: no cover
30
+ try:
31
+ from agenticdome_sdk.client import AgentGuardHTTPError # type: ignore
32
+ except Exception:
33
+ class AgentGuardHTTPError(Exception): # type: ignore
34
+ pass
35
+
36
+
37
+ logger = logging.getLogger("agenticdome.pydanticai")
38
+ logger.setLevel(logging.INFO)
39
+
40
+ DepsT = TypeVar("DepsT")
41
+
42
+
43
+ # ------------------------------------------------------------------
44
+ # Configuration
45
+ # ------------------------------------------------------------------
46
+
47
+ def _env_bool(name: str, default: bool) -> bool:
48
+ value = os.getenv(name)
49
+ if value is None:
50
+ return default
51
+ return value.strip().lower() in ("1", "true", "yes", "y", "on")
52
+
53
+
54
+ def _env_int(name: str, default: int) -> int:
55
+ value = os.getenv(name)
56
+ if value is None:
57
+ return default
58
+ try:
59
+ return int(value)
60
+ except (TypeError, ValueError):
61
+ return default
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class FirewallConfig:
66
+ api_base: str = field(
67
+ default_factory=lambda: os.getenv(
68
+ "AGENTICDOME_API_BASE",
69
+ "https://au.agenticdome.io",
70
+ ).rstrip("/")
71
+ )
72
+ api_key: str = field(default_factory=lambda: os.getenv("AGENTICDOME_API_KEY", ""))
73
+ tenant_id: str = field(default_factory=lambda: os.getenv("AGENTICDOME_TENANT_ID", ""))
74
+
75
+ platform: str = field(default_factory=lambda: os.getenv("AGENTICDOME_PLATFORM", "pydanticai"))
76
+ timeout_s: int = field(default_factory=lambda: _env_int("AGENTICDOME_TIMEOUT_S", 20))
77
+ fail_closed: bool = field(default_factory=lambda: _env_bool("AGENTICDOME_FAIL_CLOSED", True))
78
+
79
+ require_explicit_session_id: bool = field(
80
+ default_factory=lambda: _env_bool("AGENTICDOME_REQUIRE_SESSION_ID", False)
81
+ )
82
+ default_tool_platform: str = field(
83
+ default_factory=lambda: os.getenv("AGENTICDOME_DEFAULT_TOOL_PLATFORM", "python")
84
+ )
85
+
86
+ redact_pii: bool = field(default_factory=lambda: _env_bool("AGENTICDOME_REDACT_PII", True))
87
+ redact_secrets: bool = field(
88
+ default_factory=lambda: _env_bool("AGENTICDOME_REDACT_SECRETS", True)
89
+ )
90
+ block_on_sensitive_output: bool = field(
91
+ default_factory=lambda: _env_bool("AGENTICDOME_BLOCK_ON_SENSITIVE_OUTPUT", False)
92
+ )
93
+
94
+ enable_a2a_for_delegation: bool = field(
95
+ default_factory=lambda: _env_bool("AGENTICDOME_ENABLE_A2A_FOR_DELEGATION", True)
96
+ )
97
+ handoff_token_ttl_s: int = field(
98
+ default_factory=lambda: _env_int("AGENTICDOME_HANDOFF_TOKEN_TTL_S", 900)
99
+ )
100
+
101
+ redis_url: str = field(default_factory=lambda: os.getenv("AGENTICDOME_REDIS_URL", "").strip())
102
+ redis_key_prefix: str = field(
103
+ default_factory=lambda: os.getenv(
104
+ "AGENTICDOME_REDIS_KEY_PREFIX",
105
+ "AgenticDome:pydanticai:handoff",
106
+ )
107
+ )
108
+
109
+ report_incidents: bool = field(
110
+ default_factory=lambda: _env_bool("AGENTICDOME_REPORT_INCIDENTS", True)
111
+ )
112
+ blocked_incident_severity: str = field(
113
+ default_factory=lambda: os.getenv("AGENTICDOME_BLOCKED_INCIDENT_SEVERITY", "medium")
114
+ )
115
+
116
+
117
+ class PydanticAIFirewallError(RuntimeError):
118
+ """Base exception for AgenticDome PydanticAI firewall errors."""
119
+
120
+
121
+ class PydanticAIFirewallDenied(PydanticAIFirewallError):
122
+ """Raised when AgenticDome explicitly blocks execution."""
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class DecisionTokenRecord:
127
+ decision_token: str
128
+ source_agent_id: str
129
+ created_at: float
130
+
131
+
132
+ # ------------------------------------------------------------------
133
+ # Token Store
134
+ # ------------------------------------------------------------------
135
+
136
+ class DecisionTokenStore:
137
+ def put(
138
+ self,
139
+ *,
140
+ session_id: str,
141
+ target_agent_id: str,
142
+ tool_name: str,
143
+ tool_args: Dict[str, Any],
144
+ record: DecisionTokenRecord,
145
+ ttl_s: int,
146
+ ) -> None:
147
+ raise NotImplementedError
148
+
149
+ def get(
150
+ self,
151
+ *,
152
+ session_id: str,
153
+ target_agent_id: str,
154
+ tool_name: str,
155
+ tool_args: Dict[str, Any],
156
+ ) -> Optional[DecisionTokenRecord]:
157
+ raise NotImplementedError
158
+
159
+ def delete(
160
+ self,
161
+ *,
162
+ session_id: str,
163
+ target_agent_id: str,
164
+ tool_name: str,
165
+ tool_args: Dict[str, Any],
166
+ ) -> None:
167
+ raise NotImplementedError
168
+
169
+
170
+ def _stable_json(value: Any) -> str:
171
+ return json.dumps(value, sort_keys=True, default=str, separators=(",", ":"))
172
+
173
+
174
+ def _tool_fingerprint(tool_name: str, tool_args: Dict[str, Any]) -> str:
175
+ payload = {
176
+ "tool_name": tool_name or "",
177
+ "tool_args": tool_args or {},
178
+ }
179
+ return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()
180
+
181
+
182
+ class InMemoryDecisionTokenStore(DecisionTokenStore):
183
+ def __init__(self, tenant_id: str) -> None:
184
+ self._tenant_id = tenant_id
185
+ self._lock = Lock()
186
+ self._data: Dict[str, Tuple[float, DecisionTokenRecord]] = {}
187
+
188
+ def _key(
189
+ self,
190
+ session_id: str,
191
+ target_agent_id: str,
192
+ tool_name: str,
193
+ tool_args: Dict[str, Any],
194
+ ) -> str:
195
+ fp = _tool_fingerprint(tool_name, tool_args)
196
+ return f"{self._tenant_id}:{session_id}:{target_agent_id}:{fp}"
197
+
198
+ def _cleanup(self) -> None:
199
+ now = time.time()
200
+ expired = [
201
+ key for key, (expires_at, _) in self._data.items()
202
+ if expires_at <= now
203
+ ]
204
+ for key in expired:
205
+ self._data.pop(key, None)
206
+
207
+ def put(
208
+ self,
209
+ *,
210
+ session_id: str,
211
+ target_agent_id: str,
212
+ tool_name: str,
213
+ tool_args: Dict[str, Any],
214
+ record: DecisionTokenRecord,
215
+ ttl_s: int,
216
+ ) -> None:
217
+ key = self._key(session_id, target_agent_id, tool_name, tool_args)
218
+ with self._lock:
219
+ self._cleanup()
220
+ self._data[key] = (time.time() + ttl_s, record)
221
+
222
+ def get(
223
+ self,
224
+ *,
225
+ session_id: str,
226
+ target_agent_id: str,
227
+ tool_name: str,
228
+ tool_args: Dict[str, Any],
229
+ ) -> Optional[DecisionTokenRecord]:
230
+ key = self._key(session_id, target_agent_id, tool_name, tool_args)
231
+ with self._lock:
232
+ self._cleanup()
233
+ entry = self._data.get(key)
234
+ return entry[1] if entry else None
235
+
236
+ def delete(
237
+ self,
238
+ *,
239
+ session_id: str,
240
+ target_agent_id: str,
241
+ tool_name: str,
242
+ tool_args: Dict[str, Any],
243
+ ) -> None:
244
+ key = self._key(session_id, target_agent_id, tool_name, tool_args)
245
+ with self._lock:
246
+ self._data.pop(key, None)
247
+
248
+
249
+ class RedisDecisionTokenStore(DecisionTokenStore):
250
+ def __init__(self, url: str, prefix: str, tenant_id: str) -> None:
251
+ import redis
252
+
253
+ self.r = redis.Redis.from_url(url, decode_responses=True)
254
+ self.prefix = f"{prefix}:{tenant_id}"
255
+
256
+ def _key(
257
+ self,
258
+ session_id: str,
259
+ target_agent_id: str,
260
+ tool_name: str,
261
+ tool_args: Dict[str, Any],
262
+ ) -> str:
263
+ fp = _tool_fingerprint(tool_name, tool_args)
264
+ return f"{self.prefix}:{session_id}:{target_agent_id}:{fp}"
265
+
266
+ def put(
267
+ self,
268
+ *,
269
+ session_id: str,
270
+ target_agent_id: str,
271
+ tool_name: str,
272
+ tool_args: Dict[str, Any],
273
+ record: DecisionTokenRecord,
274
+ ttl_s: int,
275
+ ) -> None:
276
+ key = self._key(session_id, target_agent_id, tool_name, tool_args)
277
+ payload = {
278
+ "decision_token": record.decision_token,
279
+ "source_agent_id": record.source_agent_id,
280
+ "created_at": record.created_at,
281
+ }
282
+ self.r.setex(key, ttl_s, json.dumps(payload))
283
+
284
+ def get(
285
+ self,
286
+ *,
287
+ session_id: str,
288
+ target_agent_id: str,
289
+ tool_name: str,
290
+ tool_args: Dict[str, Any],
291
+ ) -> Optional[DecisionTokenRecord]:
292
+ key = self._key(session_id, target_agent_id, tool_name, tool_args)
293
+ raw = self.r.get(key)
294
+ if not raw:
295
+ return None
296
+
297
+ try:
298
+ payload = json.loads(raw)
299
+ return DecisionTokenRecord(
300
+ decision_token=str(payload["decision_token"]),
301
+ source_agent_id=str(payload["source_agent_id"]),
302
+ created_at=float(payload.get("created_at", time.time())),
303
+ )
304
+ except Exception:
305
+ return None
306
+
307
+ def delete(
308
+ self,
309
+ *,
310
+ session_id: str,
311
+ target_agent_id: str,
312
+ tool_name: str,
313
+ tool_args: Dict[str, Any],
314
+ ) -> None:
315
+ key = self._key(session_id, target_agent_id, tool_name, tool_args)
316
+ self.r.delete(key)
317
+
318
+
319
+ def _build_token_store(config: FirewallConfig) -> DecisionTokenStore:
320
+ if config.redis_url:
321
+ try:
322
+ return RedisDecisionTokenStore(
323
+ config.redis_url,
324
+ config.redis_key_prefix,
325
+ config.tenant_id,
326
+ )
327
+ except ImportError:
328
+ logger.warning(
329
+ "Redis package missing. Install with: "
330
+ "pip install 'agenticdome-python-sdk[redis]'. "
331
+ "Using in-memory token cache."
332
+ )
333
+ except Exception as exc:
334
+ logger.warning(
335
+ "Redis token store unavailable: %s. Using in-memory token cache.",
336
+ exc,
337
+ )
338
+
339
+ return InMemoryDecisionTokenStore(config.tenant_id)
340
+
341
+
342
+ # ------------------------------------------------------------------
343
+ # Helpers
344
+ # ------------------------------------------------------------------
345
+
346
+ def _safe_getattr(obj: Any, name: str, default: Any = None) -> Any:
347
+ try:
348
+ return getattr(obj, name, default)
349
+ except Exception:
350
+ return default
351
+
352
+
353
+ def _safe_setattr(obj: Any, name: str, value: Any) -> None:
354
+ try:
355
+ setattr(obj, name, value)
356
+ return
357
+ except Exception:
358
+ pass
359
+
360
+ try:
361
+ if hasattr(obj, "__dict__"):
362
+ obj.__dict__[name] = value
363
+ except Exception:
364
+ pass
365
+
366
+
367
+ def _is_handoff_tool(tool_name: str) -> bool:
368
+ lower = tool_name.lower()
369
+ return any(marker in lower for marker in ("delegate", "handoff", "route", "transfer"))
370
+
371
+
372
+ def _target_agent_id(kwargs: Dict[str, Any]) -> str:
373
+ return str(
374
+ kwargs.get("target_agent_id")
375
+ or kwargs.get("coworker")
376
+ or kwargs.get("agent")
377
+ or kwargs.get("specialist_agent_id")
378
+ or "specialist"
379
+ )
380
+
381
+
382
+ def _target_tool_name(current_tool_name: str, kwargs: Dict[str, Any]) -> str:
383
+ return str(
384
+ kwargs.get("target_tool_name")
385
+ or kwargs.get("tool_name")
386
+ or kwargs.get("skill_name")
387
+ or current_tool_name
388
+ )
389
+
390
+
391
+ def _target_tool_args(kwargs: Dict[str, Any]) -> Dict[str, Any]:
392
+ raw = (
393
+ kwargs.get("target_tool_args")
394
+ or kwargs.get("skill_args")
395
+ or kwargs.get("arguments")
396
+ or {}
397
+ )
398
+
399
+ if isinstance(raw, dict):
400
+ return dict(raw)
401
+
402
+ return {"_raw_input": str(raw)}
403
+
404
+
405
+ def _strip_private_args(kwargs: Dict[str, Any]) -> Dict[str, Any]:
406
+ return {
407
+ key: value
408
+ for key, value in kwargs.items()
409
+ if not key.startswith("_AgenticDome_") and not key.startswith("_decision_")
410
+ }
411
+
412
+
413
+ def _run_async_from_sync(async_fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
414
+ try:
415
+ return anyio.from_thread.run(async_fn, *args, **kwargs)
416
+ except RuntimeError:
417
+ return anyio.run(async_fn, *args, **kwargs)
418
+
419
+
420
+ # ------------------------------------------------------------------
421
+ # PydanticAI Firewall
422
+ # ------------------------------------------------------------------
423
+
424
+ class CyberSecFirewall:
425
+ """
426
+ AgenticDome Security Firewall for PydanticAI.
427
+
428
+ Provides:
429
+ - Prompt ingress screening where supported by the PydanticAI runtime
430
+ - Tool execution authorization
431
+ - Manager-to-specialist delegation token generation
432
+ - Specialist decision-token verification
433
+ - Output DLP sanitization
434
+ """
435
+
436
+ def __init__(self, config: Optional[FirewallConfig] = None) -> None:
437
+ self.config = config or FirewallConfig()
438
+
439
+ if not self.config.api_base or not self.config.api_key or not self.config.tenant_id:
440
+ logger.warning(
441
+ "AgenticDome PydanticAI firewall unconfigured. "
442
+ "Set AGENTICDOME_API_BASE, AGENTICDOME_API_KEY, and AGENTICDOME_TENANT_ID. "
443
+ "Runtime will operate in fail-open mode for SDK calls."
444
+ )
445
+ self.client: Optional[AgentGuardClient] = None
446
+ else:
447
+ self.client = AgentGuardClient(
448
+ api_base=self.config.api_base,
449
+ api_key=self.config.api_key,
450
+ tenant_id=self.config.tenant_id,
451
+ timeout=self.config.timeout_s,
452
+ )
453
+
454
+ self.token_store = _build_token_store(self.config)
455
+
456
+ async def _to_thread(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
457
+ return await anyio.to_thread.run_sync(lambda: fn(*args, **kwargs))
458
+
459
+ def _client_call(self, method_names: Tuple[str, ...], *args: Any, **kwargs: Any) -> Any:
460
+ if self.client is None:
461
+ return None
462
+
463
+ last_type_error: Optional[TypeError] = None
464
+
465
+ for method_name in method_names:
466
+ method = getattr(self.client, method_name, None)
467
+ if method is None:
468
+ continue
469
+
470
+ try:
471
+ return method(*args, **kwargs)
472
+ except TypeError as exc:
473
+ last_type_error = exc
474
+ continue
475
+
476
+ if last_type_error:
477
+ raise last_type_error
478
+
479
+ raise AttributeError(
480
+ f"AgenticDome client does not implement any of: {', '.join(method_names)}"
481
+ )
482
+
483
+ def _extract_payload(self, response: Any) -> Dict[str, Any]:
484
+ if isinstance(response, dict):
485
+ result = response.get("result")
486
+ if isinstance(result, dict):
487
+ return result
488
+ return response
489
+ return {}
490
+
491
+ def _extract_verdict(self, response: Any) -> str:
492
+ payload = self._extract_payload(response)
493
+ return str(payload.get("verdict") or payload.get("decision") or "ALLOWED").upper()
494
+
495
+ def _reason(self, response: Any) -> str:
496
+ payload = self._extract_payload(response)
497
+ return str(payload.get("reason") or payload.get("message") or response)
498
+
499
+ def _decision_token(self, response: Any) -> Optional[str]:
500
+ payload = self._extract_payload(response)
501
+ token = payload.get("decision_token") or payload.get("token")
502
+ return str(token) if token else None
503
+
504
+ def _agent_name(self, ctx: RunContext[Any], fallback: str = "unnamed_agent") -> str:
505
+ agent = _safe_getattr(ctx, "agent")
506
+ value = (
507
+ _safe_getattr(agent, "name")
508
+ or _safe_getattr(ctx, "agent_name")
509
+ or _safe_getattr(_safe_getattr(ctx, "deps"), "agent_id")
510
+ )
511
+ return str(value) if value else fallback
512
+
513
+ def _session_id(self, ctx: RunContext[Any]) -> str:
514
+ deps = _safe_getattr(ctx, "deps")
515
+
516
+ for attr in ("run_id", "trace_id", "session_id", "task_id"):
517
+ value = _safe_getattr(ctx, attr) or _safe_getattr(deps, attr)
518
+ if value:
519
+ return str(value)
520
+
521
+ if self.config.require_explicit_session_id:
522
+ raise PydanticAIFirewallDenied(
523
+ "Strict mode error: missing session_id, run_id, trace_id, or task_id."
524
+ )
525
+
526
+ return f"pydanticai-session-{id(ctx)}"
527
+
528
+ def _report_incident(
529
+ self,
530
+ *,
531
+ agent_id: str,
532
+ incident_type: str,
533
+ severity: Optional[str] = None,
534
+ details: str = "",
535
+ ) -> None:
536
+ if self.client is None or not self.config.report_incidents:
537
+ return
538
+
539
+ try:
540
+ self._client_call(
541
+ ("report_incident", "reportIncident"),
542
+ agent_id=agent_id,
543
+ incident_type=incident_type,
544
+ severity=severity or self.config.blocked_incident_severity,
545
+ details=details,
546
+ tenant_id=self.config.tenant_id,
547
+ is_agent=True,
548
+ platform=self.config.platform,
549
+ )
550
+ except Exception as exc:
551
+ logger.debug("AgenticDome incident reporting failed: %s", exc)
552
+
553
+ def _handle_error(self, stage: str, exc: Exception, agent_id: str = "unknown") -> None:
554
+ logger.error("AgenticDome PydanticAI %s error: %s", stage, exc)
555
+
556
+ self._report_incident(
557
+ agent_id=agent_id,
558
+ incident_type=f"pydanticai_{stage}_error",
559
+ severity=self.config.blocked_incident_severity,
560
+ details=str(exc),
561
+ )
562
+
563
+ if self.config.fail_closed:
564
+ if isinstance(exc, PydanticAIFirewallDenied):
565
+ raise exc
566
+ raise PydanticAIFirewallDenied(f"AgenticDome security check failed: {exc}") from exc
567
+
568
+ # ------------------------------------------------------------------
569
+ # Agent lifecycle hooks
570
+ # ------------------------------------------------------------------
571
+
572
+ def attach_to_agent(self, agent: Agent[DepsT, Any]) -> None:
573
+ """
574
+ Attach prompt ingress and response egress hooks to a PydanticAI Agent
575
+ when the installed PydanticAI runtime exposes compatible hook decorators.
576
+
577
+ PydanticAI hook APIs may vary by version. If lifecycle decorators are not
578
+ available, use secure_tool() around tools for tool perimeter security.
579
+ """
580
+
581
+ before_runner_init = getattr(agent, "before_runner_init", None)
582
+ after_runner_end = getattr(agent, "after_runner_end", None)
583
+
584
+ if callable(before_runner_init):
585
+ @before_runner_init
586
+ def _ingress_prompt_shield(ctx: RunContext[DepsT], prompt: str) -> str:
587
+ if self.client is None or not str(prompt or "").strip():
588
+ return prompt
589
+
590
+ agent_id = getattr(agent, "name", None) or self._agent_name(ctx)
591
+
592
+ try:
593
+ response = self._client_call(
594
+ ("guardrail_validate", "guardrailValidate"),
595
+ text=str(prompt),
596
+ agent_id=agent_id,
597
+ platform=self.config.platform,
598
+ source_platform=self.config.platform,
599
+ direction="input",
600
+ session_id=self._session_id(ctx),
601
+ policy_context={
602
+ "source_agent_id": agent_id,
603
+ "request_purpose": "pydanticai_prompt_input",
604
+ "platform": self.config.platform,
605
+ },
606
+ )
607
+
608
+ if self._extract_verdict(response) == "BLOCKED":
609
+ raise PydanticAIFirewallDenied(
610
+ f"AgenticDome prompt shield blocked input: {self._reason(response)}"
611
+ )
612
+ except Exception as exc:
613
+ self._handle_error("before_runner_init", exc, agent_id)
614
+
615
+ return prompt
616
+
617
+ else:
618
+ logger.info(
619
+ "PydanticAI agent does not expose before_runner_init. "
620
+ "Prompt lifecycle hook not attached."
621
+ )
622
+
623
+ if callable(after_runner_end):
624
+ @after_runner_end
625
+ def _egress_dlp_shield(
626
+ ctx: RunContext[DepsT],
627
+ response: ModelResponse,
628
+ ) -> ModelResponse:
629
+ if self.client is None:
630
+ return response
631
+
632
+ agent_id = getattr(agent, "name", None) or self._agent_name(ctx)
633
+
634
+ try:
635
+ messages = getattr(response, "messages", None)
636
+ if not messages:
637
+ return response
638
+
639
+ last_msg = messages[-1]
640
+ parts = getattr(last_msg, "parts", None)
641
+ if not parts:
642
+ return response
643
+
644
+ for part in parts:
645
+ content = getattr(part, "content", None)
646
+ if not isinstance(content, str):
647
+ continue
648
+
649
+ scan = self._client_call(
650
+ ("mesh_validate", "meshValidate"),
651
+ text=content,
652
+ agent_id=agent_id,
653
+ direction="output",
654
+ session_id=self._session_id(ctx),
655
+ platform=self.config.platform,
656
+ redact_pii=self.config.redact_pii,
657
+ redact_secrets=self.config.redact_secrets,
658
+ block_on_sensitive_output=self.config.block_on_sensitive_output,
659
+ policy_context={
660
+ "source_agent_id": agent_id,
661
+ "request_purpose": "pydanticai_output_review",
662
+ "platform": self.config.platform,
663
+ "redact_pii": self.config.redact_pii,
664
+ "redact_secrets": self.config.redact_secrets,
665
+ "block_on_sensitive_output": self.config.block_on_sensitive_output,
666
+ },
667
+ )
668
+
669
+ payload = self._extract_payload(scan)
670
+ verdict = self._extract_verdict(scan)
671
+
672
+ if verdict == "BLOCKED" or self.config.block_on_sensitive_output:
673
+ part.content = (
674
+ "[EGRESS PAYLOAD TERMINATED BY AGENTICDOME DLP SECURITY MESH]"
675
+ )
676
+ continue
677
+
678
+ sanitized = (
679
+ payload.get("sanitized_text")
680
+ or payload.get("text")
681
+ or payload.get("output")
682
+ )
683
+
684
+ if sanitized is not None:
685
+ part.content = str(sanitized)
686
+
687
+ except Exception as exc:
688
+ logger.error("AgenticDome PydanticAI after_runner_end error: %s", exc)
689
+ if self.config.fail_closed:
690
+ try:
691
+ messages = getattr(response, "messages", None)
692
+ if messages and getattr(messages[-1], "parts", None):
693
+ for part in messages[-1].parts:
694
+ if hasattr(part, "content"):
695
+ part.content = (
696
+ "[FATAL ERROR: AGENTICDOME SECURITY CHECK FAULTED]"
697
+ )
698
+ except Exception:
699
+ pass
700
+
701
+ return response
702
+
703
+ else:
704
+ logger.info(
705
+ "PydanticAI agent does not expose after_runner_end. "
706
+ "Output lifecycle hook not attached."
707
+ )
708
+
709
+ # ------------------------------------------------------------------
710
+ # Tool perimeter
711
+ # ------------------------------------------------------------------
712
+
713
+ def secure_tool(self, tool_func: Callable[..., Any]) -> Callable[..., Any]:
714
+ """
715
+ Decorator that protects a PydanticAI tool.
716
+
717
+ It performs:
718
+ - Delegation-token verification for specialist calls
719
+ - A2A authorization and token minting for manager handoff tools
720
+ - Direct tool authorization for normal tools
721
+ - Output DLP screening after execution
722
+ """
723
+
724
+ if asyncio.iscoroutinefunction(tool_func):
725
+ @functools.wraps(tool_func)
726
+ async def _async_wrapper(ctx: RunContext[Any], *args: Any, **kwargs: Any) -> Any:
727
+ clean_kwargs, _ = await self._pre_execute_tool_check(
728
+ ctx,
729
+ tool_func.__name__,
730
+ dict(kwargs),
731
+ )
732
+ result = await tool_func(ctx, *args, **clean_kwargs)
733
+ return await self._post_execute_tool_sanitize(ctx, result)
734
+
735
+ return _async_wrapper
736
+
737
+ @functools.wraps(tool_func)
738
+ def _sync_wrapper(ctx: RunContext[Any], *args: Any, **kwargs: Any) -> Any:
739
+ clean_kwargs, _ = _run_async_from_sync(
740
+ self._pre_execute_tool_check,
741
+ ctx,
742
+ tool_func.__name__,
743
+ dict(kwargs),
744
+ )
745
+ result = tool_func(ctx, *args, **clean_kwargs)
746
+ return _run_async_from_sync(self._post_execute_tool_sanitize, ctx, result)
747
+
748
+ return _sync_wrapper
749
+
750
+ async def _pre_execute_tool_check(
751
+ self,
752
+ ctx: RunContext[Any],
753
+ name: str,
754
+ kwargs: Dict[str, Any],
755
+ ) -> Tuple[Dict[str, Any], bool]:
756
+ if self.client is None:
757
+ return kwargs, False
758
+
759
+ session_id = self._session_id(ctx)
760
+ agent_id = self._agent_name(ctx)
761
+
762
+ token = kwargs.pop("_AgenticDome_decision_token", None) or kwargs.pop("_decision_token", None)
763
+ source_agent_id = kwargs.pop("_AgenticDome_source_agent_id", None) or kwargs.pop(
764
+ "_source_agent_id",
765
+ None,
766
+ )
767
+
768
+ clean_kwargs = _strip_private_args(kwargs)
769
+
770
+ # Case A: Specialist execution verifies delegated decision token.
771
+ if token and source_agent_id:
772
+ response = await self._to_thread(
773
+ self._client_call,
774
+ ("a2a_verify_decision_token_rpc", "a2aVerifyDecisionTokenRpc", "a2a_verify_decision_token"),
775
+ str(token),
776
+ tool_name=name,
777
+ tool_args=clean_kwargs,
778
+ agent_id=agent_id,
779
+ source_agent_id=str(source_agent_id),
780
+ platform=self.config.platform,
781
+ require_allowed=True,
782
+ )
783
+
784
+ payload = self._extract_payload(response)
785
+
786
+ if payload and payload.get("valid") is False:
787
+ raise PydanticAIFirewallDenied(
788
+ f"AgenticDome rejected handoff token: {self._reason(response)}"
789
+ )
790
+
791
+ return clean_kwargs, True
792
+
793
+ # Case B: Manager handoff tool authorizes delegation and injects token.
794
+ if self.config.enable_a2a_for_delegation and _is_handoff_tool(name):
795
+ target_agent_id = _target_agent_id(clean_kwargs)
796
+ target_tool_name = _target_tool_name(name, clean_kwargs)
797
+ target_args = _target_tool_args(clean_kwargs)
798
+
799
+ response = await self._to_thread(
800
+ self._client_call,
801
+ ("a2a_authorize_tool", "a2aAuthorizeTool"),
802
+ text=f"PydanticAI manager {agent_id} delegating {target_tool_name} to {target_agent_id}",
803
+ agent_id=target_agent_id,
804
+ platform=self.config.platform,
805
+ source_platform=self.config.platform,
806
+ tool_platform=str(clean_kwargs.get("tool_platform") or self.config.default_tool_platform),
807
+ tool_name=target_tool_name,
808
+ tool_args=target_args,
809
+ session_id=session_id,
810
+ direction="outbound",
811
+ source_agent_id=agent_id,
812
+ policy_context={
813
+ "source_agent_id": agent_id,
814
+ "request_purpose": "pydanticai_delegated_task",
815
+ "platform": self.config.platform,
816
+ "delegation_chain": [agent_id, target_agent_id],
817
+ },
818
+ )
819
+
820
+ if self._extract_verdict(response) == "BLOCKED":
821
+ raise PydanticAIFirewallDenied(
822
+ f"AgenticDome blocked PydanticAI delegation: {self._reason(response)}"
823
+ )
824
+
825
+ decision_token = self._decision_token(response)
826
+
827
+ if decision_token:
828
+ clean_kwargs["_AgenticDome_decision_token"] = decision_token
829
+ clean_kwargs["_AgenticDome_source_agent_id"] = agent_id
830
+
831
+ target_args["_AgenticDome_decision_token"] = decision_token
832
+ target_args["_AgenticDome_source_agent_id"] = agent_id
833
+
834
+ if "target_tool_args" in clean_kwargs or "skill_args" not in clean_kwargs:
835
+ clean_kwargs["target_tool_args"] = target_args
836
+ else:
837
+ clean_kwargs["skill_args"] = target_args
838
+
839
+ self.token_store.put(
840
+ session_id=session_id,
841
+ target_agent_id=target_agent_id,
842
+ tool_name=target_tool_name,
843
+ tool_args=target_args,
844
+ record=DecisionTokenRecord(
845
+ decision_token=decision_token,
846
+ source_agent_id=agent_id,
847
+ created_at=time.time(),
848
+ ),
849
+ ttl_s=self.config.handoff_token_ttl_s,
850
+ )
851
+
852
+ return clean_kwargs, False
853
+
854
+ # Case C: Direct tool authorization.
855
+ response = await self._to_thread(
856
+ self._client_call,
857
+ ("guardrail_validate", "guardrailValidate"),
858
+ text=f"PydanticAI tool execution: {name}",
859
+ agent_id=agent_id,
860
+ platform=self.config.platform,
861
+ source_platform=self.config.platform,
862
+ tool_platform=str(clean_kwargs.get("tool_platform") or self.config.default_tool_platform),
863
+ tool_name=name,
864
+ tool_args=clean_kwargs,
865
+ direction="outbound",
866
+ session_id=session_id,
867
+ policy_context={
868
+ "source_agent_id": agent_id,
869
+ "request_purpose": "pydanticai_tool_execution",
870
+ "platform": self.config.platform,
871
+ },
872
+ )
873
+
874
+ if self._extract_verdict(response) == "BLOCKED":
875
+ raise PydanticAIFirewallDenied(
876
+ f"AgenticDome blocked tool execution '{name}': {self._reason(response)}"
877
+ )
878
+
879
+ return clean_kwargs, False
880
+
881
+ async def _post_execute_tool_sanitize(self, ctx: RunContext[Any], result: Any) -> Any:
882
+ if self.client is None:
883
+ return result
884
+
885
+ agent_id = self._agent_name(ctx)
886
+
887
+ if isinstance(result, (dict, list, tuple)):
888
+ raw_text = json.dumps(result, default=str)
889
+ else:
890
+ raw_text = str(result)
891
+
892
+ response = await self._to_thread(
893
+ self._client_call,
894
+ ("mesh_validate", "meshValidate"),
895
+ text=raw_text,
896
+ agent_id=agent_id,
897
+ direction="output",
898
+ session_id=self._session_id(ctx),
899
+ platform=self.config.platform,
900
+ redact_pii=self.config.redact_pii,
901
+ redact_secrets=self.config.redact_secrets,
902
+ block_on_sensitive_output=self.config.block_on_sensitive_output,
903
+ policy_context={
904
+ "source_agent_id": agent_id,
905
+ "request_purpose": "pydanticai_tool_output_review",
906
+ "platform": self.config.platform,
907
+ "redact_pii": self.config.redact_pii,
908
+ "redact_secrets": self.config.redact_secrets,
909
+ "block_on_sensitive_output": self.config.block_on_sensitive_output,
910
+ },
911
+ )
912
+
913
+ payload = self._extract_payload(response)
914
+ verdict = self._extract_verdict(response)
915
+
916
+ if verdict == "BLOCKED":
917
+ return "[BLOCKED BY AGENTICDOME ACTION LAYER DLP]"
918
+
919
+ sanitized = (
920
+ payload.get("sanitized_text")
921
+ or payload.get("text")
922
+ or payload.get("output")
923
+ )
924
+
925
+ if sanitized is not None:
926
+ if isinstance(result, str):
927
+ return str(sanitized)
928
+
929
+ if self.config.block_on_sensitive_output:
930
+ return str(sanitized)
931
+
932
+ return result
933
+
934
+
935
+ __all__ = [
936
+ "FirewallConfig",
937
+ "PydanticAIFirewallError",
938
+ "PydanticAIFirewallDenied",
939
+ "DecisionTokenRecord",
940
+ "DecisionTokenStore",
941
+ "InMemoryDecisionTokenStore",
942
+ "RedisDecisionTokenStore",
943
+ "CyberSecFirewall",
944
+ ]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agenticdome-python-sdk"
7
- version = "1.0.2"
7
+ version = "1.0.4"
8
8
  description = "Official Python SDK for AgentGuard Intelligence Engine and Action Firewall."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -32,10 +32,20 @@ setup(
32
32
  ],
33
33
  "redis": [
34
34
  "redis>=4.5.0",
35
+ ],
36
+ "pydanticai": [
37
+ "pydantic-ai",
38
+ "anyio>=4.0.0",
39
+ ],
40
+ "pydantic": [
41
+ "pydantic-ai",
42
+ "anyio>=4.0.0",
35
43
  ],
36
44
  "all": [
37
45
  "crewai",
38
46
  "redis>=4.5.0",
47
+ "pydantic-ai",
48
+ "anyio>=4.0.0",
39
49
  ],
40
50
  "dev": [
41
51
  "build",
@@ -0,0 +1,22 @@
1
+ import pytest
2
+
3
+
4
+ pytest.importorskip("pydantic_ai")
5
+
6
+
7
+ def test_pydanticai_firewall_imports():
8
+ from agenticdome_sdk.pydantic import CyberSecFirewall, FirewallConfig
9
+
10
+ config = FirewallConfig(
11
+ api_base="https://au.agenticdome.io",
12
+ api_key="test-key",
13
+ tenant_id="test-tenant",
14
+ fail_closed=False,
15
+ )
16
+
17
+ firewall = CyberSecFirewall(config=config)
18
+
19
+ assert firewall.config.api_base == "https://au.agenticdome.io"
20
+ assert firewall.config.platform == "pydanticai"
21
+ assert hasattr(firewall, "secure_tool")
22
+ assert hasattr(firewall, "attach_to_agent")