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.
- computer_agent_py-0.1.0.dist-info/METADATA +307 -0
- computer_agent_py-0.1.0.dist-info/RECORD +25 -0
- computer_agent_py-0.1.0.dist-info/WHEEL +4 -0
- computeragent/__init__.py +90 -0
- computeragent/_proxy/__init__.py +8 -0
- computeragent/_proxy/client.py +225 -0
- computeragent/_proxy/query.py +165 -0
- computeragent/policy/__init__.py +59 -0
- computeragent/policy/authorizer.py +161 -0
- computeragent/policy/cedar.py +182 -0
- computeragent/policy/opa.py +124 -0
- computeragent/policy/types.py +121 -0
- computeragent/py.typed +0 -0
- computeragent/telemetry/__init__.py +24 -0
- computeragent/telemetry/config.py +176 -0
- computeragent/telemetry/event.py +355 -0
- computeragent/telemetry/middleware/__init__.py +8 -0
- computeragent/telemetry/middleware/guardrails.py +127 -0
- computeragent/telemetry/middleware/pii.py +158 -0
- computeragent/telemetry/pipeline.py +160 -0
- computeragent/telemetry/sinks/__init__.py +28 -0
- computeragent/telemetry/sinks/agentos.py +442 -0
- computeragent/telemetry/sinks/message_archive.py +136 -0
- computeragent/telemetry/sinks/otel.py +375 -0
- computeragent/types.py +13 -0
|
@@ -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
|
+
)
|