regent-control 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,85 @@
1
+ """Regent Control — Python SDK.
2
+
3
+ Two integration models:
4
+
5
+ * **Gate-direct** (:class:`RegentControl` / :class:`AsyncRegentControl`): your code
6
+ authorizes an action, gets a scoped token, performs it, reports the outcome.
7
+ * **Sidecar-routing** (:class:`SidecarSession` / :class:`AsyncSidecarSession`): your
8
+ code makes its normal HTTP call pointed at the sidecar, which injects the real
9
+ credential — the agent never holds the provider secret.
10
+
11
+ A policy ``deny`` is a normal result of :meth:`RegentControl.authorize`; opt into
12
+ raising via ``decision.raise_for_status()`` / ``authorize_or_raise``. The sidecar
13
+ session raises the typed error automatically.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from regent_control.client import (
19
+ DEFAULT_BASE_URL,
20
+ AsyncRegentControl,
21
+ RegentControl,
22
+ )
23
+ from regent_control.crypto import derive_agent_secret, hmac_sha256_hex
24
+ from regent_control.decorator import guarded
25
+ from regent_control.errors import (
26
+ AgentNotActive,
27
+ ControlDenied,
28
+ ControlError,
29
+ ControlNetworkError,
30
+ Escalated,
31
+ IdentityNotResolved,
32
+ MandateExceeded,
33
+ MandateNotFound,
34
+ PolicyDenied,
35
+ DuplicateRequest,
36
+ RiskThresholdExceeded,
37
+ VelocityExceeded,
38
+ ToolNotAllowed,
39
+ )
40
+ from regent_control.sidecar import AsyncSidecarSession, SidecarSession
41
+ from regent_control.types import (
42
+ CompleteResult,
43
+ Decision,
44
+ DecisionCode,
45
+ Escalation,
46
+ Obligation,
47
+ )
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ __all__ = [
52
+ "__version__",
53
+ "DEFAULT_BASE_URL",
54
+ # gate-direct
55
+ "RegentControl",
56
+ "AsyncRegentControl",
57
+ # sidecar-routing
58
+ "SidecarSession",
59
+ "AsyncSidecarSession",
60
+ # decorator
61
+ "guarded",
62
+ # types
63
+ "Decision",
64
+ "DecisionCode",
65
+ "Obligation",
66
+ "Escalation",
67
+ "CompleteResult",
68
+ # crypto
69
+ "derive_agent_secret",
70
+ "hmac_sha256_hex",
71
+ # errors
72
+ "ControlError",
73
+ "ControlNetworkError",
74
+ "ControlDenied",
75
+ "IdentityNotResolved",
76
+ "AgentNotActive",
77
+ "PolicyDenied",
78
+ "ToolNotAllowed",
79
+ "MandateNotFound",
80
+ "MandateExceeded",
81
+ "DuplicateRequest",
82
+ "RiskThresholdExceeded",
83
+ "VelocityExceeded",
84
+ "Escalated",
85
+ ]
@@ -0,0 +1,137 @@
1
+ """Internal HTTP layer (sync + async). Not part of the public surface.
2
+
3
+ * Bearer API-key auth; optional HMAC-SHA256 body signing (``X-Agent-Signature``).
4
+ * Retries network errors / 429 / 5xx with exponential backoff.
5
+ * A 200 carrying a ``deny`` decision is NOT an error — the JSON is returned and
6
+ the caller (the client) interprets it. Only transport/auth/validation fail.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+ from dataclasses import dataclass, replace
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from regent_control.crypto import hmac_sha256_hex
19
+ from regent_control.errors import ControlError, ControlNetworkError
20
+
21
+ _RETRYABLE = {429, 500, 502, 503, 504}
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class HttpConfig:
26
+ base_url: str
27
+ api_key: str
28
+ agent_secret: str | None = None
29
+ timeout_seconds: float = 10.0
30
+ max_retries: int = 2
31
+
32
+
33
+ def _headers(cfg: HttpConfig, payload: str, extra: dict[str, str] | None) -> dict[str, str]:
34
+ headers = {
35
+ "Content-Type": "application/json",
36
+ "Accept": "application/json",
37
+ "Authorization": f"Bearer {cfg.api_key}",
38
+ "User-Agent": "regent-control-python/0.1.0",
39
+ }
40
+ if extra:
41
+ headers.update(extra)
42
+ if cfg.agent_secret:
43
+ sig = hmac_sha256_hex(cfg.agent_secret, payload)
44
+ headers["X-Agent-Signature"] = f"hmac-sha256={sig}"
45
+ headers["X-Agent-Signature-Timestamp"] = str(int(time.time()))
46
+ return headers
47
+
48
+
49
+ def _interpret(resp: httpx.Response) -> dict[str, Any] | None:
50
+ """Return parsed JSON on success, None to signal a retryable status."""
51
+ if resp.status_code in _RETRYABLE:
52
+ return None
53
+ if resp.status_code >= 400:
54
+ rid = resp.headers.get("x-request-id") or resp.headers.get("x-correlation-id")
55
+ detail = ""
56
+ try:
57
+ detail = resp.json().get("detail") or resp.text
58
+ except Exception: # noqa: BLE001
59
+ detail = resp.text
60
+ raise ControlError(
61
+ f"control plane returned {resp.status_code}: {detail}",
62
+ status_code=resp.status_code,
63
+ request_id=rid,
64
+ )
65
+ return resp.json()
66
+
67
+
68
+ def _backoff_seconds(attempt: int) -> float:
69
+ return min(0.2 * (2**attempt), 2.0)
70
+
71
+
72
+ class HttpClient:
73
+ """Synchronous transport."""
74
+
75
+ def __init__(self, cfg: HttpConfig, client: httpx.Client | None = None) -> None:
76
+ self._cfg = replace(cfg, base_url=cfg.base_url.rstrip("/"))
77
+ self._client = client or httpx.Client(timeout=self._cfg.timeout_seconds)
78
+ self._owns = client is None
79
+
80
+ def post(self, path: str, body: Any, extra_headers: dict[str, str] | None = None) -> dict[str, Any]:
81
+ import json as _json
82
+
83
+ payload = _json.dumps(body or {}, separators=(",", ":"))
84
+ url = f"{self._cfg.base_url}{path}"
85
+ headers = _headers(self._cfg, payload, extra_headers)
86
+ last_exc: Exception | None = None
87
+ for attempt in range(self._cfg.max_retries + 1):
88
+ try:
89
+ resp = self._client.post(url, content=payload, headers=headers)
90
+ parsed = _interpret(resp)
91
+ if parsed is not None:
92
+ return parsed
93
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
94
+ last_exc = exc
95
+ if attempt < self._cfg.max_retries:
96
+ time.sleep(_backoff_seconds(attempt))
97
+ if last_exc is not None:
98
+ raise ControlNetworkError(f"request to {url} failed: {last_exc}", last_exc)
99
+ raise ControlNetworkError(f"request to {url} exhausted retries")
100
+
101
+ def close(self) -> None:
102
+ if self._owns:
103
+ self._client.close()
104
+
105
+
106
+ class AsyncHttpClient:
107
+ """Asynchronous transport (same behavior as :class:`HttpClient`)."""
108
+
109
+ def __init__(self, cfg: HttpConfig, client: httpx.AsyncClient | None = None) -> None:
110
+ self._cfg = replace(cfg, base_url=cfg.base_url.rstrip("/"))
111
+ self._client = client or httpx.AsyncClient(timeout=self._cfg.timeout_seconds)
112
+ self._owns = client is None
113
+
114
+ async def post(self, path: str, body: Any, extra_headers: dict[str, str] | None = None) -> dict[str, Any]:
115
+ import json as _json
116
+
117
+ payload = _json.dumps(body or {}, separators=(",", ":"))
118
+ url = f"{self._cfg.base_url}{path}"
119
+ headers = _headers(self._cfg, payload, extra_headers)
120
+ last_exc: Exception | None = None
121
+ for attempt in range(self._cfg.max_retries + 1):
122
+ try:
123
+ resp = await self._client.post(url, content=payload, headers=headers)
124
+ parsed = _interpret(resp)
125
+ if parsed is not None:
126
+ return parsed
127
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
128
+ last_exc = exc
129
+ if attempt < self._cfg.max_retries:
130
+ await asyncio.sleep(_backoff_seconds(attempt))
131
+ if last_exc is not None:
132
+ raise ControlNetworkError(f"request to {url} failed: {last_exc}", last_exc)
133
+ raise ControlNetworkError(f"request to {url} exhausted retries")
134
+
135
+ async def aclose(self) -> None:
136
+ if self._owns:
137
+ await self._client.aclose()
@@ -0,0 +1,261 @@
1
+ """Gate-direct clients — :class:`RegentControl` (sync) and :class:`AsyncRegentControl`.
2
+
3
+ This is the "wrap-and-gate" model: your code asks the Decision API to authorize a
4
+ proposed action, gets an ``allow`` with a short-lived scoped token, performs the
5
+ action attaching that token, then reports the outcome with :meth:`complete`.
6
+
7
+ For the "no key in the agent" model where the sidecar injects the real credential,
8
+ use :class:`regent_control.sidecar.SidecarSession` instead.
9
+
10
+ control = RegentControl(api_key=KEY, agent_id="agent_42")
11
+ d = control.authorize("stripe", "refund.create",
12
+ amount_usd=50, mandate_id="mnd_1",
13
+ user_token=rep_id_token, intent="refund duplicate charge",
14
+ facts={"account_status": "active", "refund_to_original": True})
15
+ if not d.allowed:
16
+ ... # d.code / d.reason — handle deny/escalate
17
+ do_refund(scoped_token=d.token) # the token authorizes the downstream call
18
+ control.complete(d.decision_id, status="success", downstream_ref=refund_id)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from regent_control._http import AsyncHttpClient, HttpClient, HttpConfig
28
+ from regent_control.crypto import derive_agent_secret
29
+ from regent_control.types import CompleteResult, CompleteStatus, Decision
30
+
31
+ DEFAULT_BASE_URL = "https://api.regentprotocol.org"
32
+
33
+ _DECISIONS = "/v1/control/decisions"
34
+
35
+
36
+ def _build_request(
37
+ agent_id: str,
38
+ tool: str,
39
+ action: str,
40
+ resource: str | None,
41
+ args: dict[str, Any] | None,
42
+ context: dict[str, Any] | None,
43
+ *,
44
+ amount_usd: float | None,
45
+ mandate_id: str | None,
46
+ idempotency_key: str | None,
47
+ user_token: str | None,
48
+ intent: str | None,
49
+ reasoning_summary: str | None,
50
+ task: str | None,
51
+ session_id: str | None,
52
+ user_ref: str | None,
53
+ op: str | None,
54
+ resource_type: str | None,
55
+ account_status: str | None,
56
+ refund_to_original: bool | None,
57
+ facts: dict[str, Any] | None,
58
+ ) -> tuple[dict[str, Any], dict[str, str] | None]:
59
+ ctx: dict[str, Any] = {
60
+ "session_id": session_id,
61
+ "user_ref": user_ref,
62
+ "amount_usd": amount_usd,
63
+ "mandate_id": mandate_id,
64
+ "idempotency_key": idempotency_key,
65
+ "user_token": user_token,
66
+ "intent": intent,
67
+ "reasoning_summary": reasoning_summary,
68
+ "task": task,
69
+ "op": op,
70
+ "resource_type": resource_type,
71
+ "account_status": account_status,
72
+ "refund_to_original": refund_to_original,
73
+ }
74
+ if facts:
75
+ ctx.update(facts)
76
+ if context:
77
+ ctx.update(context)
78
+ ctx = {k: v for k, v in ctx.items() if v is not None} # gate has safe defaults
79
+ body = {
80
+ "agent_id": agent_id,
81
+ "tool": tool,
82
+ "action": action,
83
+ "resource": resource,
84
+ "args": args or {},
85
+ "context": ctx,
86
+ }
87
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
88
+ return body, headers
89
+
90
+
91
+ class RegentControl:
92
+ """Synchronous gate-direct client."""
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ api_key: str,
98
+ agent_id: str,
99
+ base_url: str = DEFAULT_BASE_URL,
100
+ agent_secret: str | None = None,
101
+ sign_requests: bool = False,
102
+ timeout_seconds: float = 10.0,
103
+ max_retries: int = 2,
104
+ client: httpx.Client | None = None,
105
+ ) -> None:
106
+ self._agent_id = agent_id
107
+ if sign_requests and agent_secret is None:
108
+ agent_secret = derive_agent_secret(api_key, agent_id)
109
+ cfg = HttpConfig(
110
+ base_url=base_url, api_key=api_key, agent_secret=agent_secret,
111
+ timeout_seconds=timeout_seconds, max_retries=max_retries,
112
+ )
113
+ self._http = HttpClient(cfg, client=client)
114
+
115
+ @property
116
+ def agent_id(self) -> str:
117
+ return self._agent_id
118
+
119
+ def authorize(
120
+ self,
121
+ tool: str,
122
+ action: str,
123
+ *,
124
+ resource: str | None = None,
125
+ args: dict[str, Any] | None = None,
126
+ context: dict[str, Any] | None = None,
127
+ amount_usd: float | None = None,
128
+ mandate_id: str | None = None,
129
+ idempotency_key: str | None = None,
130
+ user_token: str | None = None,
131
+ intent: str | None = None,
132
+ reasoning_summary: str | None = None,
133
+ task: str | None = None,
134
+ session_id: str | None = None,
135
+ user_ref: str | None = None,
136
+ op: str | None = None,
137
+ resource_type: str | None = None,
138
+ account_status: str | None = None,
139
+ refund_to_original: bool | None = None,
140
+ facts: dict[str, Any] | None = None,
141
+ ) -> Decision:
142
+ """Authorize a proposed action. Returns allow / deny / escalate (never raises on deny)."""
143
+ body, headers = _build_request(
144
+ self._agent_id, tool, action, resource, args, context,
145
+ amount_usd=amount_usd, mandate_id=mandate_id, idempotency_key=idempotency_key,
146
+ user_token=user_token, intent=intent, reasoning_summary=reasoning_summary, task=task,
147
+ session_id=session_id, user_ref=user_ref, op=op, resource_type=resource_type,
148
+ account_status=account_status, refund_to_original=refund_to_original, facts=facts,
149
+ )
150
+ return Decision.from_wire(self._http.post(_DECISIONS, body, headers))
151
+
152
+ def authorize_or_raise(self, *args: Any, **kwargs: Any) -> Decision:
153
+ """Like :meth:`authorize` but raises the typed error unless the verdict is ``allow``."""
154
+ return self.authorize(*args, **kwargs).raise_for_status()
155
+
156
+ def complete(
157
+ self,
158
+ decision_id: str,
159
+ *,
160
+ status: CompleteStatus,
161
+ downstream_ref: str | None = None,
162
+ error: str | None = None,
163
+ ) -> CompleteResult:
164
+ """Report the outcome of an authorized action (closes audit, reconciles counters)."""
165
+ body = {"status": status, "downstream_ref": downstream_ref, "error": error}
166
+ raw = self._http.post(f"{_DECISIONS}/{decision_id}/complete", body)
167
+ return CompleteResult.from_wire(raw)
168
+
169
+ def close(self) -> None:
170
+ self._http.close()
171
+
172
+ def __enter__(self) -> RegentControl:
173
+ return self
174
+
175
+ def __exit__(self, *exc: object) -> None:
176
+ self.close()
177
+
178
+
179
+ class AsyncRegentControl:
180
+ """Asynchronous gate-direct client (mirror of :class:`RegentControl`)."""
181
+
182
+ def __init__(
183
+ self,
184
+ *,
185
+ api_key: str,
186
+ agent_id: str,
187
+ base_url: str = DEFAULT_BASE_URL,
188
+ agent_secret: str | None = None,
189
+ sign_requests: bool = False,
190
+ timeout_seconds: float = 10.0,
191
+ max_retries: int = 2,
192
+ client: httpx.AsyncClient | None = None,
193
+ ) -> None:
194
+ self._agent_id = agent_id
195
+ if sign_requests and agent_secret is None:
196
+ agent_secret = derive_agent_secret(api_key, agent_id)
197
+ cfg = HttpConfig(
198
+ base_url=base_url, api_key=api_key, agent_secret=agent_secret,
199
+ timeout_seconds=timeout_seconds, max_retries=max_retries,
200
+ )
201
+ self._http = AsyncHttpClient(cfg, client=client)
202
+
203
+ @property
204
+ def agent_id(self) -> str:
205
+ return self._agent_id
206
+
207
+ async def authorize(
208
+ self,
209
+ tool: str,
210
+ action: str,
211
+ *,
212
+ resource: str | None = None,
213
+ args: dict[str, Any] | None = None,
214
+ context: dict[str, Any] | None = None,
215
+ amount_usd: float | None = None,
216
+ mandate_id: str | None = None,
217
+ idempotency_key: str | None = None,
218
+ user_token: str | None = None,
219
+ intent: str | None = None,
220
+ reasoning_summary: str | None = None,
221
+ task: str | None = None,
222
+ session_id: str | None = None,
223
+ user_ref: str | None = None,
224
+ op: str | None = None,
225
+ resource_type: str | None = None,
226
+ account_status: str | None = None,
227
+ refund_to_original: bool | None = None,
228
+ facts: dict[str, Any] | None = None,
229
+ ) -> Decision:
230
+ body, headers = _build_request(
231
+ self._agent_id, tool, action, resource, args, context,
232
+ amount_usd=amount_usd, mandate_id=mandate_id, idempotency_key=idempotency_key,
233
+ user_token=user_token, intent=intent, reasoning_summary=reasoning_summary, task=task,
234
+ session_id=session_id, user_ref=user_ref, op=op, resource_type=resource_type,
235
+ account_status=account_status, refund_to_original=refund_to_original, facts=facts,
236
+ )
237
+ return Decision.from_wire(await self._http.post(_DECISIONS, body, headers))
238
+
239
+ async def authorize_or_raise(self, *args: Any, **kwargs: Any) -> Decision:
240
+ return (await self.authorize(*args, **kwargs)).raise_for_status()
241
+
242
+ async def complete(
243
+ self,
244
+ decision_id: str,
245
+ *,
246
+ status: CompleteStatus,
247
+ downstream_ref: str | None = None,
248
+ error: str | None = None,
249
+ ) -> CompleteResult:
250
+ body = {"status": status, "downstream_ref": downstream_ref, "error": error}
251
+ raw = await self._http.post(f"{_DECISIONS}/{decision_id}/complete", body)
252
+ return CompleteResult.from_wire(raw)
253
+
254
+ async def aclose(self) -> None:
255
+ await self._http.aclose()
256
+
257
+ async def __aenter__(self) -> AsyncRegentControl:
258
+ return self
259
+
260
+ async def __aexit__(self, *exc: object) -> None:
261
+ await self.aclose()
@@ -0,0 +1,21 @@
1
+ """HMAC helpers — must match the server.
2
+
3
+ The per-agent signing secret is ``HMAC-SHA256(api_key, agent_id)``; a signed
4
+ request carries ``X-Agent-Signature: hmac-sha256=<hex>`` over the raw JSON body.
5
+ This is identical to ``@regent/control-sdk`` (TypeScript), so an agent can be
6
+ moved between the two with the same credentials.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+
14
+
15
+ def hmac_sha256_hex(secret: str, message: str) -> str:
16
+ return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
17
+
18
+
19
+ def derive_agent_secret(api_key: str, agent_id: str) -> str:
20
+ """The per-agent HMAC secret: ``HMAC-SHA256(api_key, agent_id)`` as hex."""
21
+ return hmac_sha256_hex(api_key, agent_id)
@@ -0,0 +1,72 @@
1
+ """``@guarded`` — wrap a tool function so every call is authorized first.
2
+
3
+ The decorator authorizes the action before the function runs, raises the typed
4
+ error on a non-allow, optionally injects the scoped token, and reports the outcome
5
+ with ``complete`` (``success`` / ``failed``) when the function returns or raises.
6
+
7
+ control = RegentControl(api_key=KEY, agent_id="agent_42")
8
+
9
+ @guarded(control, tool="payments", action="refund.create", amount_arg="amount_usd")
10
+ def issue_refund(*, charge: str, amount_usd: float, scoped_token: str = "") -> str:
11
+ return provider.refund(charge, amount_usd, token=scoped_token)
12
+
13
+ issue_refund(charge="ch_1", amount_usd=50) # authorized → runs → completed
14
+ issue_refund(charge="ch_1", amount_usd=999) # raises MandateExceeded; never runs
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import inspect
21
+ from typing import Any, Callable, TypeVar
22
+
23
+ F = TypeVar("F", bound=Callable[..., Any])
24
+
25
+
26
+ def guarded(
27
+ control: Any,
28
+ *,
29
+ tool: str,
30
+ action: str,
31
+ amount_arg: str | None = None,
32
+ pass_token_as: str | None = "scoped_token",
33
+ **authz: Any,
34
+ ) -> Callable[[F], F]:
35
+ """Decorator factory. ``control`` is a :class:`~regent_control.client.RegentControl`.
36
+
37
+ * ``amount_arg`` — the name of a keyword argument whose value is the action's
38
+ ``amount_usd`` (so a money action is gated against its mandate).
39
+ * ``pass_token_as`` — if the wrapped function declares this parameter, the allow
40
+ token is injected into it. Set to ``None`` to never inject.
41
+ * ``**authz`` — any other static ``authorize`` kwargs (``mandate_id``, ``op``,
42
+ ``user_token``, ``intent``, ``facts``, …). Per-call values can be supplied via
43
+ a ``control_context`` keyword on the call, merged over these.
44
+ """
45
+
46
+ def deco(fn: F) -> F:
47
+ sig = inspect.signature(fn)
48
+ wants_token = pass_token_as is not None and pass_token_as in sig.parameters
49
+
50
+ @functools.wraps(fn)
51
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
52
+ per_call = kwargs.pop("control_context", None) or {}
53
+ amount = kwargs.get(amount_arg) if amount_arg else None
54
+ decision = control.authorize(
55
+ tool, action,
56
+ amount_usd=amount if isinstance(amount, (int, float)) else None,
57
+ **{**authz, **per_call},
58
+ )
59
+ decision.raise_for_status() # deny/escalate → typed error; never runs fn
60
+ if wants_token:
61
+ kwargs[pass_token_as] = decision.token
62
+ try:
63
+ result = fn(*args, **kwargs)
64
+ except Exception as exc: # noqa: BLE001 — report then re-raise
65
+ control.complete(decision.decision_id, status="failed", error=str(exc))
66
+ raise
67
+ control.complete(decision.decision_id, status="success")
68
+ return result
69
+
70
+ return wrapper # type: ignore[return-value]
71
+
72
+ return deco