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.
- regent_control/__init__.py +85 -0
- regent_control/_http.py +137 -0
- regent_control/client.py +261 -0
- regent_control/crypto.py +21 -0
- regent_control/decorator.py +72 -0
- regent_control/dev.py +172 -0
- regent_control/errors.py +136 -0
- regent_control/sidecar.py +212 -0
- regent_control/types.py +137 -0
- regent_control/verify.py +128 -0
- regent_control-0.1.0.dist-info/METADATA +192 -0
- regent_control-0.1.0.dist-info/RECORD +16 -0
- regent_control-0.1.0.dist-info/WHEEL +4 -0
- regent_control-0.1.0.dist-info/entry_points.txt +2 -0
- regent_control-0.1.0.dist-info/licenses/LICENSE +202 -0
- regent_control-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -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
|
+
]
|
regent_control/_http.py
ADDED
|
@@ -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()
|
regent_control/client.py
ADDED
|
@@ -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()
|
regent_control/crypto.py
ADDED
|
@@ -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
|