computer-agent-py 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,165 @@
1
+ """``query`` — drop-in for :func:`claude_agent_sdk.query` with a telemetry tap.
2
+
3
+ Every message yielded by the upstream stream is also reported into the active
4
+ telemetry pipeline. The user-visible stream is preserved verbatim — the proxy
5
+ never modifies or reorders yielded messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import claude_agent_sdk
15
+ from claude_agent_sdk.types import AssistantMessage, ResultMessage, TextBlock
16
+
17
+ from ..telemetry import TelemetryEvent, get_pipeline, new_session_id
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import AsyncIterable, AsyncIterator
21
+
22
+ logger = logging.getLogger("computeragent")
23
+
24
+
25
+ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
26
+ *,
27
+ prompt: str | AsyncIterable[dict[str, Any]],
28
+ options: Any = None,
29
+ transport: Any = None,
30
+ ) -> AsyncIterator[Any]:
31
+ """Drop-in for ``claude_agent_sdk.query`` — see upstream docs for the
32
+ parameter semantics. The wrapper additionally pushes a
33
+ :class:`~computeragent.telemetry.TelemetryEvent` per message into the
34
+ active pipeline.
35
+
36
+ The pipeline is always the module-level default unless the caller
37
+ pre-configured one via :func:`computeragent.telemetry.configure`.
38
+ """
39
+ pipeline = get_pipeline()
40
+ session_id = new_session_id()
41
+ agent_name = _derive_agent_name(options)
42
+ started_at = time.monotonic()
43
+ last_assistant_text = ""
44
+
45
+ try:
46
+ await pipeline.emit(
47
+ TelemetryEvent.session_started(
48
+ session_id=session_id,
49
+ agent_name=agent_name,
50
+ options=options,
51
+ prompt=prompt,
52
+ )
53
+ )
54
+
55
+ last_result: ResultMessage | None = None
56
+ upstream_kwargs: dict[str, Any] = {"prompt": prompt}
57
+ if options is not None:
58
+ upstream_kwargs["options"] = options
59
+ if transport is not None:
60
+ upstream_kwargs["transport"] = transport
61
+
62
+ upstream_session_id: str | None = None
63
+ async for msg in claude_agent_sdk.query(**upstream_kwargs):
64
+ # Capture upstream's session id once for join-by-id later, but
65
+ # do NOT rebind our `session_id` mid-stream — the sinks already
66
+ # opened bookkeeping under the placeholder on session_started,
67
+ # so swapping the key here would orphan that state and produce
68
+ # empty agent_logs.query / missing sessions.entries[].
69
+ real_sid = getattr(msg, "session_id", None)
70
+ if (
71
+ isinstance(real_sid, str)
72
+ and real_sid
73
+ and real_sid != session_id
74
+ and upstream_session_id is None
75
+ ):
76
+ upstream_session_id = real_sid
77
+
78
+ for ev in TelemetryEvent.from_message(
79
+ msg, session_id=session_id, agent_name=agent_name
80
+ ):
81
+ await pipeline.emit(ev)
82
+
83
+ if isinstance(msg, AssistantMessage):
84
+ # Track latest assistant text as a fallback when ResultMessage.result is empty.
85
+ for block in getattr(msg, "content", []) or []:
86
+ if isinstance(block, TextBlock):
87
+ last_assistant_text = block.text
88
+
89
+ if isinstance(msg, ResultMessage):
90
+ last_result = msg
91
+
92
+ yield msg
93
+
94
+ duration_ms = (time.monotonic() - started_at) * 1000.0
95
+ if last_result is not None:
96
+ ev = TelemetryEvent.session_ended_ok(
97
+ session_id=session_id,
98
+ agent_name=agent_name,
99
+ result=last_result,
100
+ duration_ms=duration_ms,
101
+ last_assistant_text=last_assistant_text,
102
+ )
103
+ if upstream_session_id is not None:
104
+ ev.payload["upstream_session_id"] = upstream_session_id
105
+ await pipeline.emit(ev)
106
+ else:
107
+ # Upstream finished without a ResultMessage — synthesize one event.
108
+ await pipeline.emit(
109
+ TelemetryEvent(
110
+ kind="session_ended",
111
+ session_id=session_id,
112
+ agent_name=agent_name,
113
+ payload={
114
+ "is_error": False,
115
+ "subtype": "no_result",
116
+ "result": last_assistant_text,
117
+ "duration_ms": duration_ms,
118
+ "num_turns": 0,
119
+ "total_cost_usd": 0.0,
120
+ "usage": {},
121
+ },
122
+ )
123
+ )
124
+ except BaseException as exc:
125
+ duration_ms = (time.monotonic() - started_at) * 1000.0
126
+ try:
127
+ await pipeline.emit(
128
+ TelemetryEvent.session_ended_error(
129
+ session_id=session_id,
130
+ agent_name=agent_name,
131
+ exc=exc,
132
+ duration_ms=duration_ms,
133
+ )
134
+ )
135
+ except Exception: # noqa: BLE001
136
+ logger.debug("error-path telemetry emit failed", exc_info=True)
137
+ raise
138
+ finally:
139
+ try:
140
+ await pipeline.flush()
141
+ except Exception: # noqa: BLE001
142
+ logger.debug("pipeline flush in query() finally failed", exc_info=True)
143
+
144
+
145
+ def _derive_agent_name(options: Any) -> str | None:
146
+ """Pick a stable agent name from options.
147
+
148
+ Strategy:
149
+ 1. Explicit ``agent_name`` attribute (not in upstream today but cheap to
150
+ honour if a fork adds it).
151
+ 2. First non-empty line of ``system_prompt`` (NordAssist's prod prompt
152
+ starts with "You are NordAssist..." — that line is the natural label).
153
+ 3. ``None`` — the sinks fall back to an anonymous hash.
154
+ """
155
+ if options is None:
156
+ return None
157
+ name = getattr(options, "agent_name", None)
158
+ if isinstance(name, str) and name:
159
+ return name
160
+ sp = getattr(options, "system_prompt", None)
161
+ if isinstance(sp, str) and sp:
162
+ first = sp.strip().splitlines()[0].strip()
163
+ if first:
164
+ return first[:80]
165
+ return None
@@ -0,0 +1,59 @@
1
+ """Policy-based tool-use authorization.
2
+
3
+ Public surface:
4
+
5
+ * :class:`~computeragent.policy.types.PolicyEngine` — Protocol implemented by
6
+ :class:`~computeragent.policy.opa.OpaPolicyEngine` and
7
+ :class:`~computeragent.policy.cedar.CedarPolicyEngine`.
8
+ * :class:`~computeragent.policy.types.PolicyInput` and friends
9
+ (``PolicyPrincipal``, ``PolicyAction``, ``PolicyResource``, ``PolicyResult``,
10
+ ``PolicyDecision``) — canonical engine-agnostic shapes.
11
+ * :class:`~computeragent.policy.authorizer.PolicyToolAuthorizer` — drop into
12
+ ``ClaudeAgentOptions.can_use_tool`` to gate every tool call through your
13
+ engine of choice.
14
+
15
+ Engines are imported lazily so a user who installs only ``computer-agent-py``
16
+ (no Cedar extra) doesn't pay an ``ImportError`` cost on
17
+ ``from computeragent.policy import OpaPolicyEngine``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .authorizer import PolicyToolAuthorizer
23
+ from .types import (
24
+ FailMode,
25
+ PolicyAction,
26
+ PolicyDecision,
27
+ PolicyEngine,
28
+ PolicyInput,
29
+ PolicyPrincipal,
30
+ PolicyResource,
31
+ PolicyResult,
32
+ )
33
+
34
+ __all__ = [
35
+ "FailMode",
36
+ "OpaPolicyEngine",
37
+ "CedarPolicyEngine",
38
+ "PolicyAction",
39
+ "PolicyDecision",
40
+ "PolicyEngine",
41
+ "PolicyInput",
42
+ "PolicyPrincipal",
43
+ "PolicyResource",
44
+ "PolicyResult",
45
+ "PolicyToolAuthorizer",
46
+ ]
47
+
48
+
49
+ def __getattr__(name: str): # type: ignore[no-untyped-def]
50
+ """Lazy import — only raise ImportError for the engine the user asked for."""
51
+ if name == "OpaPolicyEngine":
52
+ from .opa import OpaPolicyEngine as _OpaPolicyEngine
53
+
54
+ return _OpaPolicyEngine
55
+ if name == "CedarPolicyEngine":
56
+ from .cedar import CedarPolicyEngine as _CedarPolicyEngine
57
+
58
+ return _CedarPolicyEngine
59
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,161 @@
1
+ """``PolicyToolAuthorizer`` — adapter from :class:`PolicyEngine` to
2
+ ``claude_agent_sdk.types.CanUseTool``.
3
+
4
+ Drops straight onto ``ClaudeAgentOptions.can_use_tool``. On each tool the
5
+ agent wants to execute, the authorizer:
6
+
7
+ 1. Builds a :class:`PolicyInput` using the user-supplied resolvers + the
8
+ ``ToolPermissionContext`` upstream gives us.
9
+ 2. Calls ``await engine.evaluate(input)``.
10
+ 3. Returns ``PermissionResultAllow`` or ``PermissionResultDeny``.
11
+ 4. Emits a ``policy_decision`` telemetry event so the OTel sink can annotate
12
+ the active ``execute_tool`` span.
13
+
14
+ If the engine raises, the result depends on the engine's ``fail_mode`` (the
15
+ engine controls fail-closed-vs-open, not the authorizer).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ from collections.abc import Awaitable, Callable
23
+ from typing import TYPE_CHECKING, Any, Literal
24
+
25
+ from claude_agent_sdk.types import (
26
+ PermissionResultAllow,
27
+ PermissionResultDeny,
28
+ )
29
+
30
+ from ..telemetry import TelemetryEvent, get_pipeline
31
+ from .types import PolicyDecision, PolicyEngine, PolicyInput, PolicyPrincipal, PolicyResource
32
+
33
+ if TYPE_CHECKING:
34
+ from claude_agent_sdk.types import ToolPermissionContext
35
+
36
+ logger = logging.getLogger("computeragent.policy")
37
+
38
+ PrincipalResolver = Callable[["ToolPermissionContext"], PolicyPrincipal]
39
+ ResourceResolver = Callable[["ToolPermissionContext"], PolicyResource]
40
+ ContextResolver = Callable[["ToolPermissionContext"], dict[str, Any]]
41
+ # Async variants are also acceptable — we await whatever the resolver returns
42
+ # in case the user wants to fetch metadata from a database or env.
43
+ PrincipalResolverAsync = Callable[["ToolPermissionContext"], Awaitable[PolicyPrincipal]]
44
+ ResourceResolverAsync = Callable[["ToolPermissionContext"], Awaitable[PolicyResource]]
45
+ ContextResolverAsync = Callable[["ToolPermissionContext"], Awaitable[dict[str, Any]]]
46
+
47
+
48
+ class PolicyToolAuthorizer:
49
+ """``can_use_tool`` callable that delegates to a :class:`PolicyEngine`."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ engine: PolicyEngine,
55
+ principal_resolver: PrincipalResolver | PrincipalResolverAsync,
56
+ resource_resolver: ResourceResolver | ResourceResolverAsync | None = None,
57
+ context_resolver: ContextResolver | ContextResolverAsync | None = None,
58
+ on_deny: Literal["deny", "interrupt"] = "deny",
59
+ ) -> None:
60
+ self._engine = engine
61
+ self._principal_resolver = principal_resolver
62
+ self._resource_resolver = resource_resolver
63
+ self._context_resolver = context_resolver
64
+ self._on_deny = on_deny
65
+
66
+ async def __call__(
67
+ self,
68
+ tool_name: str,
69
+ tool_input: dict[str, Any],
70
+ context: ToolPermissionContext,
71
+ ) -> PermissionResultAllow | PermissionResultDeny:
72
+ started_at = time.monotonic()
73
+ input_obj = await self._build_input(tool_name, tool_input, context)
74
+ try:
75
+ result = await self._engine.evaluate(input_obj)
76
+ except Exception as exc: # noqa: BLE001 - engines are user code; never break the agent
77
+ # The engine's own fail_mode would normally swallow this, but if
78
+ # the engine itself misbehaves (unhandled), default to deny.
79
+ logger.warning(
80
+ "policy engine %r raised unexpectedly; denying",
81
+ type(self._engine).__name__,
82
+ exc_info=True,
83
+ )
84
+ permission = PermissionResultDeny(
85
+ message=f"policy engine error: {exc}",
86
+ interrupt=self._on_deny == "interrupt",
87
+ )
88
+ await self._emit_telemetry(input_obj, None, exc, started_at)
89
+ return permission
90
+
91
+ await self._emit_telemetry(input_obj, result, None, started_at)
92
+
93
+ if result.decision == PolicyDecision.ALLOW:
94
+ return PermissionResultAllow(updated_input=tool_input)
95
+ return PermissionResultDeny(
96
+ message=result.reason or "denied by policy",
97
+ interrupt=self._on_deny == "interrupt",
98
+ )
99
+
100
+ # ── helpers ────────────────────────────────────────────────────────────
101
+
102
+ async def _build_input(
103
+ self,
104
+ tool_name: str,
105
+ tool_input: dict[str, Any],
106
+ context: ToolPermissionContext,
107
+ ) -> PolicyInput:
108
+ from .types import PolicyAction # local import to keep module surface tidy
109
+
110
+ principal = await _maybe_await(self._principal_resolver(context))
111
+ resource: PolicyResource
112
+ if self._resource_resolver is not None:
113
+ resource = await _maybe_await(self._resource_resolver(context))
114
+ else:
115
+ resource = PolicyResource(
116
+ type="agent",
117
+ session_id=getattr(context, "session_id", "") or "",
118
+ )
119
+ ctx: dict[str, Any]
120
+ if self._context_resolver is not None:
121
+ ctx = await _maybe_await(self._context_resolver(context))
122
+ else:
123
+ ctx = {"session_id": getattr(context, "session_id", "") or ""}
124
+
125
+ return PolicyInput(
126
+ principal=principal,
127
+ action=PolicyAction(
128
+ name="tool_use",
129
+ tool_name=tool_name,
130
+ tool_input=dict(tool_input or {}),
131
+ ),
132
+ resource=resource,
133
+ context=ctx,
134
+ )
135
+
136
+ async def _emit_telemetry(
137
+ self,
138
+ input_obj: PolicyInput,
139
+ result: Any,
140
+ error: BaseException | None,
141
+ started_at: float,
142
+ ) -> None:
143
+ latency_ms = (time.monotonic() - started_at) * 1000.0
144
+ try:
145
+ event = TelemetryEvent.policy_decision(
146
+ input=input_obj,
147
+ result=result,
148
+ error=error,
149
+ latency_ms=latency_ms,
150
+ engine_name=getattr(self._engine, "name", type(self._engine).__name__),
151
+ )
152
+ await get_pipeline().emit(event)
153
+ except Exception: # noqa: BLE001
154
+ logger.debug("policy_decision telemetry emit failed", exc_info=True)
155
+
156
+
157
+ async def _maybe_await(value: Any) -> Any:
158
+ """Allow resolvers to be either sync (return a value) or async (return a coroutine)."""
159
+ if hasattr(value, "__await__"):
160
+ return await value
161
+ return value
@@ -0,0 +1,182 @@
1
+ """``CedarPolicyEngine`` — in-process Cedar evaluation via ``cedarpy``.
2
+
3
+ Cedar policies and entities are compiled / loaded at engine construction.
4
+ Each ``evaluate`` call translates :class:`PolicyInput` into Cedar's request
5
+ shape, then calls ``cedarpy.is_authorized``.
6
+
7
+ Translation rules:
8
+
9
+ * ``PolicyPrincipal(id="alice", groups=["engineer"])``
10
+ → ``User::"alice"`` with parents ``[Group::"engineer", ...]``.
11
+ * ``PolicyAction(name="tool_use", tool_name="Read")``
12
+ → ``Action::"toolUse"`` with attribute ``tool: "Read"``.
13
+ * ``PolicyResource(type="agent", agent_name="nordassist")``
14
+ → ``Agent::"nordassist"``.
15
+
16
+ Optional extra: ``pip install 'computer-agent-py[cedar]'``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ try:
25
+ import cedarpy as _cedarpy
26
+ except ImportError as _exc: # pragma: no cover - guarded by lazy attribute
27
+ raise ImportError(
28
+ "CedarPolicyEngine requires the [cedar] extra. "
29
+ "Install with: pip install 'computer-agent-py[cedar]'"
30
+ ) from _exc
31
+
32
+ from .types import FailMode, PolicyDecision, PolicyInput, PolicyResult
33
+
34
+ logger = logging.getLogger("computeragent.policy")
35
+
36
+
37
+ class CedarPolicyEngine:
38
+ """Local Cedar engine. No network calls.
39
+
40
+ Pass either ``policies`` (one or more Cedar policy texts) or ``schema``
41
+ (Cedar schema text). ``entities`` is an optional pre-loaded list using
42
+ cedarpy's entity dict shape; per-request entities (one for principal,
43
+ action, and resource) are merged in automatically.
44
+ """
45
+
46
+ name = "cedar"
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ policies: str | list[str],
52
+ entities: list[dict[str, Any]] | None = None,
53
+ schema: str | None = None,
54
+ fail_mode: FailMode = "deny",
55
+ ) -> None:
56
+ self._policies = "\n".join(policies) if isinstance(policies, list) else policies
57
+ self._schema = schema
58
+ self._static_entities = list(entities or [])
59
+ self._fail_mode = fail_mode
60
+
61
+ # Fail fast on policy parse errors at startup, not at first request.
62
+ try:
63
+ _cedarpy.format_policies(self._policies)
64
+ except Exception as exc: # noqa: BLE001 - cedarpy raises various types
65
+ raise ValueError(f"Cedar policy parse error: {exc}") from exc
66
+
67
+ async def evaluate(self, input: PolicyInput) -> PolicyResult:
68
+ try:
69
+ request, entities = _build_request_and_entities(input, self._static_entities)
70
+ result = _cedarpy.is_authorized(
71
+ request=request,
72
+ policies=self._policies,
73
+ entities=entities,
74
+ schema=self._schema,
75
+ )
76
+ allow = result.decision == _cedarpy.Decision.Allow
77
+ reasons = list(getattr(result.diagnostics, "reasons", []) or [])
78
+ errors = list(getattr(result.diagnostics, "errors", []) or [])
79
+ reason_parts: list[str] = []
80
+ if reasons:
81
+ reason_parts.append(f"policies={','.join(reasons)}")
82
+ if errors:
83
+ reason_parts.append(f"errors={','.join(str(e) for e in errors)}")
84
+ reason = "; ".join(reason_parts) or ("cedar allowed" if allow else "cedar denied")
85
+ return PolicyResult(
86
+ decision=PolicyDecision.ALLOW if allow else PolicyDecision.DENY,
87
+ reason=reason,
88
+ engine=self.name,
89
+ )
90
+ except Exception as exc: # noqa: BLE001 - cedarpy raises bare exceptions
91
+ return self._failure_result(exc)
92
+
93
+ async def aclose(self) -> None:
94
+ # Nothing to close — cedarpy is purely in-process.
95
+ return
96
+
97
+ # ── helpers ────────────────────────────────────────────────────────────
98
+
99
+ def _failure_result(self, exc: BaseException) -> PolicyResult:
100
+ if self._fail_mode == "allow":
101
+ logger.warning("Cedar policy engine error; fail_mode=allow → allowing: %s", exc)
102
+ return PolicyResult(
103
+ decision=PolicyDecision.ALLOW,
104
+ reason=f"policy engine unavailable (fail_mode=allow): {exc}",
105
+ engine=self.name,
106
+ )
107
+ logger.warning("Cedar policy engine error; fail_mode=deny → denying: %s", exc)
108
+ return PolicyResult(
109
+ decision=PolicyDecision.DENY,
110
+ reason=f"policy engine unavailable: {exc}",
111
+ engine=self.name,
112
+ )
113
+
114
+
115
+ def _build_request_and_entities(
116
+ input: PolicyInput,
117
+ static_entities: list[dict[str, Any]],
118
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
119
+ """Translate a PolicyInput into cedarpy's ``request`` + ``entities`` shape."""
120
+ user_uid = {"type": "User", "id": input.principal.id or "anonymous"}
121
+ agent_uid = {
122
+ "type": "Agent",
123
+ "id": input.resource.agent_name or input.resource.session_id or "anonymous",
124
+ }
125
+
126
+ # Build the per-request entities: principal + groups + agent.
127
+ group_entities: list[dict[str, Any]] = [
128
+ {"uid": {"type": "Group", "id": g}, "attrs": {}, "parents": []}
129
+ for g in input.principal.groups
130
+ ]
131
+ principal_entity = {
132
+ "uid": user_uid,
133
+ "attrs": _coerce_attrs(input.principal.attributes),
134
+ "parents": [{"type": "Group", "id": g} for g in input.principal.groups],
135
+ }
136
+ resource_entity = {
137
+ "uid": agent_uid,
138
+ "attrs": _coerce_attrs(
139
+ {
140
+ "model": input.resource.model,
141
+ "session_id": input.resource.session_id,
142
+ **(input.resource.attributes or {}),
143
+ }
144
+ ),
145
+ "parents": [],
146
+ }
147
+ # Cedar's action attributes live on the action entity itself; tool_name
148
+ # is the most useful key to expose for `when` clauses like
149
+ # `action.tool == "Read"`.
150
+ action_entity = {
151
+ "uid": {"type": "Action", "id": "toolUse"},
152
+ "attrs": _coerce_attrs(
153
+ {
154
+ "tool": input.action.tool_name,
155
+ # tool_input is omitted from attrs by default — Cedar prefers
156
+ # primitives, and tool inputs can be arbitrary dicts. Users
157
+ # who want to write policies against tool inputs should
158
+ # supply a context_resolver that flattens specific keys.
159
+ }
160
+ ),
161
+ "parents": [],
162
+ }
163
+ entities = [
164
+ *static_entities,
165
+ principal_entity,
166
+ resource_entity,
167
+ action_entity,
168
+ *group_entities,
169
+ ]
170
+
171
+ request = {
172
+ "principal": f'User::"{user_uid["id"]}"',
173
+ "action": 'Action::"toolUse"',
174
+ "resource": f'Agent::"{agent_uid["id"]}"',
175
+ "context": _coerce_attrs(input.context),
176
+ }
177
+ return request, entities
178
+
179
+
180
+ def _coerce_attrs(attrs: dict[str, Any]) -> dict[str, Any]:
181
+ """Drop None values; Cedar treats missing attribute as ``has`` = false."""
182
+ return {k: v for k, v in attrs.items() if v is not None and v != ""}
@@ -0,0 +1,124 @@
1
+ """``OpaPolicyEngine`` — remote-HTTP OPA backend.
2
+
3
+ POSTs to ``{url}/v1/data/{policy_path}`` with body ``{"input": <PolicyInput>}``.
4
+ Parses OPA's response in either of two common shapes:
5
+
6
+ * ``{"result": true | false}`` — bare boolean decision.
7
+ * ``{"result": {"allow": bool, "reason": str?, "obligations": list?}}`` —
8
+ object form with optional reason / advisory obligations.
9
+
10
+ Failure modes (network error, timeout, non-2xx, malformed JSON) honour
11
+ ``fail_mode=`` — DENY by default, ``"allow"`` for non-prod environments.
12
+ Uses ``httpx`` which is already a core dependency, so no extra to install.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Any, cast
19
+
20
+ import httpx
21
+
22
+ from .types import FailMode, PolicyDecision, PolicyInput, PolicyResult
23
+
24
+ logger = logging.getLogger("computeragent.policy")
25
+
26
+
27
+ class OpaPolicyEngine:
28
+ """Open Policy Agent backend over OPA's REST data API.
29
+
30
+ Parameters mirror an OPA sidecar deployment. The ``policy_path`` is the
31
+ period-or-slash-separated path under ``/v1/data``: e.g. for a Rego file
32
+ ``package computeragent.tools`` with rule ``allow``, set
33
+ ``policy_path="computeragent/tools/allow"``.
34
+ """
35
+
36
+ name = "opa"
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ url: str,
42
+ policy_path: str,
43
+ fail_mode: FailMode = "deny",
44
+ timeout: float = 2.0,
45
+ headers: dict[str, str] | None = None,
46
+ client: httpx.AsyncClient | None = None,
47
+ ) -> None:
48
+ self._url = url.rstrip("/")
49
+ self._policy_path = policy_path.strip("/").replace(".", "/")
50
+ self._fail_mode = fail_mode
51
+ self._timeout = timeout
52
+ self._headers = headers or {}
53
+ self._owns_client = client is None
54
+ self._client = client or httpx.AsyncClient(timeout=timeout)
55
+
56
+ async def evaluate(self, input: PolicyInput) -> PolicyResult:
57
+ endpoint = f"{self._url}/v1/data/{self._policy_path}"
58
+ body = {"input": input.to_json()}
59
+ try:
60
+ response = await self._client.post(
61
+ endpoint,
62
+ json=body,
63
+ headers=self._headers,
64
+ timeout=self._timeout,
65
+ )
66
+ response.raise_for_status()
67
+ payload = response.json()
68
+ except (httpx.HTTPError, ValueError) as exc:
69
+ # Connection error, timeout, non-2xx, JSON decode failure.
70
+ return self._failure_result(exc)
71
+
72
+ return self._parse_result(payload)
73
+
74
+ async def aclose(self) -> None:
75
+ if self._owns_client:
76
+ try:
77
+ await self._client.aclose()
78
+ except Exception: # noqa: BLE001 - cleanup must not raise
79
+ logger.debug("opa client aclose failed", exc_info=True)
80
+
81
+ # ── helpers ────────────────────────────────────────────────────────────
82
+
83
+ def _parse_result(self, payload: dict[str, Any]) -> PolicyResult:
84
+ result_val = payload.get("result")
85
+ if isinstance(result_val, bool):
86
+ return PolicyResult(
87
+ decision=PolicyDecision.ALLOW if result_val else PolicyDecision.DENY,
88
+ reason="opa allowed" if result_val else "opa denied",
89
+ engine=self.name,
90
+ )
91
+ if isinstance(result_val, dict):
92
+ allow = bool(result_val.get("allow", False))
93
+ reason = str(result_val.get("reason") or "")
94
+ obligations_raw = result_val.get("obligations") or []
95
+ obligations: list[dict[str, Any]] = []
96
+ if isinstance(obligations_raw, list):
97
+ obligations = [
98
+ cast("dict[str, Any]", o) for o in obligations_raw if isinstance(o, dict)
99
+ ]
100
+ return PolicyResult(
101
+ decision=PolicyDecision.ALLOW if allow else PolicyDecision.DENY,
102
+ reason=reason or ("opa allowed" if allow else "opa denied"),
103
+ obligations=obligations,
104
+ engine=self.name,
105
+ )
106
+ # Unknown shape — treat as deny with diagnostics.
107
+ return self._failure_result(
108
+ ValueError(f"unexpected OPA result shape: {type(result_val).__name__}")
109
+ )
110
+
111
+ def _failure_result(self, exc: BaseException) -> PolicyResult:
112
+ if self._fail_mode == "allow":
113
+ logger.warning("OPA policy engine error; fail_mode=allow → allowing: %s", exc)
114
+ return PolicyResult(
115
+ decision=PolicyDecision.ALLOW,
116
+ reason=f"policy engine unavailable (fail_mode=allow): {exc}",
117
+ engine=self.name,
118
+ )
119
+ logger.warning("OPA policy engine error; fail_mode=deny → denying: %s", exc)
120
+ return PolicyResult(
121
+ decision=PolicyDecision.DENY,
122
+ reason=f"policy engine unavailable: {exc}",
123
+ engine=self.name,
124
+ )