reqlo-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ coverage
15
+
16
+ # next.js
17
+ .next/
18
+ .next-dev/
19
+ out/
20
+
21
+ # production
22
+ build
23
+ dist/
24
+
25
+ # misc
26
+ .DS_Store
27
+ *.pem
28
+
29
+ # debug
30
+ npm-debug.log*
31
+ yarn-debug.log*
32
+ yarn-error.log*
33
+ .pnpm-debug.log*
34
+
35
+ # env files (can opt-in for committing if needed)
36
+ .env*
37
+
38
+ # vercel
39
+ .vercel
40
+
41
+ # typescript
42
+ *.tsbuildinfo
43
+ next-env.d.ts
44
+ !.env.example
45
+
46
+ # turborepo
47
+ .turbo
48
+
49
+ # fumadocs generated content index
50
+ .source
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ReQlo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: reqlo-sdk
3
+ Version: 0.1.0
4
+ Summary: Server-side Python client for ReQlo: authorization for AI agents.
5
+ Project-URL: Homepage, https://reqlo.io
6
+ Project-URL: Documentation, https://reqlo-web.vercel.app/docs/sdks/python
7
+ Author: ReQlo
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ReQlo Python SDK
17
+
18
+ Server-side client for [ReQlo](https://reqlo.io), authorization for AI agents (`pip install reqlo-sdk`, `import reqlo`). Python 3.10+, standard library only,
19
+ sync and asyncio. Keep your project API key on your server.
20
+
21
+ ```bash
22
+ pip install reqlo-sdk
23
+ ```
24
+
25
+ ```python
26
+ import os
27
+ from reqlo import Reqlo, ReqloDeniedError
28
+
29
+ reqlo = Reqlo(api_key=os.environ["REQLO_API_KEY"])
30
+
31
+ try:
32
+ reqlo.enforce(
33
+ agent={"id": "agt_7Hq2mK9xR4vTq8Lp"},
34
+ subject={"id": session.user_id, "kind": "user"}, # from your verified session
35
+ action="documents.read",
36
+ resource={"type": "document", "id": doc.id, "attributes": {"owner_id": doc.owner_id}},
37
+ context={"document_classification": doc.classification},
38
+ )
39
+ except ReqloDeniedError as denied:
40
+ return {"error": "Not allowed", "reason": denied.decision["reason_code"]}
41
+ # Any other ReqloError (timeout, network, HTTP error) propagates: the action does not run.
42
+ ```
43
+
44
+ - `authorize(...)` returns the decision dict; act only when `decision["decision"] == "allow"`.
45
+ - `enforce(...)` returns only on allow and raises `ReqloDeniedError` on deny or `approval_required`.
46
+ - `AsyncReqlo` has the same methods for asyncio.
47
+ - `approvals.list(status=)`, `approvals.retrieve(id)`.
48
+ - `delegations.create(agent_id, subject, actions, expires_at)`, `list`, `retrieve`, `revoke`. Create is never retried.
49
+ - `reqlo.webhooks.verify(body, signature_header, secret)` and `construct_event(...)` check webhook signatures.
50
+
51
+ Retries `429` and `5xx` twice with backoff (3-second timeout). Timeouts and network errors are never retried and
52
+ never mean allow. Documentation: <https://reqlo-web.vercel.app/docs/sdks/python>
@@ -0,0 +1,37 @@
1
+ # ReQlo Python SDK
2
+
3
+ Server-side client for [ReQlo](https://reqlo.io), authorization for AI agents (`pip install reqlo-sdk`, `import reqlo`). Python 3.10+, standard library only,
4
+ sync and asyncio. Keep your project API key on your server.
5
+
6
+ ```bash
7
+ pip install reqlo-sdk
8
+ ```
9
+
10
+ ```python
11
+ import os
12
+ from reqlo import Reqlo, ReqloDeniedError
13
+
14
+ reqlo = Reqlo(api_key=os.environ["REQLO_API_KEY"])
15
+
16
+ try:
17
+ reqlo.enforce(
18
+ agent={"id": "agt_7Hq2mK9xR4vTq8Lp"},
19
+ subject={"id": session.user_id, "kind": "user"}, # from your verified session
20
+ action="documents.read",
21
+ resource={"type": "document", "id": doc.id, "attributes": {"owner_id": doc.owner_id}},
22
+ context={"document_classification": doc.classification},
23
+ )
24
+ except ReqloDeniedError as denied:
25
+ return {"error": "Not allowed", "reason": denied.decision["reason_code"]}
26
+ # Any other ReqloError (timeout, network, HTTP error) propagates: the action does not run.
27
+ ```
28
+
29
+ - `authorize(...)` returns the decision dict; act only when `decision["decision"] == "allow"`.
30
+ - `enforce(...)` returns only on allow and raises `ReqloDeniedError` on deny or `approval_required`.
31
+ - `AsyncReqlo` has the same methods for asyncio.
32
+ - `approvals.list(status=)`, `approvals.retrieve(id)`.
33
+ - `delegations.create(agent_id, subject, actions, expires_at)`, `list`, `retrieve`, `revoke`. Create is never retried.
34
+ - `reqlo.webhooks.verify(body, signature_header, secret)` and `construct_event(...)` check webhook signatures.
35
+
36
+ Retries `429` and `5xx` twice with backoff (3-second timeout). Timeouts and network errors are never retried and
37
+ never mean allow. Documentation: <https://reqlo-web.vercel.app/docs/sdks/python>
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reqlo-sdk"
7
+ version = "0.1.0"
8
+ description = "Server-side Python client for ReQlo: authorization for AI agents."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ authors = [{ name = "ReQlo" }]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://reqlo.io"
23
+ Documentation = "https://reqlo-web.vercel.app/docs/sdks/python"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/reqlo"]
@@ -0,0 +1,10 @@
1
+ """ReQlo: authorization for AI agents. Server-side client; keep your project API key on your server."""
2
+ from . import webhooks
3
+ from ._client import AsyncReqlo, Reqlo
4
+ from ._errors import (ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloError, ReqloNetworkError,
5
+ ReqloResponseError, ReqloSignatureError, ReqloTimeoutError)
6
+ from ._version import VERSION
7
+
8
+ __version__ = VERSION
9
+ __all__ = ["Reqlo", "AsyncReqlo", "webhooks", "ReqloError", "ReqloConfigurationError", "ReqloAPIError", "ReqloTimeoutError",
10
+ "ReqloNetworkError", "ReqloResponseError", "ReqloDeniedError", "ReqloSignatureError", "__version__"]
@@ -0,0 +1,225 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ import re
5
+ import socket
6
+ import time
7
+ from email.utils import parsedate_to_datetime
8
+ from typing import Any, Dict, List, Optional
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.parse import urlencode, urlsplit
11
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
12
+
13
+ from ._errors import (ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloError, ReqloNetworkError,
14
+ ReqloResponseError, ReqloTimeoutError)
15
+ from ._validate import approval, approval_list, decision, delegation, delegation_list
16
+ from ._version import VERSION
17
+
18
+ # Only these codes are surfaced; anything else becomes None so response bodies never leak into logs.
19
+ _CODES = {"method_not_allowed", "parameter_invalid", "attribute_invalid", "action_unknown", "request_too_large",
20
+ "invalid_json", "invalid_request", "api_key_required", "api_key_invalid", "api_key_revoked", "scope_missing",
21
+ "authentication_required", "user_session_required", "resource_missing", "rate_limited", "internal_error",
22
+ "service_unavailable"}
23
+ _STATUSES = ("pending", "approved", "rejected", "expired", "consumed")
24
+ _APPROVAL_ID = re.compile(r"^APR_[A-Za-z0-9]{16}$")
25
+ _DELEGATION_ID = re.compile(r"^DEL_[A-Za-z0-9]{16}$")
26
+
27
+
28
+ class _NoRedirect(HTTPRedirectHandler):
29
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None: # never follow redirects with a credential
30
+ return None
31
+
32
+
33
+ def _base_url(value: str) -> str:
34
+ parts = urlsplit(value)
35
+ local = parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1")
36
+ if (parts.scheme != "https" and not local) or parts.username or parts.password or parts.query or parts.fragment \
37
+ or not re.match(r"^/v1/?$", parts.path or ""):
38
+ raise ReqloConfigurationError("base_url must be HTTPS with path /v1 and no credentials, query or fragment. "
39
+ "Loopback HTTP is allowed for development.")
40
+ return "%s://%s/v1" % (parts.scheme, parts.netloc)
41
+
42
+
43
+ def _retry_delay(headers: Any, attempt: int) -> Optional[float]:
44
+ delay = min(5.0, 0.25 * 2 ** attempt * (0.75 + random.random() * 0.5))
45
+ header = headers.get("Retry-After") if headers is not None else None
46
+ if header:
47
+ header = header.strip()
48
+ try:
49
+ delay = max(0.0, float(header)) if re.match(r"^\d+(\.\d+)?$", header) else max(0.0, parsedate_to_datetime(header).timestamp() - time.time())
50
+ except (TypeError, ValueError):
51
+ pass
52
+ # Surface the error rather than wait a long, server-directed time.
53
+ return delay if delay <= 10.0 else None
54
+
55
+
56
+ class _Approvals:
57
+ def __init__(self, client: "Reqlo") -> None:
58
+ self._client = client
59
+
60
+ def list(self, status: Optional[str] = None) -> Dict[str, Any]:
61
+ """Approvals in the key's environment, newest first: {"data": [...], "environment_id": ...}."""
62
+ if status is not None and status not in _STATUSES:
63
+ raise ReqloConfigurationError("status must be one of: %s." % ", ".join(_STATUSES))
64
+ return approval_list(self._client._request("GET", "/approvals" + ("?status=" + status if status else "")))
65
+
66
+ def retrieve(self, approval_id: str) -> Dict[str, Any]:
67
+ if not isinstance(approval_id, str) or not _APPROVAL_ID.match(approval_id):
68
+ raise ReqloConfigurationError("Use an approval ID returned by ReQlo.")
69
+ return approval(self._client._request("GET", "/approvals/" + approval_id))
70
+
71
+
72
+ class _Delegations:
73
+ def __init__(self, client: "Reqlo") -> None:
74
+ self._client = client
75
+
76
+ def create(self, agent_id: str, subject: Dict[str, str], actions: List[str], expires_at: str) -> Dict[str, Any]:
77
+ """Record a person's grant. Call it from your backend after they consent. Never retried automatically."""
78
+ if not isinstance(agent_id, str) or not isinstance(subject, dict) or not isinstance(actions, list) or not isinstance(expires_at, str):
79
+ raise ReqloConfigurationError("Delegations need agent_id, subject, actions and expires_at.")
80
+ body = {"agent_id": agent_id, "subject": subject, "actions": actions, "expires_at": expires_at}
81
+ return delegation(self._client._request("POST", "/delegations", body, retry=False))
82
+
83
+ def list(self, agent_id: Optional[str] = None, subject_id: Optional[str] = None) -> List[Dict[str, Any]]:
84
+ query = {k: v for k, v in (("agent_id", agent_id), ("subject_id", subject_id)) if v is not None}
85
+ return delegation_list(self._client._request("GET", "/delegations" + ("?" + urlencode(query) if query else "")))
86
+
87
+ def retrieve(self, delegation_id: str) -> Dict[str, Any]:
88
+ if not isinstance(delegation_id, str) or not _DELEGATION_ID.match(delegation_id):
89
+ raise ReqloConfigurationError("Use a delegation ID returned by ReQlo.")
90
+ return delegation(self._client._request("GET", "/delegations/" + delegation_id))
91
+
92
+ def revoke(self, delegation_id: str) -> Dict[str, Any]:
93
+ if not isinstance(delegation_id, str) or not _DELEGATION_ID.match(delegation_id):
94
+ raise ReqloConfigurationError("Use a delegation ID returned by ReQlo.")
95
+ return delegation(self._client._request("POST", "/delegations/%s/revoke" % delegation_id, {}))
96
+
97
+
98
+ class Reqlo:
99
+ """Server-side ReQlo client. Keep the project API key on your server; never ship it to a browser or a prompt."""
100
+
101
+ def __init__(self, api_key: str, base_url: str = "https://api.reqlo.io/v1", timeout: float = 3.0, max_retries: int = 2) -> None:
102
+ if not isinstance(api_key, str) or not re.match(r"^rq_\S+$", api_key):
103
+ raise ReqloConfigurationError("A secret project API key (rq_...) is required.")
104
+ if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or not 0 < timeout <= 120:
105
+ raise ReqloConfigurationError("timeout must be between 0 and 120 seconds.")
106
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or not 0 <= max_retries <= 5:
107
+ raise ReqloConfigurationError("max_retries must be between 0 and 5.")
108
+ self._api_key = api_key
109
+ self._base_url = _base_url(base_url)
110
+ self._timeout = float(timeout)
111
+ self._max_retries = max_retries
112
+ self._opener = build_opener(_NoRedirect())
113
+ self.approvals = _Approvals(self)
114
+ self.delegations = _Delegations(self)
115
+
116
+ def __repr__(self) -> str: # never print the key
117
+ return "Reqlo(base_url=%r)" % self._base_url
118
+
119
+ def authorize(self, *, agent: Dict[str, str], action: str, resource: Dict[str, Any], subject: Optional[Dict[str, str]] = None,
120
+ context: Optional[Dict[str, Any]] = None, delegation_id: Optional[str] = None, approval_id: Optional[str] = None) -> Dict[str, Any]:
121
+ """Returns the decision. Only act when decision["decision"] == "allow"."""
122
+ body: Dict[str, Any] = {"agent": agent, "action": action, "resource": resource}
123
+ for key, value in (("subject", subject), ("context", context), ("delegation_id", delegation_id), ("approval_id", approval_id)):
124
+ if value is not None:
125
+ body[key] = value
126
+ return decision(self._request("POST", "/authorize", body))
127
+
128
+ def enforce(self, **request: Any) -> Dict[str, Any]:
129
+ """Returns the decision only on allow. Raises ReqloDeniedError otherwise, and ReqloError on any failure."""
130
+ result = self.authorize(**request)
131
+ if result["decision"] != "allow":
132
+ raise ReqloDeniedError(result)
133
+ return result
134
+
135
+ def _request(self, method: str, path: str, body: Any = None, retry: bool = True) -> Any:
136
+ try:
137
+ data = None if body is None else json.dumps(body, allow_nan=False).encode("utf-8")
138
+ except (TypeError, ValueError):
139
+ raise ReqloConfigurationError("The request must be JSON serializable.") from None
140
+ headers = {"Authorization": "Bearer " + self._api_key, "Accept": "application/json", "User-Agent": "reqlo-python/" + VERSION}
141
+ if data is not None:
142
+ headers["Content-Type"] = "application/json"
143
+ attempt = 0
144
+ while True:
145
+ request = Request(self._base_url + path, data=data, method=method, headers=headers)
146
+ try:
147
+ with self._opener.open(request, timeout=self._timeout) as response:
148
+ raw = response.read()
149
+ try:
150
+ return json.loads(raw)
151
+ except ValueError:
152
+ raise ReqloResponseError() from None
153
+ except HTTPError as error:
154
+ status = error.code
155
+ try:
156
+ payload = json.loads(error.read())
157
+ except Exception:
158
+ payload = None
159
+ finally:
160
+ error.close()
161
+ err = payload.get("error") if isinstance(payload, dict) and isinstance(payload.get("error"), dict) else {}
162
+ code = err.get("code") if err.get("code") in _CODES else None
163
+ rid = err.get("request_id") or (error.headers.get("Reqlo-Request-Id") if error.headers else None)
164
+ rid = rid if isinstance(rid, str) and re.match(r"^[A-Za-z0-9_-]{1,128}$", rid) else None
165
+ delay = _retry_delay(error.headers, attempt) if retry and (status == 429 or 500 <= status <= 599) and attempt < self._max_retries else None
166
+ if delay is None:
167
+ raise ReqloAPIError(status, code, rid) from None
168
+ time.sleep(delay)
169
+ attempt += 1
170
+ except ReqloError:
171
+ raise
172
+ except (socket.timeout, TimeoutError):
173
+ raise ReqloTimeoutError() from None
174
+ except URLError as error:
175
+ if isinstance(error.reason, (socket.timeout, TimeoutError)):
176
+ raise ReqloTimeoutError() from None
177
+ raise ReqloNetworkError() from None
178
+ except OSError:
179
+ raise ReqloNetworkError() from None
180
+
181
+
182
+ class _AsyncApprovals:
183
+ def __init__(self, sync: _Approvals) -> None:
184
+ self._sync = sync
185
+
186
+ async def list(self, status: Optional[str] = None) -> Dict[str, Any]:
187
+ return await asyncio.to_thread(self._sync.list, status)
188
+
189
+ async def retrieve(self, approval_id: str) -> Dict[str, Any]:
190
+ return await asyncio.to_thread(self._sync.retrieve, approval_id)
191
+
192
+
193
+ class _AsyncDelegations:
194
+ def __init__(self, sync: _Delegations) -> None:
195
+ self._sync = sync
196
+
197
+ async def create(self, agent_id: str, subject: Dict[str, str], actions: List[str], expires_at: str) -> Dict[str, Any]:
198
+ return await asyncio.to_thread(self._sync.create, agent_id, subject, actions, expires_at)
199
+
200
+ async def list(self, agent_id: Optional[str] = None, subject_id: Optional[str] = None) -> List[Dict[str, Any]]:
201
+ return await asyncio.to_thread(self._sync.list, agent_id, subject_id)
202
+
203
+ async def retrieve(self, delegation_id: str) -> Dict[str, Any]:
204
+ return await asyncio.to_thread(self._sync.retrieve, delegation_id)
205
+
206
+ async def revoke(self, delegation_id: str) -> Dict[str, Any]:
207
+ return await asyncio.to_thread(self._sync.revoke, delegation_id)
208
+
209
+
210
+ class AsyncReqlo:
211
+ """The same client for asyncio (FastAPI, agent frameworks). Requests run in a worker thread."""
212
+
213
+ def __init__(self, api_key: str, base_url: str = "https://api.reqlo.io/v1", timeout: float = 3.0, max_retries: int = 2) -> None:
214
+ self._sync = Reqlo(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
215
+ self.approvals = _AsyncApprovals(self._sync.approvals)
216
+ self.delegations = _AsyncDelegations(self._sync.delegations)
217
+
218
+ def __repr__(self) -> str:
219
+ return "AsyncReqlo(base_url=%r)" % self._sync._base_url
220
+
221
+ async def authorize(self, **request: Any) -> Dict[str, Any]:
222
+ return await asyncio.to_thread(lambda: self._sync.authorize(**request))
223
+
224
+ async def enforce(self, **request: Any) -> Dict[str, Any]:
225
+ return await asyncio.to_thread(lambda: self._sync.enforce(**request))
@@ -0,0 +1,47 @@
1
+ """Every error means the protected action must stay stopped. None of them is ever an allow."""
2
+ from typing import Any, Dict, Optional
3
+
4
+
5
+ class ReqloError(Exception):
6
+ """Base class for every ReQlo error."""
7
+
8
+
9
+ class ReqloConfigurationError(ReqloError):
10
+ """Invalid client options or arguments. Fix your code or configuration."""
11
+
12
+
13
+ class ReqloAPIError(ReqloError):
14
+ """ReQlo returned an HTTP error. `code` is a stable error code when ReQlo sent a known one."""
15
+
16
+ def __init__(self, status: int, code: Optional[str], request_id: Optional[str]) -> None:
17
+ super().__init__("ReQlo returned HTTP %d. The protected action must remain stopped." % status)
18
+ self.status = status
19
+ self.code = code
20
+ self.request_id = request_id
21
+
22
+
23
+ class ReqloTimeoutError(ReqloError):
24
+ def __init__(self) -> None:
25
+ super().__init__("The ReQlo request timed out. The protected action must remain stopped.")
26
+
27
+
28
+ class ReqloNetworkError(ReqloError):
29
+ def __init__(self) -> None:
30
+ super().__init__("The ReQlo request could not be completed. The protected action must remain stopped.")
31
+
32
+
33
+ class ReqloResponseError(ReqloError):
34
+ def __init__(self) -> None:
35
+ super().__init__("ReQlo returned an invalid response. The protected action must remain stopped.")
36
+
37
+
38
+ class ReqloDeniedError(ReqloError):
39
+ """`enforce` got deny or approval_required. `decision` holds the full decision."""
40
+
41
+ def __init__(self, decision: Dict[str, Any]) -> None:
42
+ super().__init__("This action requires human approval." if decision.get("decision") == "approval_required" else "This action was denied.")
43
+ self.decision = decision
44
+
45
+
46
+ class ReqloSignatureError(ReqloError):
47
+ """A webhook's signature is missing, invalid or too old."""
@@ -0,0 +1,85 @@
1
+ """Checks every response shape, so a malformed or unexpected response is an error, never an allow."""
2
+ import re
3
+ from datetime import datetime
4
+ from typing import Any, Dict, List
5
+
6
+ from ._errors import ReqloResponseError
7
+
8
+ _STATUSES = ("pending", "approved", "rejected", "expired", "consumed")
9
+
10
+
11
+ def _text(value: Any) -> bool:
12
+ return isinstance(value, str) and len(value) > 0
13
+
14
+
15
+ def _nullable_text(value: Any) -> bool:
16
+ return value is None or isinstance(value, str)
17
+
18
+
19
+ def _date(value: Any) -> bool:
20
+ """An ISO 8601 timestamp with date and time, as ReQlo returns (with or without fractions and offset)."""
21
+ if not isinstance(value, str) or not re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
22
+ return False
23
+ try:
24
+ datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
25
+ return True
26
+ except ValueError:
27
+ return False
28
+
29
+
30
+ def _approval_id(value: Any) -> bool:
31
+ return isinstance(value, str) and re.match(r"^APR_[A-Za-z0-9]{16}$", value) is not None
32
+
33
+
34
+ def decision(value: Any) -> Dict[str, Any]:
35
+ if not isinstance(value, dict) or not _text(value.get("id")) or value.get("object") != "decision" or not _text(value.get("reason_code")) \
36
+ or not isinstance(value.get("matched_rules"), list) or not all(_text(r) for r in value["matched_rules"]) \
37
+ or not _nullable_text(value.get("policy_version")) or not _text(value.get("environment")) \
38
+ or not isinstance(value.get("livemode"), bool) or not _date(value.get("created_at")) or not _text(value.get("request_id")):
39
+ raise ReqloResponseError()
40
+ if value.get("decision") == "approval_required":
41
+ a = value.get("approval")
42
+ if value.get("allowed") is not False or not isinstance(a, dict) or not _approval_id(a.get("id")) or a.get("status") != "pending" or not _date(a.get("expires_at")):
43
+ raise ReqloResponseError()
44
+ elif value.get("decision") not in ("allow", "deny") or value.get("allowed") is not (value["decision"] == "allow") or value.get("approval") is not None:
45
+ raise ReqloResponseError()
46
+ return value
47
+
48
+
49
+ def approval(value: Any) -> Dict[str, Any]:
50
+ summary = value.get("summary") if isinstance(value, dict) else None
51
+ if not isinstance(value, dict) or not _approval_id(value.get("id")) or value.get("object") != "approval" or value.get("status") not in _STATUSES \
52
+ or not _nullable_text(value.get("decision_id")) or not isinstance(value.get("request_digest"), str) \
53
+ or not re.match(r"^sha256:[a-f0-9]{64}$", value["request_digest"]) \
54
+ or not isinstance(summary, dict) or not all(summary.get(k) is None or _text(summary.get(k)) for k in ("agent_id", "subject_id", "action", "resource")) \
55
+ or not _nullable_text(value.get("channel_id")) or not _date(value.get("expires_at")) or not _nullable_text(value.get("resolved_by")) \
56
+ or not (value.get("resolved_at") is None or _date(value.get("resolved_at"))) or not _nullable_text(value.get("note")) \
57
+ or not _date(value.get("created_at")):
58
+ raise ReqloResponseError()
59
+ return value
60
+
61
+
62
+ def approval_list(value: Any) -> Dict[str, Any]:
63
+ if not isinstance(value, dict) or not isinstance(value.get("data"), list) or not _text(value.get("environment_id")):
64
+ raise ReqloResponseError()
65
+ return {"data": [approval(item) for item in value["data"]], "environment_id": value["environment_id"]}
66
+
67
+
68
+ def delegation(value: Any) -> Dict[str, Any]:
69
+ subject = value.get("subject") if isinstance(value, dict) else None
70
+ verification = value.get("verification") if isinstance(value, dict) else None
71
+ if not isinstance(value, dict) or not isinstance(value.get("id"), str) or not re.match(r"^DEL_[A-Za-z0-9]{16}$", value["id"]) \
72
+ or value.get("object") != "delegation" or not _text(value.get("agent_id")) \
73
+ or not isinstance(subject, dict) or not _text(subject.get("id")) or not _text(subject.get("kind")) \
74
+ or not isinstance(value.get("actions"), list) or not all(_text(a) for a in value["actions"]) \
75
+ or not isinstance(verification, dict) or verification.get("method") != "server_session" \
76
+ or value.get("status") not in ("active", "revoked", "expired") or not _date(value.get("expires_at")) \
77
+ or not _date(value.get("created_at")) or not (value.get("revoked_at") is None or _date(value.get("revoked_at"))):
78
+ raise ReqloResponseError()
79
+ return value
80
+
81
+
82
+ def delegation_list(value: Any) -> List[Dict[str, Any]]:
83
+ if not isinstance(value, dict) or not isinstance(value.get("data"), list):
84
+ raise ReqloResponseError()
85
+ return [delegation(item) for item in value["data"]]
@@ -0,0 +1 @@
1
+ VERSION = "0.1.0"
File without changes
@@ -0,0 +1,39 @@
1
+ """Verify ReQlo webhook signatures. An event never authorizes an action; it only tells you to look."""
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import re
6
+ import time
7
+ from typing import Any, Dict, Optional, Union
8
+
9
+ from ._errors import ReqloSignatureError
10
+
11
+ TOLERANCE_SECONDS = 300
12
+ _HEADER = re.compile(r"^t=(\d{1,12}),v1=([a-f0-9]{64})$")
13
+ _SECRET = re.compile(r"^whsec_[a-f0-9]{64}$")
14
+
15
+
16
+ def verify(payload: Union[bytes, str], header: Optional[str], secret: str, tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> bool:
17
+ """True only if `header` (Reqlo-Signature) signs the exact raw body with `secret`, within the tolerance."""
18
+ match = _HEADER.match(header or "")
19
+ if not match or not isinstance(secret, str) or not _SECRET.match(secret):
20
+ return False
21
+ current = int(time.time()) if now is None else now
22
+ if abs(current - int(match.group(1))) > tolerance:
23
+ return False
24
+ body = payload.encode("utf-8") if isinstance(payload, str) else payload
25
+ expected = hmac.new(secret.encode("utf-8"), match.group(1).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
26
+ return hmac.compare_digest(expected, match.group(2))
27
+
28
+
29
+ def construct_event(payload: Union[bytes, str], header: Optional[str], secret: str, tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> Dict[str, Any]:
30
+ """Verifies the signature and returns the parsed event. Raises ReqloSignatureError if it isn't valid."""
31
+ if not verify(payload, header, secret, tolerance, now):
32
+ raise ReqloSignatureError("The webhook signature is missing, invalid or too old.")
33
+ try:
34
+ event = json.loads(payload)
35
+ except ValueError:
36
+ raise ReqloSignatureError("The webhook body isn't valid JSON.") from None
37
+ if not isinstance(event, dict) or event.get("object") != "event" or not isinstance(event.get("type"), str):
38
+ raise ReqloSignatureError("The webhook body isn't a ReQlo event.")
39
+ return event
@@ -0,0 +1,199 @@
1
+ import asyncio
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import threading
6
+ import time
7
+ import unittest
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+
10
+ import reqlo
11
+ from reqlo import (AsyncReqlo, Reqlo, ReqloAPIError, ReqloConfigurationError, ReqloDeniedError, ReqloNetworkError,
12
+ ReqloResponseError, ReqloSignatureError, ReqloTimeoutError, webhooks)
13
+
14
+ NOW = "2026-11-09T10:42:07.123Z"
15
+ APPROVAL = {"id": "APR_7Hq2mK9xR4vTq8Lp", "object": "approval", "status": "pending", "decision_id": None,
16
+ "request_digest": "sha256:" + "a" * 64, "summary": {"agent_id": "agt_x", "action": "documents.read"},
17
+ "channel_id": None, "expires_at": NOW, "resolved_by": None, "resolved_at": None, "note": None, "created_at": NOW}
18
+ DELEGATION = {"id": "DEL_5Hq2mK9xR4vTq8Lp", "object": "delegation", "agent_id": "agt_x", "subject": {"id": "usr_94", "kind": "user"},
19
+ "actions": ["calendar.schedule"], "verification": {"method": "server_session"}, "status": "active",
20
+ "expires_at": NOW, "created_at": NOW, "revoked_at": None}
21
+
22
+
23
+ def decision(kind):
24
+ base = {"id": "dec_1", "object": "decision", "decision": kind, "allowed": kind == "allow", "reason_code": "policy_allowed" if kind == "allow" else "policy_denied",
25
+ "matched_rules": [], "policy_version": "2026-11-09.1", "approval": None, "environment": "sandbox", "livemode": False, "created_at": NOW, "request_id": "r1"}
26
+ if kind == "approval_required":
27
+ base.update(reason_code="approval_required", approval={"id": APPROVAL["id"], "status": "pending", "expires_at": NOW})
28
+ return base
29
+
30
+
31
+ class Server:
32
+ """Queue of (status, body, headers) responses; records every request."""
33
+
34
+ def __init__(self):
35
+ self.queue, self.requests = [], []
36
+ server = self
37
+
38
+ class Handler(BaseHTTPRequestHandler):
39
+ def log_message(self, *args):
40
+ pass
41
+
42
+ def handle_any(self):
43
+ length = int(self.headers.get("Content-Length") or 0)
44
+ body = json.loads(self.rfile.read(length)) if length else None
45
+ server.requests.append((self.command, self.path, dict(self.headers), body))
46
+ status, payload, headers = server.queue.pop(0) if server.queue else (500, {}, {})
47
+ if status == "sleep":
48
+ time.sleep(payload)
49
+ status, payload, headers = 200, decision("allow"), {}
50
+ raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
51
+ self.send_response(status)
52
+ for key, value in headers.items():
53
+ self.send_header(key, value)
54
+ self.send_header("Content-Type", "application/json")
55
+ self.send_header("Content-Length", str(len(raw)))
56
+ self.end_headers()
57
+ self.wfile.write(raw)
58
+
59
+ do_GET = do_POST = handle_any
60
+
61
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
62
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
63
+ self.url = "http://127.0.0.1:%d/v1" % self.httpd.server_port
64
+
65
+ def close(self):
66
+ self.httpd.shutdown()
67
+ self.httpd.server_close()
68
+
69
+
70
+ REQUEST = dict(agent={"id": "agt_x"}, action="documents.read", resource={"type": "document", "id": "doc_1"})
71
+
72
+
73
+ class ClientTest(unittest.TestCase):
74
+ def setUp(self):
75
+ self.server = Server()
76
+ self.client = Reqlo("rq_test_secret", base_url=self.server.url, max_retries=2)
77
+
78
+ def tearDown(self):
79
+ self.server.close()
80
+
81
+ def test_authorize_sends_the_request_and_returns_the_decision(self):
82
+ self.server.queue.append((200, decision("allow"), {}))
83
+ result = self.client.authorize(subject={"id": "usr_1", "kind": "user"}, context={"level": 2}, **REQUEST)
84
+ self.assertEqual(result["decision"], "allow")
85
+ method, path, headers, body = self.server.requests[0]
86
+ self.assertEqual((method, path), ("POST", "/v1/authorize"))
87
+ self.assertEqual(headers["Authorization"], "Bearer rq_test_secret")
88
+ self.assertEqual(body, {**REQUEST, "subject": {"id": "usr_1", "kind": "user"}, "context": {"level": 2}})
89
+
90
+ def test_enforce_returns_on_allow_and_raises_on_deny_or_approval(self):
91
+ self.server.queue += [(200, decision("allow"), {}), (200, decision("deny"), {}), (200, decision("approval_required"), {})]
92
+ self.assertEqual(self.client.enforce(**REQUEST)["decision"], "allow")
93
+ with self.assertRaises(ReqloDeniedError) as denied:
94
+ self.client.enforce(**REQUEST)
95
+ self.assertEqual(denied.exception.decision["reason_code"], "policy_denied")
96
+ with self.assertRaises(ReqloDeniedError) as held:
97
+ self.client.enforce(**REQUEST)
98
+ self.assertEqual(held.exception.decision["approval"]["id"], APPROVAL["id"])
99
+
100
+ def test_malformed_or_contradictory_responses_are_errors_not_allows(self):
101
+ bad = [{"decision": "allow"}, {**decision("allow"), "allowed": False}, {**decision("deny"), "allowed": True},
102
+ {**decision("allow"), "decision": "maybe"}, b"not json"]
103
+ for body in bad:
104
+ self.server.queue.append((200, body, {}))
105
+ with self.assertRaises(ReqloResponseError):
106
+ self.client.enforce(**REQUEST)
107
+
108
+ def test_retries_429_and_5xx_then_raises_with_code_and_request_id(self):
109
+ self.server.queue += [(503, {"error": {"code": "service_unavailable", "request_id": "r9"}}, {"Retry-After": "0"})] * 3
110
+ with self.assertRaises(ReqloAPIError) as error:
111
+ self.client.authorize(**REQUEST)
112
+ self.assertEqual((error.exception.status, error.exception.code, error.exception.request_id), (503, "service_unavailable", "r9"))
113
+ self.assertEqual(len(self.server.requests), 3)
114
+
115
+ def test_4xx_is_not_retried_and_unknown_codes_are_hidden(self):
116
+ self.server.queue.append((422, {"error": {"code": "something_secret", "message": "secret detail"}}, {}))
117
+ with self.assertRaises(ReqloAPIError) as error:
118
+ self.client.authorize(**REQUEST)
119
+ self.assertIsNone(error.exception.code)
120
+ self.assertNotIn("secret", str(error.exception))
121
+ self.assertEqual(len(self.server.requests), 1)
122
+
123
+ def test_timeouts_and_network_failures_raise_and_are_not_retried(self):
124
+ slow = Reqlo("rq_test_secret", base_url=self.server.url, timeout=0.2)
125
+ self.server.queue.append(("sleep", 1, {}))
126
+ with self.assertRaises(ReqloTimeoutError):
127
+ slow.enforce(**REQUEST)
128
+ self.assertEqual(len(self.server.requests), 1)
129
+ with self.assertRaises(ReqloNetworkError):
130
+ Reqlo("rq_test_secret", base_url="http://127.0.0.1:9/v1", max_retries=0).enforce(**REQUEST)
131
+
132
+ def test_delegation_create_is_never_retried(self):
133
+ self.server.queue.append((503, {"error": {"code": "service_unavailable"}}, {"Retry-After": "0"}))
134
+ with self.assertRaises(ReqloAPIError):
135
+ self.client.delegations.create("agt_x", {"id": "usr_94", "kind": "user"}, ["calendar.schedule"], NOW)
136
+ self.assertEqual(len(self.server.requests), 1)
137
+
138
+ def test_approvals_and_delegations(self):
139
+ self.server.queue += [(200, {"data": [APPROVAL], "environment_id": "env"}, {}), (200, APPROVAL, {}),
140
+ (201, DELEGATION, {}), (200, {"data": [DELEGATION]}, {}), (200, DELEGATION, {}), (200, {**DELEGATION, "status": "revoked", "revoked_at": NOW}, {})]
141
+ self.assertEqual(self.client.approvals.list(status="pending")["data"][0]["id"], APPROVAL["id"])
142
+ self.assertEqual(self.client.approvals.retrieve(APPROVAL["id"])["status"], "pending")
143
+ self.assertEqual(self.client.delegations.create("agt_x", {"id": "usr_94", "kind": "user"}, ["calendar.schedule"], NOW)["status"], "active")
144
+ self.assertEqual(len(self.client.delegations.list(subject_id="usr_94")), 1)
145
+ self.assertEqual(self.client.delegations.retrieve(DELEGATION["id"])["id"], DELEGATION["id"])
146
+ self.assertEqual(self.client.delegations.revoke(DELEGATION["id"])["status"], "revoked")
147
+ paths = [(m, p) for m, p, _, _ in self.server.requests]
148
+ self.assertEqual(paths, [("GET", "/v1/approvals?status=pending"), ("GET", "/v1/approvals/" + APPROVAL["id"]), ("POST", "/v1/delegations"),
149
+ ("GET", "/v1/delegations?subject_id=usr_94"), ("GET", "/v1/delegations/" + DELEGATION["id"]), ("POST", "/v1/delegations/%s/revoke" % DELEGATION["id"])])
150
+
151
+ def test_async_client(self):
152
+ self.server.queue += [(200, decision("allow"), {}), (200, decision("deny"), {})]
153
+ client = AsyncReqlo("rq_test_secret", base_url=self.server.url)
154
+ self.assertEqual(asyncio.run(client.enforce(**REQUEST))["decision"], "allow")
155
+ with self.assertRaises(ReqloDeniedError):
156
+ asyncio.run(client.enforce(**REQUEST))
157
+
158
+ def test_configuration_is_checked_and_the_key_never_printed(self):
159
+ for kwargs in [dict(api_key="sk_nope"), dict(api_key="rq_test_x", base_url="http://api.reqlo.io/v1"), dict(api_key="rq_test_x", base_url="https://api.reqlo.io/v2"),
160
+ dict(api_key="rq_test_x", timeout=0), dict(api_key="rq_test_x", max_retries=9)]:
161
+ with self.assertRaises(ReqloConfigurationError):
162
+ Reqlo(**kwargs)
163
+ self.assertNotIn("rq_test_secret", repr(self.client))
164
+ with self.assertRaises(ReqloConfigurationError):
165
+ self.client.approvals.retrieve("not-an-id")
166
+ with self.assertRaises(ReqloConfigurationError):
167
+ self.client.approvals.list(status="maybe")
168
+
169
+
170
+ class WebhookTest(unittest.TestCase):
171
+ secret = "whsec_" + "b" * 64
172
+ body = json.dumps({"id": "EVT_1", "object": "event", "type": "approval.requested", "data": {"object": {"id": APPROVAL["id"]}}})
173
+
174
+ def sign(self, t, body=None, secret=None):
175
+ digest = hmac.new((secret or self.secret).encode(), ("%d." % t).encode() + (body or self.body).encode(), hashlib.sha256).hexdigest()
176
+ return "t=%d,v1=%s" % (t, digest)
177
+
178
+ def test_verify(self):
179
+ now = 1_800_000_000
180
+ self.assertTrue(webhooks.verify(self.body, self.sign(now), self.secret, now=now))
181
+ self.assertTrue(webhooks.verify(self.body.encode(), self.sign(now - 299), self.secret, now=now))
182
+ self.assertFalse(webhooks.verify(self.body, self.sign(now - 301), self.secret, now=now))
183
+ self.assertFalse(webhooks.verify(self.body + " ", self.sign(now), self.secret, now=now))
184
+ self.assertFalse(webhooks.verify(self.body, self.sign(now, secret="whsec_" + "c" * 64), self.secret, now=now))
185
+ self.assertFalse(webhooks.verify(self.body, None, self.secret, now=now))
186
+ self.assertFalse(webhooks.verify(self.body, self.sign(now), "not-a-secret", now=now))
187
+
188
+ def test_construct_event(self):
189
+ now = 1_800_000_000
190
+ self.assertEqual(webhooks.construct_event(self.body, self.sign(now), self.secret, now=now)["type"], "approval.requested")
191
+ with self.assertRaises(ReqloSignatureError):
192
+ webhooks.construct_event(self.body, "t=1,v1=" + "0" * 64, self.secret, now=now)
193
+
194
+ def test_version(self):
195
+ self.assertEqual(reqlo.__version__, "0.1.0")
196
+
197
+
198
+ if __name__ == "__main__":
199
+ unittest.main()