shieldops-sdk 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,50 @@
1
+ """ShieldOps SDK -- AI Security Control Plane SDK.
2
+
3
+ Intercept and govern AI agent tool calls with one line of code.
4
+
5
+ Quick start::
6
+
7
+ from shieldops_sdk import ShieldOpsClient, ShieldOpsInterceptor, ShieldOpsConfig
8
+
9
+ # API client for the ShieldOps platform
10
+ client = ShieldOpsClient(api_key="sk-...")
11
+
12
+ # Framework-agnostic tool call interceptor
13
+ config = ShieldOpsConfig(api_key="sk-...", mode="enforce")
14
+ interceptor = ShieldOpsInterceptor(config)
15
+ decision = interceptor.check("my_tool", {"arg": "value"})
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from shieldops_sdk.async_client import AsyncShieldOpsClient
21
+ from shieldops_sdk.client import ShieldOpsClient
22
+ from shieldops_sdk.config import SDKMode, ShieldOpsConfig
23
+ from shieldops_sdk.exceptions import (
24
+ AuthenticationError,
25
+ NotFoundError,
26
+ RateLimitError,
27
+ ShieldOpsConnectionError,
28
+ ShieldOpsDeniedError,
29
+ ShieldOpsError,
30
+ ValidationError,
31
+ )
32
+ from shieldops_sdk.interceptor import Decision, ShieldOpsInterceptor, ToolCall
33
+
34
+ __all__ = [
35
+ "AsyncShieldOpsClient",
36
+ "AuthenticationError",
37
+ "Decision",
38
+ "NotFoundError",
39
+ "RateLimitError",
40
+ "SDKMode",
41
+ "ShieldOpsClient",
42
+ "ShieldOpsConfig",
43
+ "ShieldOpsConnectionError",
44
+ "ShieldOpsDeniedError",
45
+ "ShieldOpsError",
46
+ "ShieldOpsInterceptor",
47
+ "ToolCall",
48
+ "ValidationError",
49
+ ]
50
+ __version__ = "0.1.0"
@@ -0,0 +1,41 @@
1
+ """Private policy module — default pattern catalogues and merge helpers.
2
+
3
+ The leading underscore makes this package private. It is not part of the
4
+ public SDK API and may be reorganised between minor releases. Public clients
5
+ should configure policy via ``ShieldOpsConfig.extra_blocked_patterns`` and
6
+ ``ShieldOpsConfig.extra_high_risk_patterns`` rather than importing from here.
7
+
8
+ The helpers centralise the "effective policy set" computation so future PRs
9
+ (mode/telemetry split, callbacks) can reuse one merge implementation rather
10
+ than duplicating ``defaults | extras`` in multiple call sites.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from shieldops_sdk._policy._defaults import (
18
+ DEFAULT_BLOCKED_PATTERNS,
19
+ DEFAULT_HIGH_RISK_PATTERNS,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from shieldops_sdk.config import ShieldOpsConfig
24
+
25
+
26
+ def effective_blocked_patterns(config: ShieldOpsConfig) -> set[str]:
27
+ """Return a fresh set of blocked patterns: defaults ∪ config.extra_blocked_patterns."""
28
+ return set(DEFAULT_BLOCKED_PATTERNS) | set(config.extra_blocked_patterns)
29
+
30
+
31
+ def effective_high_risk_patterns(config: ShieldOpsConfig) -> set[str]:
32
+ """Return a fresh set of high-risk patterns: defaults ∪ config.extra_high_risk_patterns."""
33
+ return set(DEFAULT_HIGH_RISK_PATTERNS) | set(config.extra_high_risk_patterns)
34
+
35
+
36
+ __all__ = [
37
+ "DEFAULT_BLOCKED_PATTERNS",
38
+ "DEFAULT_HIGH_RISK_PATTERNS",
39
+ "effective_blocked_patterns",
40
+ "effective_high_risk_patterns",
41
+ ]
@@ -0,0 +1,33 @@
1
+ """Default pattern catalogues for the ShieldOps interceptor.
2
+
3
+ These are ``frozenset`` so the module-level defaults cannot be mutated by
4
+ client code. The interceptor copies them into per-instance ``set`` attributes
5
+ at construction time, preserving the existing pattern of
6
+ ``interceptor._blocked_tools.update(...)`` for advanced post-construct
7
+ configuration.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ DEFAULT_BLOCKED_PATTERNS: frozenset[str] = frozenset(
13
+ {
14
+ "delete_database",
15
+ "drop_table",
16
+ "modify_iam_root",
17
+ "rm_rf",
18
+ "format_disk",
19
+ "disable_firewall",
20
+ "delete_backup",
21
+ }
22
+ )
23
+
24
+ DEFAULT_HIGH_RISK_PATTERNS: frozenset[str] = frozenset(
25
+ {
26
+ "execute_command",
27
+ "run_shell",
28
+ "modify_security_group",
29
+ "change_iam_policy",
30
+ "create_user",
31
+ "rotate_credentials",
32
+ }
33
+ )
@@ -0,0 +1,72 @@
1
+ """Shared HTTP response handling for sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from shieldops_sdk.exceptions import (
8
+ AuthenticationError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ServerError,
12
+ ShieldOpsError,
13
+ ValidationError,
14
+ )
15
+
16
+
17
+ def handle_response(response: httpx.Response) -> None:
18
+ """Raise the appropriate SDK exception for non-2xx responses.
19
+
20
+ This is intentionally a plain function so both sync and async
21
+ resource classes can reuse it without duplication.
22
+ """
23
+ if response.is_success:
24
+ return
25
+
26
+ status = response.status_code
27
+
28
+ # Try to extract a detail message from the JSON body
29
+ detail = ""
30
+ try:
31
+ body = response.json()
32
+ detail = body.get("detail", "") if isinstance(body, dict) else ""
33
+ except Exception: # noqa: BLE001
34
+ detail = response.text[:200] if response.text else ""
35
+
36
+ if status in (401, 403):
37
+ raise AuthenticationError(detail or "Authentication failed")
38
+
39
+ if status == 404:
40
+ raise NotFoundError(detail or "Resource not found")
41
+
42
+ if status == 422:
43
+ raise ValidationError(detail or "Validation error")
44
+
45
+ if status == 429:
46
+ retry_after: int | None = None
47
+ raw = response.headers.get("Retry-After")
48
+ if raw is not None:
49
+ try:
50
+ retry_after = int(raw)
51
+ except ValueError:
52
+ pass
53
+ # Also try the JSON body which ShieldOps returns
54
+ if retry_after is None:
55
+ try:
56
+ body = response.json()
57
+ retry_after = body.get("retry_after")
58
+ except Exception: # noqa: BLE001
59
+ pass
60
+ raise RateLimitError(
61
+ detail or "Rate limit exceeded",
62
+ retry_after=retry_after,
63
+ )
64
+
65
+ if status >= 500:
66
+ raise ServerError(
67
+ detail or "Internal server error",
68
+ status_code=status,
69
+ )
70
+
71
+ # Catch-all for other 4xx
72
+ raise ShieldOpsError(detail or f"Request failed with status {status}", status_code=status)
@@ -0,0 +1,83 @@
1
+ """Asynchronous ShieldOps API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from shieldops_sdk._response import handle_response
10
+ from shieldops_sdk.resources.agents import AsyncAgentsResource
11
+ from shieldops_sdk.resources.investigations import AsyncInvestigationsResource
12
+ from shieldops_sdk.resources.remediations import AsyncRemediationsResource
13
+ from shieldops_sdk.resources.security import AsyncSecurityResource
14
+ from shieldops_sdk.resources.vulnerabilities import AsyncVulnerabilitiesResource
15
+
16
+ _DEFAULT_BASE_URL = "http://localhost:8000/api/v1"
17
+ _DEFAULT_TIMEOUT = 30.0
18
+
19
+
20
+ class AsyncShieldOpsClient:
21
+ """Async client for the ShieldOps REST API.
22
+
23
+ Usage::
24
+
25
+ async with AsyncShieldOpsClient(api_key="sk-...") as client:
26
+ invs = await client.investigations.list(limit=10)
27
+ for inv in invs.items:
28
+ print(inv.investigation_id, inv.status)
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ base_url: str = _DEFAULT_BASE_URL,
35
+ api_key: str | None = None,
36
+ token: str | None = None,
37
+ timeout: float = _DEFAULT_TIMEOUT,
38
+ ) -> None:
39
+ headers: dict[str, str] = {"User-Agent": "shieldops-sdk/1.0.0"}
40
+ if api_key:
41
+ headers["X-API-Key"] = api_key
42
+ elif token:
43
+ headers["Authorization"] = f"Bearer {token}"
44
+
45
+ self._http = httpx.AsyncClient(
46
+ base_url=base_url,
47
+ headers=headers,
48
+ timeout=timeout,
49
+ )
50
+
51
+ # Resource namespaces
52
+ self.investigations = AsyncInvestigationsResource(self._http)
53
+ self.remediations = AsyncRemediationsResource(self._http)
54
+ self.security = AsyncSecurityResource(self._http)
55
+ self.vulnerabilities = AsyncVulnerabilitiesResource(self._http)
56
+ self.agents = AsyncAgentsResource(self._http)
57
+
58
+ # -- Async context manager --------------------------------------------
59
+
60
+ async def __aenter__(self) -> AsyncShieldOpsClient:
61
+ return self
62
+
63
+ async def __aexit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc_val: BaseException | None,
67
+ exc_tb: Any,
68
+ ) -> None:
69
+ await self.close()
70
+
71
+ async def close(self) -> None:
72
+ """Close the underlying HTTP transport."""
73
+ await self._http.aclose()
74
+
75
+ # -- Convenience methods ----------------------------------------------
76
+
77
+ async def health(self) -> dict[str, Any]:
78
+ """Check the API health endpoint."""
79
+ base = str(self._http.base_url)
80
+ root = base.rsplit("/api/", 1)[0] if "/api/" in base else base
81
+ resp = await self._http.get(f"{root}/health")
82
+ handle_response(resp)
83
+ return resp.json()
@@ -0,0 +1,85 @@
1
+ """Synchronous ShieldOps API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from shieldops_sdk._response import handle_response
10
+ from shieldops_sdk.resources.agents import AgentsResource
11
+ from shieldops_sdk.resources.investigations import InvestigationsResource
12
+ from shieldops_sdk.resources.remediations import RemediationsResource
13
+ from shieldops_sdk.resources.security import SecurityResource
14
+ from shieldops_sdk.resources.vulnerabilities import VulnerabilitiesResource
15
+
16
+ _DEFAULT_BASE_URL = "http://localhost:8000/api/v1"
17
+ _DEFAULT_TIMEOUT = 30.0
18
+
19
+
20
+ class ShieldOpsClient:
21
+ """Synchronous client for the ShieldOps REST API.
22
+
23
+ Usage::
24
+
25
+ with ShieldOpsClient(api_key="sk-...") as client:
26
+ invs = client.investigations.list(limit=10)
27
+ for inv in invs.items:
28
+ print(inv.investigation_id, inv.status)
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ base_url: str = _DEFAULT_BASE_URL,
35
+ api_key: str | None = None,
36
+ token: str | None = None,
37
+ timeout: float = _DEFAULT_TIMEOUT,
38
+ ) -> None:
39
+ headers: dict[str, str] = {"User-Agent": "shieldops-sdk/1.0.0"}
40
+ if api_key:
41
+ headers["X-API-Key"] = api_key
42
+ elif token:
43
+ headers["Authorization"] = f"Bearer {token}"
44
+
45
+ self._http = httpx.Client(
46
+ base_url=base_url,
47
+ headers=headers,
48
+ timeout=timeout,
49
+ )
50
+
51
+ # Resource namespaces
52
+ self.investigations = InvestigationsResource(self._http)
53
+ self.remediations = RemediationsResource(self._http)
54
+ self.security = SecurityResource(self._http)
55
+ self.vulnerabilities = VulnerabilitiesResource(self._http)
56
+ self.agents = AgentsResource(self._http)
57
+
58
+ # -- Context manager --------------------------------------------------
59
+
60
+ def __enter__(self) -> ShieldOpsClient:
61
+ return self
62
+
63
+ def __exit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc_val: BaseException | None,
67
+ exc_tb: Any,
68
+ ) -> None:
69
+ self.close()
70
+
71
+ def close(self) -> None:
72
+ """Close the underlying HTTP transport."""
73
+ self._http.close()
74
+
75
+ # -- Convenience methods ----------------------------------------------
76
+
77
+ def health(self) -> dict[str, Any]:
78
+ """Check the API health endpoint."""
79
+ # Health lives outside the /api/v1 prefix, so use an absolute URL.
80
+ base = str(self._http.base_url)
81
+ # Strip the API version path to reach the root.
82
+ root = base.rsplit("/api/", 1)[0] if "/api/" in base else base
83
+ resp = self._http.get(f"{root}/health")
84
+ handle_response(resp)
85
+ return resp.json()
@@ -0,0 +1,95 @@
1
+ """ShieldOps SDK configuration — loaded from constructor args or environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class SDKMode(str, Enum):
12
+ """SDK enforcement mode."""
13
+
14
+ AUDIT = "audit"
15
+ ENFORCE = "enforce"
16
+
17
+
18
+ class SDKTelemetry(str, Enum):
19
+ """SDK telemetry destination.
20
+
21
+ Separate from :class:`SDKMode` — mode controls block-vs-audit on policy
22
+ violations; telemetry controls *where* records of those decisions go.
23
+
24
+ - ``LOCAL``: keep all events in-process, no network at all.
25
+ - ``REMOTE``: send to the ShieldOps backend (requires ``api_key``).
26
+ - ``OTLP``: send to the user's OpenTelemetry collector.
27
+ """
28
+
29
+ LOCAL = "local"
30
+ REMOTE = "remote"
31
+ OTLP = "otlp"
32
+
33
+
34
+ class ShieldOpsConfig(BaseModel):
35
+ """Configuration for the ShieldOps SDK.
36
+
37
+ Values can be provided directly or read from environment variables:
38
+ - ``SHIELDOPS_API_KEY``
39
+ - ``SHIELDOPS_ENDPOINT``
40
+ - ``SHIELDOPS_MODE``
41
+
42
+ Attributes:
43
+ api_key: ShieldOps API key for authentication.
44
+ endpoint: ShieldOps API base URL.
45
+ mode: Operating mode -- ``audit`` logs without blocking, ``enforce`` blocks risky calls.
46
+ timeout: HTTP request timeout in seconds.
47
+ """
48
+
49
+ api_key: str = Field(default="")
50
+ endpoint: str = Field(default="https://api.shieldops.io")
51
+ mode: SDKMode = SDKMode.AUDIT
52
+ telemetry: SDKTelemetry = Field(
53
+ default=SDKTelemetry.LOCAL,
54
+ description=(
55
+ "Where intercept records are sent. LOCAL = in-process only "
56
+ "(default, no network). REMOTE = POST to ShieldOps backend "
57
+ "(requires api_key). OTLP = export via OpenTelemetry collector."
58
+ ),
59
+ )
60
+ timeout: float = Field(default=5.0, ge=0.1)
61
+ extra_blocked_patterns: set[str] = Field(
62
+ default_factory=set,
63
+ description=(
64
+ "Additional tool-name patterns to deny on top of the SDK defaults. "
65
+ "Merged with shieldops_sdk._policy defaults at interceptor construction."
66
+ ),
67
+ )
68
+ extra_high_risk_patterns: set[str] = Field(
69
+ default_factory=set,
70
+ description=(
71
+ "Additional tool-name patterns to flag as high-risk on top of the "
72
+ "SDK defaults. Merged with shieldops_sdk._policy defaults at "
73
+ "interceptor construction."
74
+ ),
75
+ )
76
+
77
+ def model_post_init(self, __context: object) -> None:
78
+ """Populate unset fields from environment variables."""
79
+ if not self.api_key:
80
+ self.api_key = os.environ.get("SHIELDOPS_API_KEY", "")
81
+ if self.endpoint == "https://api.shieldops.io":
82
+ env_endpoint = os.environ.get("SHIELDOPS_ENDPOINT", "")
83
+ if env_endpoint:
84
+ self.endpoint = env_endpoint
85
+ env_mode = os.environ.get("SHIELDOPS_MODE", "")
86
+ if env_mode and env_mode.lower() in ("audit", "enforce"):
87
+ self.mode = SDKMode(env_mode.lower())
88
+
89
+ @property
90
+ def is_enforce(self) -> bool:
91
+ return self.mode == SDKMode.ENFORCE
92
+
93
+ @property
94
+ def is_audit(self) -> bool:
95
+ return self.mode == SDKMode.AUDIT
@@ -0,0 +1,85 @@
1
+ """ShieldOps SDK exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ShieldOpsError(Exception):
7
+ """Base exception for all SDK errors."""
8
+
9
+ def __init__(self, message: str, status_code: int | None = None) -> None:
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.status_code = status_code
13
+
14
+
15
+ class AuthenticationError(ShieldOpsError):
16
+ """Invalid or expired API credentials (401/403)."""
17
+
18
+ def __init__(self, message: str = "Authentication failed") -> None:
19
+ super().__init__(message, status_code=401)
20
+
21
+
22
+ class NotFoundError(ShieldOpsError):
23
+ """Resource not found (404)."""
24
+
25
+ def __init__(self, message: str = "Resource not found") -> None:
26
+ super().__init__(message, status_code=404)
27
+
28
+
29
+ class RateLimitError(ShieldOpsError):
30
+ """Rate limit exceeded (429)."""
31
+
32
+ def __init__(
33
+ self,
34
+ message: str = "Rate limit exceeded",
35
+ retry_after: int | None = None,
36
+ ) -> None:
37
+ super().__init__(message, status_code=429)
38
+ self.retry_after = retry_after
39
+
40
+
41
+ class ValidationError(ShieldOpsError):
42
+ """Request validation failed (422)."""
43
+
44
+ def __init__(self, message: str = "Validation error") -> None:
45
+ super().__init__(message, status_code=422)
46
+
47
+
48
+ class ServerError(ShieldOpsError):
49
+ """Server-side error (5xx)."""
50
+
51
+ def __init__(
52
+ self,
53
+ message: str = "Internal server error",
54
+ status_code: int = 500,
55
+ ) -> None:
56
+ super().__init__(message, status_code=status_code)
57
+
58
+
59
+ class ShieldOpsDeniedError(ShieldOpsError):
60
+ """Raised in enforce mode when a tool call is denied by policy.
61
+
62
+ Attributes:
63
+ tool_name: The tool that was denied.
64
+ reasons: List of policy violation reasons.
65
+ risk_score: The computed risk score for the tool call.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ tool_name: str = "",
71
+ reasons: list[str] | None = None,
72
+ risk_score: float = 0.0,
73
+ ) -> None:
74
+ self.tool_name = tool_name
75
+ self.reasons = reasons or []
76
+ self.risk_score = risk_score
77
+ detail = f"Tool '{tool_name}' denied: {', '.join(self.reasons)}"
78
+ super().__init__(detail, status_code=403)
79
+
80
+
81
+ class ShieldOpsConnectionError(ShieldOpsError):
82
+ """Raised when the SDK cannot reach the ShieldOps API."""
83
+
84
+ def __init__(self, message: str = "Failed to connect to ShieldOps API") -> None:
85
+ super().__init__(message, status_code=None)
@@ -0,0 +1,22 @@
1
+ """ShieldOps SDK experimental namespace.
2
+
3
+ Modules under ``shieldops_sdk.experimental`` provide integrations for AI agent
4
+ frameworks whose APIs are still in flux. Their public surface may change in any
5
+ minor SDK release without a deprecation cycle — pin a specific SDK version if
6
+ you depend on these.
7
+
8
+ Stable integrations live under ``shieldops_sdk.integrations``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import warnings
14
+
15
+ warnings.warn(
16
+ "shieldops_sdk.experimental contains unstable integrations whose surface "
17
+ "may change without notice between minor releases. Pin your SDK version "
18
+ "if you depend on these. Stable integrations live under "
19
+ "shieldops_sdk.integrations.",
20
+ UserWarning,
21
+ stacklevel=2,
22
+ )
@@ -0,0 +1,114 @@
1
+ """ShieldOps Microsoft AutoGen integration (experimental).
2
+
3
+ Wraps an AutoGen agent so its ``execute_function`` calls flow through the
4
+ ShieldOps interceptor. In ``enforce`` mode a denied call raises
5
+ ``ShieldOpsDeniedError`` from inside the wrapped function — the AutoGen agent
6
+ sees the raise the same as any other tool error.
7
+
8
+ This module is **experimental**. The legacy version exposed ``record_tool_result``,
9
+ ``on_message``, and ``get_audit_report`` for telemetry; those depended on
10
+ internal interceptor methods that the public SDK does not expose. Use
11
+ ``shieldops_sdk.telemetry`` for result/latency reporting instead.
12
+
13
+ Usage::
14
+
15
+ from shieldops_sdk.experimental.autogen import ShieldOpsAutoGenWrapper
16
+
17
+ wrapper = ShieldOpsAutoGenWrapper(api_key="sk-...", mode="enforce")
18
+ secured_agent = wrapper.wrap_agent(my_autogen_agent)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ import logging
25
+ from typing import Any
26
+
27
+ from shieldops_sdk.config import SDKMode, ShieldOpsConfig
28
+ from shieldops_sdk.interceptor import ShieldOpsInterceptor
29
+
30
+ logger = logging.getLogger("shieldops_sdk.experimental.autogen")
31
+
32
+
33
+ class ShieldOpsAutoGenWrapper:
34
+ """Wraps Microsoft AutoGen agents with ShieldOps firewall interception."""
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str = "",
39
+ endpoint: str = "https://api.shieldops.io",
40
+ mode: str = "audit",
41
+ agent_id: str = "autogen-agent",
42
+ ) -> None:
43
+ config = ShieldOpsConfig(
44
+ api_key=api_key,
45
+ endpoint=endpoint,
46
+ mode=SDKMode(mode),
47
+ )
48
+ self._config = config
49
+ self._interceptor = ShieldOpsInterceptor(config)
50
+ self._agent_id = agent_id
51
+ logger.info(
52
+ "shieldops.autogen.initialized agent_id=%s mode=%s",
53
+ agent_id,
54
+ mode,
55
+ )
56
+
57
+ def wrap_tool_execution(
58
+ self,
59
+ tool_name: str,
60
+ tool_args: dict[str, Any] | None = None,
61
+ ) -> dict[str, Any]:
62
+ """Evaluate a tool call before execution.
63
+
64
+ Returns ``{"action", "risk_score"}``. In ``enforce`` mode a denied call
65
+ raises ``ShieldOpsDeniedError`` instead of returning.
66
+ """
67
+ decision = self._interceptor.check(
68
+ tool_name,
69
+ tool_args or {},
70
+ agent_id=self._agent_id,
71
+ )
72
+ return {"action": decision.action, "risk_score": decision.risk_score}
73
+
74
+ def wrap_agent(self, agent: Any) -> Any:
75
+ """Monkey-patch ``agent.execute_function`` to route through the interceptor.
76
+
77
+ Returns the same agent for chainability. If the agent has no
78
+ ``execute_function`` attribute, this is a no-op.
79
+ """
80
+ if not hasattr(agent, "execute_function"):
81
+ logger.info(
82
+ "shieldops.autogen.wrap_agent_noop agent=%s reason=no_execute_function",
83
+ getattr(agent, "name", agent),
84
+ )
85
+ return agent
86
+
87
+ interceptor = self._interceptor
88
+ agent_id = self._agent_id
89
+ original_exec = agent.execute_function
90
+
91
+ @functools.wraps(original_exec)
92
+ def wrapped_exec(func_call: dict[str, Any], **kwargs: Any) -> Any:
93
+ tool_name = func_call.get("name", "unknown_function")
94
+ tool_args = func_call.get("arguments", {})
95
+ # check() raises ShieldOpsDeniedError in enforce mode for denied calls.
96
+ interceptor.check(tool_name, tool_args, agent_id=agent_id)
97
+ return original_exec(func_call, **kwargs)
98
+
99
+ agent.execute_function = wrapped_exec
100
+ logger.info(
101
+ "shieldops.autogen.agent_wrapped agent=%s",
102
+ getattr(agent, "name", agent),
103
+ )
104
+ return agent
105
+
106
+ @property
107
+ def interceptor(self) -> ShieldOpsInterceptor:
108
+ """Underlying interceptor (for advanced telemetry/stats access)."""
109
+ return self._interceptor
110
+
111
+ @property
112
+ def stats(self) -> dict[str, Any]:
113
+ """Interception statistics."""
114
+ return self._interceptor.stats