ledgix-python 0.1.0__tar.gz → 0.1.2__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.
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/PKG-INFO +4 -2
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/README.md +3 -1
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/pyproject.toml +1 -1
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/__init__.py +3 -1
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/client.py +142 -44
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/config.py +18 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/enforce.py +15 -9
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/exceptions.py +9 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/models.py +9 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/tests/conftest.py +27 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/tests/test_client.py +118 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/.gitignore +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/demo.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/requirements.txt +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/adapters/__init__.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/adapters/crewai.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/adapters/langchain.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/src/ledgix_python/adapters/llamaindex.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/tests/__init__.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/tests/test_adapters.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/tests/test_enforce.py +0 -0
- {ledgix_python-0.1.0 → ledgix_python-0.1.2}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ledgix-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault
|
|
5
5
|
Project-URL: Homepage, https://github.com/ledgix-dev/python-sdk
|
|
6
6
|
Project-URL: Documentation, https://docs.ledgix.dev
|
|
@@ -75,6 +75,8 @@ Set environment variables (prefix: `LEDGIX_`):
|
|
|
75
75
|
| `LEDGIX_VAULT_API_KEY` | `""` | API key for Vault auth |
|
|
76
76
|
| `LEDGIX_VAULT_TIMEOUT` | `30.0` | Request timeout (seconds) |
|
|
77
77
|
| `LEDGIX_VERIFY_JWT` | `true` | Verify A-JWT signatures |
|
|
78
|
+
| `LEDGIX_JWT_ISSUER` | `alcv-vault` | Expected A-JWT issuer |
|
|
79
|
+
| `LEDGIX_JWT_AUDIENCE` | `ledgix-sdk` | Expected A-JWT audience |
|
|
78
80
|
| `LEDGIX_AGENT_ID` | `default-agent` | Agent identifier |
|
|
79
81
|
|
|
80
82
|
Or pass a `VaultConfig` directly:
|
|
@@ -175,4 +177,4 @@ python demo.py
|
|
|
175
177
|
|
|
176
178
|
## License
|
|
177
179
|
|
|
178
|
-
MIT
|
|
180
|
+
MIT
|
|
@@ -35,6 +35,8 @@ Set environment variables (prefix: `LEDGIX_`):
|
|
|
35
35
|
| `LEDGIX_VAULT_API_KEY` | `""` | API key for Vault auth |
|
|
36
36
|
| `LEDGIX_VAULT_TIMEOUT` | `30.0` | Request timeout (seconds) |
|
|
37
37
|
| `LEDGIX_VERIFY_JWT` | `true` | Verify A-JWT signatures |
|
|
38
|
+
| `LEDGIX_JWT_ISSUER` | `alcv-vault` | Expected A-JWT issuer |
|
|
39
|
+
| `LEDGIX_JWT_AUDIENCE` | `ledgix-sdk` | Expected A-JWT audience |
|
|
38
40
|
| `LEDGIX_AGENT_ID` | `default-agent` | Agent identifier |
|
|
39
41
|
|
|
40
42
|
Or pass a `VaultConfig` directly:
|
|
@@ -135,4 +137,4 @@ python demo.py
|
|
|
135
137
|
|
|
136
138
|
## License
|
|
137
139
|
|
|
138
|
-
MIT
|
|
140
|
+
MIT
|
|
@@ -18,6 +18,7 @@ from .config import VaultConfig
|
|
|
18
18
|
from .enforce import VaultContext, vault_enforce
|
|
19
19
|
from .exceptions import (
|
|
20
20
|
ClearanceDeniedError,
|
|
21
|
+
ManualReviewTimeoutError,
|
|
21
22
|
PolicyRegistrationError,
|
|
22
23
|
LedgixError,
|
|
23
24
|
TokenVerificationError,
|
|
@@ -30,7 +31,7 @@ from .models import (
|
|
|
30
31
|
PolicyRegistrationResponse,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
|
-
__version__ = "0.1.
|
|
34
|
+
__version__ = "0.1.1"
|
|
34
35
|
|
|
35
36
|
__all__ = [
|
|
36
37
|
# Core
|
|
@@ -47,6 +48,7 @@ __all__ = [
|
|
|
47
48
|
# Exceptions
|
|
48
49
|
"LedgixError",
|
|
49
50
|
"ClearanceDeniedError",
|
|
51
|
+
"ManualReviewTimeoutError",
|
|
50
52
|
"VaultConnectionError",
|
|
51
53
|
"TokenVerificationError",
|
|
52
54
|
"PolicyRegistrationError",
|
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
7
10
|
from typing import Any
|
|
8
11
|
|
|
9
12
|
import httpx
|
|
10
13
|
import jwt
|
|
11
14
|
|
|
15
|
+
_RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
|
|
16
|
+
|
|
12
17
|
from .config import VaultConfig
|
|
13
18
|
from .exceptions import (
|
|
14
19
|
ClearanceDeniedError,
|
|
20
|
+
ManualReviewTimeoutError,
|
|
15
21
|
PolicyRegistrationError,
|
|
16
22
|
TokenVerificationError,
|
|
17
23
|
VaultConnectionError,
|
|
@@ -72,6 +78,66 @@ class LedgixClient:
|
|
|
72
78
|
)
|
|
73
79
|
return self._async_client
|
|
74
80
|
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Retry helpers
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _backoff_delay(self, attempt: int) -> float:
|
|
86
|
+
"""Exponential backoff with full jitter, capped at 30 seconds."""
|
|
87
|
+
delay = min(30.0, self.config.retry_base_delay * (2 ** attempt))
|
|
88
|
+
return random.uniform(0.0, delay)
|
|
89
|
+
|
|
90
|
+
def _sync_retry(self, fn: Callable[[], httpx.Response]) -> httpx.Response:
|
|
91
|
+
"""Execute an HTTP callable with retry and exponential backoff.
|
|
92
|
+
|
|
93
|
+
Retries on ``httpx.TransportError`` (network errors, timeouts) and on
|
|
94
|
+
retryable HTTP status codes (429, 5xx). Raises ``VaultConnectionError``
|
|
95
|
+
after all attempts are exhausted.
|
|
96
|
+
"""
|
|
97
|
+
last_exc: httpx.TransportError | None = None
|
|
98
|
+
response: httpx.Response | None = None
|
|
99
|
+
for attempt in range(self.config.max_retries + 1):
|
|
100
|
+
try:
|
|
101
|
+
response = fn()
|
|
102
|
+
except httpx.TransportError as exc:
|
|
103
|
+
last_exc = exc
|
|
104
|
+
if attempt < self.config.max_retries:
|
|
105
|
+
time.sleep(self._backoff_delay(attempt))
|
|
106
|
+
continue
|
|
107
|
+
raise VaultConnectionError(str(exc)) from exc
|
|
108
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < self.config.max_retries:
|
|
109
|
+
time.sleep(self._backoff_delay(attempt))
|
|
110
|
+
continue
|
|
111
|
+
return response
|
|
112
|
+
if last_exc is not None:
|
|
113
|
+
raise VaultConnectionError(str(last_exc)) from last_exc
|
|
114
|
+
assert response is not None
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
async def _async_retry(self, fn: Callable[[], Awaitable[httpx.Response]]) -> httpx.Response:
|
|
118
|
+
"""Async variant of ``_sync_retry``."""
|
|
119
|
+
import asyncio
|
|
120
|
+
|
|
121
|
+
last_exc: httpx.TransportError | None = None
|
|
122
|
+
response: httpx.Response | None = None
|
|
123
|
+
for attempt in range(self.config.max_retries + 1):
|
|
124
|
+
try:
|
|
125
|
+
response = await fn()
|
|
126
|
+
except httpx.TransportError as exc:
|
|
127
|
+
last_exc = exc
|
|
128
|
+
if attempt < self.config.max_retries:
|
|
129
|
+
await asyncio.sleep(self._backoff_delay(attempt))
|
|
130
|
+
continue
|
|
131
|
+
raise VaultConnectionError(str(exc)) from exc
|
|
132
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < self.config.max_retries:
|
|
133
|
+
await asyncio.sleep(self._backoff_delay(attempt))
|
|
134
|
+
continue
|
|
135
|
+
return response
|
|
136
|
+
if last_exc is not None:
|
|
137
|
+
raise VaultConnectionError(str(last_exc)) from last_exc
|
|
138
|
+
assert response is not None
|
|
139
|
+
return response
|
|
140
|
+
|
|
75
141
|
# ------------------------------------------------------------------
|
|
76
142
|
# Clearance — sync
|
|
77
143
|
# ------------------------------------------------------------------
|
|
@@ -84,19 +150,20 @@ class LedgixClient:
|
|
|
84
150
|
VaultConnectionError: If the Vault is unreachable.
|
|
85
151
|
"""
|
|
86
152
|
try:
|
|
87
|
-
response = self.
|
|
88
|
-
|
|
89
|
-
|
|
153
|
+
response = self._sync_retry(
|
|
154
|
+
lambda: self._get_sync_client().post(
|
|
155
|
+
"/request-clearance",
|
|
156
|
+
content=request.model_dump_json(),
|
|
157
|
+
)
|
|
90
158
|
)
|
|
91
159
|
response.raise_for_status()
|
|
92
|
-
except httpx.ConnectError as exc:
|
|
93
|
-
raise VaultConnectionError(str(exc)) from exc
|
|
94
160
|
except httpx.HTTPStatusError as exc:
|
|
95
161
|
raise VaultConnectionError(
|
|
96
162
|
f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
|
|
97
163
|
) from exc
|
|
98
164
|
|
|
99
165
|
clearance = ClearanceResponse.model_validate(response.json())
|
|
166
|
+
clearance = self._resolve_pending_clearance(clearance)
|
|
100
167
|
|
|
101
168
|
if not clearance.approved:
|
|
102
169
|
raise ClearanceDeniedError(
|
|
@@ -121,19 +188,20 @@ class LedgixClient:
|
|
|
121
188
|
VaultConnectionError: If the Vault is unreachable.
|
|
122
189
|
"""
|
|
123
190
|
try:
|
|
124
|
-
response = await self.
|
|
125
|
-
|
|
126
|
-
|
|
191
|
+
response = await self._async_retry(
|
|
192
|
+
lambda: self._get_async_client().post(
|
|
193
|
+
"/request-clearance",
|
|
194
|
+
content=request.model_dump_json(),
|
|
195
|
+
)
|
|
127
196
|
)
|
|
128
197
|
response.raise_for_status()
|
|
129
|
-
except httpx.ConnectError as exc:
|
|
130
|
-
raise VaultConnectionError(str(exc)) from exc
|
|
131
198
|
except httpx.HTTPStatusError as exc:
|
|
132
199
|
raise VaultConnectionError(
|
|
133
200
|
f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
|
|
134
201
|
) from exc
|
|
135
202
|
|
|
136
203
|
clearance = ClearanceResponse.model_validate(response.json())
|
|
204
|
+
clearance = await self._aresolve_pending_clearance(clearance)
|
|
137
205
|
|
|
138
206
|
if not clearance.approved:
|
|
139
207
|
raise ClearanceDeniedError(
|
|
@@ -153,13 +221,13 @@ class LedgixClient:
|
|
|
153
221
|
def register_policy(self, policy: PolicyRegistration) -> PolicyRegistrationResponse:
|
|
154
222
|
"""Register a policy with the Vault (sync)."""
|
|
155
223
|
try:
|
|
156
|
-
response = self.
|
|
157
|
-
|
|
158
|
-
|
|
224
|
+
response = self._sync_retry(
|
|
225
|
+
lambda: self._get_sync_client().post(
|
|
226
|
+
"/register-policy",
|
|
227
|
+
content=policy.model_dump_json(),
|
|
228
|
+
)
|
|
159
229
|
)
|
|
160
230
|
response.raise_for_status()
|
|
161
|
-
except httpx.ConnectError as exc:
|
|
162
|
-
raise VaultConnectionError(str(exc)) from exc
|
|
163
231
|
except httpx.HTTPStatusError as exc:
|
|
164
232
|
raise PolicyRegistrationError(
|
|
165
233
|
f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
|
|
@@ -170,13 +238,13 @@ class LedgixClient:
|
|
|
170
238
|
async def aregister_policy(self, policy: PolicyRegistration) -> PolicyRegistrationResponse:
|
|
171
239
|
"""Register a policy with the Vault (async)."""
|
|
172
240
|
try:
|
|
173
|
-
response = await self.
|
|
174
|
-
|
|
175
|
-
|
|
241
|
+
response = await self._async_retry(
|
|
242
|
+
lambda: self._get_async_client().post(
|
|
243
|
+
"/register-policy",
|
|
244
|
+
content=policy.model_dump_json(),
|
|
245
|
+
)
|
|
176
246
|
)
|
|
177
247
|
response.raise_for_status()
|
|
178
|
-
except httpx.ConnectError as exc:
|
|
179
|
-
raise VaultConnectionError(str(exc)) from exc
|
|
180
248
|
except httpx.HTTPStatusError as exc:
|
|
181
249
|
raise PolicyRegistrationError(
|
|
182
250
|
f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
|
|
@@ -188,13 +256,43 @@ class LedgixClient:
|
|
|
188
256
|
# JWKS + A-JWT verification
|
|
189
257
|
# ------------------------------------------------------------------
|
|
190
258
|
|
|
259
|
+
def _resolve_pending_clearance(self, clearance: ClearanceResponse) -> ClearanceResponse:
|
|
260
|
+
if clearance.status not in {"processing", "pending_review"}:
|
|
261
|
+
return clearance
|
|
262
|
+
|
|
263
|
+
deadline = time.monotonic() + self.config.review_timeout
|
|
264
|
+
while time.monotonic() < deadline:
|
|
265
|
+
time.sleep(self.config.review_poll_interval)
|
|
266
|
+
response = self._get_sync_client().get(f"/clearance-status/{clearance.request_id}")
|
|
267
|
+
response.raise_for_status()
|
|
268
|
+
clearance = ClearanceResponse.model_validate(response.json())
|
|
269
|
+
if clearance.status not in {"processing", "pending_review"}:
|
|
270
|
+
return clearance
|
|
271
|
+
raise ManualReviewTimeoutError(clearance.request_id)
|
|
272
|
+
|
|
273
|
+
async def _aresolve_pending_clearance(self, clearance: ClearanceResponse) -> ClearanceResponse:
|
|
274
|
+
if clearance.status not in {"processing", "pending_review"}:
|
|
275
|
+
return clearance
|
|
276
|
+
|
|
277
|
+
import asyncio
|
|
278
|
+
|
|
279
|
+
deadline = time.monotonic() + self.config.review_timeout
|
|
280
|
+
while time.monotonic() < deadline:
|
|
281
|
+
await asyncio.sleep(self.config.review_poll_interval)
|
|
282
|
+
response = await self._get_async_client().get(f"/clearance-status/{clearance.request_id}")
|
|
283
|
+
response.raise_for_status()
|
|
284
|
+
clearance = ClearanceResponse.model_validate(response.json())
|
|
285
|
+
if clearance.status not in {"processing", "pending_review"}:
|
|
286
|
+
return clearance
|
|
287
|
+
raise ManualReviewTimeoutError(clearance.request_id)
|
|
288
|
+
|
|
191
289
|
def fetch_jwks(self) -> dict[str, Any]:
|
|
192
290
|
"""Fetch the Vault's JWKS (JSON Web Key Set) for token verification (sync)."""
|
|
193
291
|
try:
|
|
194
|
-
response = self.
|
|
292
|
+
response = self._sync_retry(
|
|
293
|
+
lambda: self._get_sync_client().get("/.well-known/jwks.json")
|
|
294
|
+
)
|
|
195
295
|
response.raise_for_status()
|
|
196
|
-
except httpx.ConnectError as exc:
|
|
197
|
-
raise VaultConnectionError(str(exc)) from exc
|
|
198
296
|
except httpx.HTTPStatusError as exc:
|
|
199
297
|
raise VaultConnectionError(
|
|
200
298
|
f"Failed to fetch JWKS: HTTP {exc.response.status_code}"
|
|
@@ -206,10 +304,10 @@ class LedgixClient:
|
|
|
206
304
|
async def afetch_jwks(self) -> dict[str, Any]:
|
|
207
305
|
"""Fetch the Vault's JWKS for token verification (async)."""
|
|
208
306
|
try:
|
|
209
|
-
response = await self.
|
|
307
|
+
response = await self._async_retry(
|
|
308
|
+
lambda: self._get_async_client().get("/.well-known/jwks.json")
|
|
309
|
+
)
|
|
210
310
|
response.raise_for_status()
|
|
211
|
-
except httpx.ConnectError as exc:
|
|
212
|
-
raise VaultConnectionError(str(exc)) from exc
|
|
213
311
|
except httpx.HTTPStatusError as exc:
|
|
214
312
|
raise VaultConnectionError(
|
|
215
313
|
f"Failed to fetch JWKS: HTTP {exc.response.status_code}"
|
|
@@ -227,37 +325,31 @@ class LedgixClient:
|
|
|
227
325
|
TokenVerificationError: If the token is invalid, expired, or
|
|
228
326
|
the JWKS cannot be fetched.
|
|
229
327
|
"""
|
|
230
|
-
|
|
328
|
+
if self._jwks_cache is None:
|
|
329
|
+
self.fetch_jwks()
|
|
330
|
+
return self._decode_token(token)
|
|
231
331
|
|
|
232
332
|
async def averify_token(self, token: str) -> dict[str, Any]:
|
|
233
|
-
"""Verify an A-JWT using the Vault's public key (async).
|
|
234
|
-
return self._verify_token_internal(token, sync=False)
|
|
235
|
-
|
|
236
|
-
def _verify_token_internal(self, token: str, sync: bool = True) -> dict[str, Any]:
|
|
237
|
-
"""Shared verification logic.
|
|
333
|
+
"""Verify an A-JWT using the Vault's public key (async).
|
|
238
334
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
335
|
+
Raises:
|
|
336
|
+
TokenVerificationError: If the token is invalid, expired, or
|
|
337
|
+
the JWKS cannot be fetched.
|
|
242
338
|
"""
|
|
243
339
|
if self._jwks_cache is None:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
else:
|
|
247
|
-
# In async context, caller must have pre-fetched JWKS.
|
|
248
|
-
# Fall back to sync fetch if cache is empty.
|
|
249
|
-
self.fetch_jwks()
|
|
340
|
+
await self.afetch_jwks()
|
|
341
|
+
return self._decode_token(token)
|
|
250
342
|
|
|
343
|
+
def _decode_token(self, token: str) -> dict[str, Any]:
|
|
344
|
+
"""Verify an A-JWT against the cached JWKS. JWKS must already be populated."""
|
|
251
345
|
if not self._jwks_cache:
|
|
252
346
|
raise TokenVerificationError("No JWKS available from Vault")
|
|
253
347
|
|
|
254
348
|
try:
|
|
255
|
-
# Extract the first key from the JWKS
|
|
256
349
|
jwks = self._jwks_cache
|
|
257
350
|
if "keys" not in jwks or not jwks["keys"]:
|
|
258
351
|
raise TokenVerificationError("JWKS contains no keys")
|
|
259
352
|
|
|
260
|
-
# Build a PyJWT key from the JWK
|
|
261
353
|
key_data = jwks["keys"][0]
|
|
262
354
|
public_key = jwt.algorithms.OKPAlgorithm.from_jwk(json.dumps(key_data))
|
|
263
355
|
|
|
@@ -265,10 +357,16 @@ class LedgixClient:
|
|
|
265
357
|
token,
|
|
266
358
|
public_key,
|
|
267
359
|
algorithms=["EdDSA"],
|
|
268
|
-
|
|
360
|
+
audience=self.config.jwt_audience,
|
|
361
|
+
issuer=self.config.jwt_issuer,
|
|
362
|
+
options={"verify_exp": True, "require": ["exp", "iss", "aud", "sub"]},
|
|
269
363
|
)
|
|
364
|
+
if decoded.get("sub") != "clearance":
|
|
365
|
+
raise TokenVerificationError("Invalid A-JWT: unexpected subject")
|
|
270
366
|
return decoded
|
|
271
367
|
|
|
368
|
+
except TokenVerificationError:
|
|
369
|
+
raise
|
|
272
370
|
except jwt.ExpiredSignatureError as exc:
|
|
273
371
|
raise TokenVerificationError("A-JWT has expired") from exc
|
|
274
372
|
except jwt.InvalidTokenError as exc:
|
|
@@ -32,8 +32,26 @@ class VaultConfig(BaseSettings):
|
|
|
32
32
|
verify_jwt: bool = True
|
|
33
33
|
"""Whether to verify A-JWTs returned by the Vault using its JWKS endpoint."""
|
|
34
34
|
|
|
35
|
+
jwt_issuer: str = "alcv-vault"
|
|
36
|
+
"""Expected issuer for Vault A-JWTs."""
|
|
37
|
+
|
|
38
|
+
jwt_audience: str = "ledgix-sdk"
|
|
39
|
+
"""Expected audience for Vault A-JWTs."""
|
|
40
|
+
|
|
35
41
|
agent_id: str = "default-agent"
|
|
36
42
|
"""Identifier for the agent using this SDK instance."""
|
|
37
43
|
|
|
38
44
|
session_id: str = ""
|
|
39
45
|
"""Optional session identifier for grouping related clearance requests."""
|
|
46
|
+
|
|
47
|
+
review_poll_interval: float = 2.0
|
|
48
|
+
"""Polling interval in seconds while waiting for manual review."""
|
|
49
|
+
|
|
50
|
+
review_timeout: float = 300.0
|
|
51
|
+
"""Maximum wait time in seconds for a pending manual review decision."""
|
|
52
|
+
|
|
53
|
+
max_retries: int = 3
|
|
54
|
+
"""Number of retry attempts for transient failures (connection errors, 5xx responses)."""
|
|
55
|
+
|
|
56
|
+
retry_base_delay: float = 0.5
|
|
57
|
+
"""Base delay in seconds for exponential backoff between retries (full jitter applied)."""
|
|
@@ -148,17 +148,23 @@ def _extract_tool_args(
|
|
|
148
148
|
args: tuple[Any, ...],
|
|
149
149
|
kwargs: dict[str, Any],
|
|
150
150
|
) -> dict[str, Any]:
|
|
151
|
-
"""Best-effort extraction of function arguments as a dict for the clearance request.
|
|
151
|
+
"""Best-effort extraction of function arguments as a dict for the clearance request.
|
|
152
|
+
|
|
153
|
+
Skips ``self``, private parameters (prefixed with ``_``), ``*args``, and
|
|
154
|
+
``**kwargs`` captures so only named, user-visible parameters are included.
|
|
155
|
+
"""
|
|
152
156
|
try:
|
|
153
157
|
sig = inspect.signature(func)
|
|
154
158
|
bound = sig.bind_partial(*args, **kwargs)
|
|
155
159
|
bound.apply_defaults()
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
result: dict[str, Any] = {}
|
|
161
|
+
for name, value in bound.arguments.items():
|
|
162
|
+
if name.startswith("_") or name == "self":
|
|
163
|
+
continue
|
|
164
|
+
param = sig.parameters[name]
|
|
165
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
166
|
+
continue
|
|
167
|
+
result[name] = value
|
|
168
|
+
return result
|
|
169
|
+
except (TypeError, ValueError):
|
|
164
170
|
return {k: v for k, v in kwargs.items() if not k.startswith("_")}
|
|
@@ -23,6 +23,15 @@ class ClearanceDeniedError(LedgixError):
|
|
|
23
23
|
super().__init__(f"Clearance denied: {reason}")
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
class ManualReviewTimeoutError(LedgixError):
|
|
27
|
+
"""Raised when a pending manual review decision does not resolve before timeout."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, request_id: str | None = None) -> None:
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
suffix = f" ({request_id})" if request_id else ""
|
|
32
|
+
super().__init__(f"Manual review timed out{suffix}")
|
|
33
|
+
|
|
34
|
+
|
|
26
35
|
class VaultConnectionError(LedgixError):
|
|
27
36
|
"""Raised when the SDK cannot reach the Vault server."""
|
|
28
37
|
|
|
@@ -27,10 +27,19 @@ class ClearanceRequest(BaseModel):
|
|
|
27
27
|
class ClearanceResponse(BaseModel):
|
|
28
28
|
"""Response from the Vault's ``/request-clearance`` endpoint."""
|
|
29
29
|
|
|
30
|
+
status: str = Field(default="denied", description="Decision state: processing, approved, denied, or pending_review")
|
|
30
31
|
approved: bool = Field(..., description="Whether the tool call was approved")
|
|
32
|
+
requires_manual_review: bool = Field(default=False, description="Whether the request is pending human review")
|
|
31
33
|
token: str | None = Field(default=None, description="Signed A-JWT if approved, None if denied")
|
|
32
34
|
reason: str = Field(default="", description="Human-readable explanation of the decision")
|
|
33
35
|
request_id: str = Field(default="", description="Vault-assigned unique ID for this request")
|
|
36
|
+
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="Judge confidence score")
|
|
37
|
+
minimum_confidence_score: float = Field(
|
|
38
|
+
default=0.0,
|
|
39
|
+
ge=0.0,
|
|
40
|
+
le=1.0,
|
|
41
|
+
description="Client-configured minimum confidence score for auto approval",
|
|
42
|
+
)
|
|
34
43
|
|
|
35
44
|
|
|
36
45
|
class PolicyRegistration(BaseModel):
|
|
@@ -36,6 +36,8 @@ def sample_jwt(ed25519_private_key: Ed25519PrivateKey) -> str:
|
|
|
36
36
|
"""Create a valid A-JWT for testing."""
|
|
37
37
|
payload = {
|
|
38
38
|
"sub": "clearance",
|
|
39
|
+
"iss": "alcv-vault",
|
|
40
|
+
"aud": "ledgix-sdk",
|
|
39
41
|
"tool": "stripe_refund",
|
|
40
42
|
"amount": 45.0,
|
|
41
43
|
"iat": datetime.now(timezone.utc),
|
|
@@ -50,6 +52,8 @@ def expired_jwt(ed25519_private_key: Ed25519PrivateKey) -> str:
|
|
|
50
52
|
"""Create an expired A-JWT for testing."""
|
|
51
53
|
payload = {
|
|
52
54
|
"sub": "clearance",
|
|
55
|
+
"iss": "alcv-vault",
|
|
56
|
+
"aud": "ledgix-sdk",
|
|
53
57
|
"tool": "stripe_refund",
|
|
54
58
|
"iat": datetime.now(timezone.utc) - timedelta(hours=1),
|
|
55
59
|
"exp": datetime.now(timezone.utc) - timedelta(minutes=5),
|
|
@@ -80,8 +84,11 @@ def vault_config() -> VaultConfig:
|
|
|
80
84
|
vault_api_key="test-api-key",
|
|
81
85
|
vault_timeout=5.0,
|
|
82
86
|
verify_jwt=False,
|
|
87
|
+
jwt_issuer="alcv-vault",
|
|
88
|
+
jwt_audience="ledgix-sdk",
|
|
83
89
|
agent_id="test-agent",
|
|
84
90
|
session_id="test-session",
|
|
91
|
+
max_retries=0,
|
|
85
92
|
)
|
|
86
93
|
|
|
87
94
|
|
|
@@ -92,8 +99,26 @@ def vault_config_with_jwt() -> VaultConfig:
|
|
|
92
99
|
vault_api_key="test-api-key",
|
|
93
100
|
vault_timeout=5.0,
|
|
94
101
|
verify_jwt=True,
|
|
102
|
+
jwt_issuer="alcv-vault",
|
|
103
|
+
jwt_audience="ledgix-sdk",
|
|
95
104
|
agent_id="test-agent",
|
|
96
105
|
session_id="test-session",
|
|
106
|
+
max_retries=0,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.fixture
|
|
111
|
+
def vault_config_retry() -> VaultConfig:
|
|
112
|
+
"""Config with retries enabled and zero backoff delay for fast retry tests."""
|
|
113
|
+
return VaultConfig(
|
|
114
|
+
vault_url="https://vault.test",
|
|
115
|
+
vault_api_key="test-api-key",
|
|
116
|
+
vault_timeout=5.0,
|
|
117
|
+
verify_jwt=False,
|
|
118
|
+
agent_id="test-agent",
|
|
119
|
+
session_id="test-session",
|
|
120
|
+
max_retries=2,
|
|
121
|
+
retry_base_delay=0.0,
|
|
97
122
|
)
|
|
98
123
|
|
|
99
124
|
|
|
@@ -119,6 +144,7 @@ def client_with_jwt(vault_config_with_jwt: VaultConfig) -> LedgixClient:
|
|
|
119
144
|
@pytest.fixture
|
|
120
145
|
def approved_response(sample_jwt: str) -> dict:
|
|
121
146
|
return {
|
|
147
|
+
"status": "approved",
|
|
122
148
|
"approved": True,
|
|
123
149
|
"token": sample_jwt,
|
|
124
150
|
"reason": "Policy check passed",
|
|
@@ -129,6 +155,7 @@ def approved_response(sample_jwt: str) -> dict:
|
|
|
129
155
|
@pytest.fixture
|
|
130
156
|
def denied_response() -> dict:
|
|
131
157
|
return {
|
|
158
|
+
"status": "denied",
|
|
132
159
|
"approved": False,
|
|
133
160
|
"token": None,
|
|
134
161
|
"reason": "Amount exceeds $100 limit",
|
|
@@ -109,6 +109,30 @@ class TestRequestClearance:
|
|
|
109
109
|
assert body["tool_args"]["amount"] == 99
|
|
110
110
|
assert body["agent_id"] == "my-agent"
|
|
111
111
|
|
|
112
|
+
@respx.mock
|
|
113
|
+
def test_processing_polls_until_approved(self, client: LedgixClient, approved_response: dict):
|
|
114
|
+
processing_response = {
|
|
115
|
+
"status": "processing",
|
|
116
|
+
"approved": False,
|
|
117
|
+
"token": None,
|
|
118
|
+
"reason": "Queued",
|
|
119
|
+
"request_id": "req-processing-001",
|
|
120
|
+
"confidence": 0.0,
|
|
121
|
+
"minimum_confidence_score": 0.8,
|
|
122
|
+
}
|
|
123
|
+
respx.post("https://vault.test/request-clearance").mock(
|
|
124
|
+
return_value=Response(202, json=processing_response)
|
|
125
|
+
)
|
|
126
|
+
respx.get("https://vault.test/clearance-status/req-processing-001").mock(
|
|
127
|
+
return_value=Response(200, json={**approved_response, "request_id": "req-processing-001"})
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
|
|
131
|
+
result = client.request_clearance(request)
|
|
132
|
+
|
|
133
|
+
assert result.approved is True
|
|
134
|
+
assert result.request_id == "req-processing-001"
|
|
135
|
+
|
|
112
136
|
|
|
113
137
|
# ──────────────────────────────────────────────────────────────────────
|
|
114
138
|
# Clearance — async
|
|
@@ -290,6 +314,100 @@ class TestTokenVerification:
|
|
|
290
314
|
# ──────────────────────────────────────────────────────────────────────
|
|
291
315
|
|
|
292
316
|
|
|
317
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
318
|
+
# Retry behaviour
|
|
319
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class TestRetry:
|
|
323
|
+
"""Tests for automatic retry with exponential backoff."""
|
|
324
|
+
|
|
325
|
+
@respx.mock
|
|
326
|
+
def test_retries_on_connection_error_then_succeeds(
|
|
327
|
+
self, vault_config_retry: VaultConfig, approved_response: dict
|
|
328
|
+
):
|
|
329
|
+
client = LedgixClient(config=vault_config_retry)
|
|
330
|
+
route = respx.post("https://vault.test/request-clearance").mock(
|
|
331
|
+
side_effect=[
|
|
332
|
+
httpx.ConnectError("Connection refused"),
|
|
333
|
+
httpx.ConnectError("Connection refused"),
|
|
334
|
+
Response(200, json=approved_response),
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
|
|
339
|
+
result = client.request_clearance(request)
|
|
340
|
+
|
|
341
|
+
assert result.approved is True
|
|
342
|
+
assert route.call_count == 3
|
|
343
|
+
client.close()
|
|
344
|
+
|
|
345
|
+
@respx.mock
|
|
346
|
+
def test_retries_on_5xx_then_succeeds(
|
|
347
|
+
self, vault_config_retry: VaultConfig, approved_response: dict
|
|
348
|
+
):
|
|
349
|
+
client = LedgixClient(config=vault_config_retry)
|
|
350
|
+
respx.post("https://vault.test/request-clearance").mock(
|
|
351
|
+
side_effect=[
|
|
352
|
+
Response(503, text="Service Unavailable"),
|
|
353
|
+
Response(503, text="Service Unavailable"),
|
|
354
|
+
Response(200, json=approved_response),
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
|
|
359
|
+
result = client.request_clearance(request)
|
|
360
|
+
|
|
361
|
+
assert result.approved is True
|
|
362
|
+
client.close()
|
|
363
|
+
|
|
364
|
+
@respx.mock
|
|
365
|
+
def test_raises_after_exhausting_retries(self, vault_config_retry: VaultConfig):
|
|
366
|
+
client = LedgixClient(config=vault_config_retry)
|
|
367
|
+
respx.post("https://vault.test/request-clearance").mock(
|
|
368
|
+
side_effect=httpx.ConnectError("Connection refused")
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
|
|
372
|
+
with pytest.raises(VaultConnectionError):
|
|
373
|
+
client.request_clearance(request)
|
|
374
|
+
client.close()
|
|
375
|
+
|
|
376
|
+
@respx.mock
|
|
377
|
+
def test_does_not_retry_on_4xx(self, vault_config_retry: VaultConfig):
|
|
378
|
+
client = LedgixClient(config=vault_config_retry)
|
|
379
|
+
route = respx.post("https://vault.test/request-clearance").mock(
|
|
380
|
+
return_value=Response(400, text="Bad Request")
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
|
|
384
|
+
with pytest.raises(VaultConnectionError):
|
|
385
|
+
client.request_clearance(request)
|
|
386
|
+
|
|
387
|
+
# 400 is not retryable — should only be called once
|
|
388
|
+
assert route.call_count == 1
|
|
389
|
+
client.close()
|
|
390
|
+
|
|
391
|
+
@respx.mock
|
|
392
|
+
@pytest.mark.asyncio
|
|
393
|
+
async def test_async_retries_on_connection_error(
|
|
394
|
+
self, vault_config_retry: VaultConfig, approved_response: dict
|
|
395
|
+
):
|
|
396
|
+
client = LedgixClient(config=vault_config_retry)
|
|
397
|
+
respx.post("https://vault.test/request-clearance").mock(
|
|
398
|
+
side_effect=[
|
|
399
|
+
httpx.ConnectError("Connection refused"),
|
|
400
|
+
Response(200, json=approved_response),
|
|
401
|
+
]
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
|
|
405
|
+
result = await client.arequest_clearance(request)
|
|
406
|
+
|
|
407
|
+
assert result.approved is True
|
|
408
|
+
await client.aclose()
|
|
409
|
+
|
|
410
|
+
|
|
293
411
|
class TestClientLifecycle:
|
|
294
412
|
"""Tests for context manager and close behavior."""
|
|
295
413
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|