bylaw-python 0.4.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.
- bylaw_python/__init__.py +114 -0
- bylaw_python/adapters/__init__.py +1 -0
- bylaw_python/adapters/_core.py +58 -0
- bylaw_python/adapters/crewai.py +99 -0
- bylaw_python/adapters/langchain.py +167 -0
- bylaw_python/adapters/llamaindex.py +90 -0
- bylaw_python/cli.py +366 -0
- bylaw_python/client.py +1595 -0
- bylaw_python/config.py +95 -0
- bylaw_python/counterparty.py +145 -0
- bylaw_python/enforce.py +561 -0
- bylaw_python/exceptions.py +104 -0
- bylaw_python/manifest.py +152 -0
- bylaw_python/models.py +330 -0
- bylaw_python/pending.py +128 -0
- bylaw_python/webhook.py +44 -0
- bylaw_python-0.4.0.dist-info/METADATA +227 -0
- bylaw_python-0.4.0.dist-info/RECORD +20 -0
- bylaw_python-0.4.0.dist-info/WHEEL +4 -0
- bylaw_python-0.4.0.dist-info/entry_points.txt +2 -0
bylaw_python/enforce.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# Ledgix ALCV — Enforcement Layer
|
|
2
|
+
# Decorator and context manager for intercepting tool calls
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import contextvars
|
|
8
|
+
import functools
|
|
9
|
+
import importlib
|
|
10
|
+
import inspect
|
|
11
|
+
import pkgutil
|
|
12
|
+
import sys
|
|
13
|
+
import types
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, TypeVar
|
|
16
|
+
|
|
17
|
+
from .client import LedgixClient
|
|
18
|
+
from .config import VaultConfig
|
|
19
|
+
from .exceptions import ClearanceDeniedError, ReviewPendingError
|
|
20
|
+
from .manifest import Manifest, ManifestRule, load_manifest
|
|
21
|
+
from .models import ClearanceRequest, ClearanceResponse
|
|
22
|
+
from .pending import PendingApproval
|
|
23
|
+
|
|
24
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Global singleton & context variable
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_default_client: LedgixClient | None = None
|
|
31
|
+
_current_clearance: contextvars.ContextVar[ClearanceResponse | None] = contextvars.ContextVar(
|
|
32
|
+
"_ledgix_clearance", default=None
|
|
33
|
+
)
|
|
34
|
+
_manifest: Manifest | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure(config: VaultConfig | None = None, **kwargs: Any) -> LedgixClient:
|
|
38
|
+
"""Configure the global Ledgix client.
|
|
39
|
+
|
|
40
|
+
Call this once at application startup. All subsequent calls to
|
|
41
|
+
:func:`enforce` will use this client automatically.
|
|
42
|
+
|
|
43
|
+
Keyword arguments are forwarded to :class:`~bylaw_python.VaultConfig`
|
|
44
|
+
when no explicit *config* object is provided::
|
|
45
|
+
|
|
46
|
+
import bylaw_python as ledgix
|
|
47
|
+
|
|
48
|
+
ledgix.configure(agent_id="finance-agent")
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
config: Optional pre-built :class:`~bylaw_python.VaultConfig`.
|
|
52
|
+
**kwargs: Config overrides passed to ``VaultConfig`` when *config* is
|
|
53
|
+
``None``.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The newly created :class:`~bylaw_python.LedgixClient`.
|
|
57
|
+
"""
|
|
58
|
+
global _default_client
|
|
59
|
+
if config is None:
|
|
60
|
+
config = VaultConfig(**kwargs)
|
|
61
|
+
_default_client = LedgixClient(config)
|
|
62
|
+
return _default_client
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_default_client() -> LedgixClient:
|
|
66
|
+
"""Return the global client, raising if :func:`configure` was never called."""
|
|
67
|
+
if _default_client is None:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"No Ledgix client configured. Call ledgix.configure() at startup "
|
|
70
|
+
"before using @ledgix.enforce()."
|
|
71
|
+
)
|
|
72
|
+
return _default_client
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def current_clearance() -> ClearanceResponse | None:
|
|
76
|
+
"""Return the :class:`~bylaw_python.ClearanceResponse` for the current call.
|
|
77
|
+
|
|
78
|
+
Returns ``None`` when called outside an :func:`enforce`-wrapped function.
|
|
79
|
+
"""
|
|
80
|
+
return _current_clearance.get()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def current_token() -> str | None:
|
|
84
|
+
"""Return the A-JWT token for the current call.
|
|
85
|
+
|
|
86
|
+
Returns ``None`` when called outside an :func:`enforce`-wrapped function or
|
|
87
|
+
when the clearance did not include a token.
|
|
88
|
+
"""
|
|
89
|
+
clearance = _current_clearance.get()
|
|
90
|
+
if clearance is None:
|
|
91
|
+
return None
|
|
92
|
+
return clearance.token
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Manifest-driven auto-instrumentation
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def auto_instrument(
|
|
100
|
+
module: types.ModuleType,
|
|
101
|
+
manifest: str | Path | dict[str, Any] | Manifest | None = None,
|
|
102
|
+
recurse: bool = False,
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
"""Scan modules and wrap matching functions according to a manifest.
|
|
105
|
+
|
|
106
|
+
Call once at startup after :func:`configure`::
|
|
107
|
+
|
|
108
|
+
import tools
|
|
109
|
+
import bylaw_python as ledgix
|
|
110
|
+
|
|
111
|
+
ledgix.configure(agent_id="my-agent")
|
|
112
|
+
ledgix.auto_instrument(tools) # reads ledgix.yaml from CWD
|
|
113
|
+
|
|
114
|
+
Or point at a specific manifest::
|
|
115
|
+
|
|
116
|
+
ledgix.auto_instrument(tools, manifest="config/ledgix.yaml")
|
|
117
|
+
ledgix.auto_instrument(tools, manifest={"enforce": [
|
|
118
|
+
{"tool": "stripe_*", "policy_id": "financial-high-risk"},
|
|
119
|
+
]})
|
|
120
|
+
|
|
121
|
+
Only functions *defined* in the scanned module are wrapped — functions
|
|
122
|
+
imported into it are skipped. If you have tools outside the scanned
|
|
123
|
+
modules use the :func:`tool` decorator as an escape hatch.
|
|
124
|
+
|
|
125
|
+
.. warning::
|
|
126
|
+
Call ``auto_instrument`` before other modules import the tool functions.
|
|
127
|
+
Monkey-patching updates the module namespace, but existing references
|
|
128
|
+
held by already-imported modules will not be retroactively wrapped.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
module: Module or package (when *recurse* is ``True``) to scan.
|
|
132
|
+
manifest: Path to a manifest file, an inline ``dict``, a pre-built
|
|
133
|
+
:class:`~bylaw_python.Manifest`, or ``None`` to auto-discover
|
|
134
|
+
``ledgix.yaml`` / ``ledgix.yml`` / ``ledgix.json`` in the CWD.
|
|
135
|
+
recurse: If ``True``, also scan sub-packages of any package module.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Sorted list of qualified names that were wrapped,
|
|
139
|
+
e.g. ``["tools.stripe_payment", "tools.issue_refund"]``.
|
|
140
|
+
"""
|
|
141
|
+
global _manifest
|
|
142
|
+
|
|
143
|
+
if isinstance(manifest, Manifest):
|
|
144
|
+
_manifest = manifest
|
|
145
|
+
else:
|
|
146
|
+
_manifest = load_manifest(manifest)
|
|
147
|
+
|
|
148
|
+
return sorted(_instrument_module(module, _manifest, recurse=recurse))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _instrument_module(
|
|
152
|
+
module: types.ModuleType,
|
|
153
|
+
manifest: Manifest,
|
|
154
|
+
*,
|
|
155
|
+
recurse: bool,
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
"""Walk *module* and monkey-patch functions that match a manifest rule."""
|
|
158
|
+
wrapped: list[str] = []
|
|
159
|
+
mod_name = module.__name__
|
|
160
|
+
|
|
161
|
+
for attr_name, obj in inspect.getmembers(module, inspect.isfunction):
|
|
162
|
+
if attr_name.startswith("_"):
|
|
163
|
+
continue
|
|
164
|
+
# Skip functions that were imported into this module from elsewhere.
|
|
165
|
+
if obj.__module__ != mod_name:
|
|
166
|
+
continue
|
|
167
|
+
rule = manifest.match(attr_name)
|
|
168
|
+
if rule is None:
|
|
169
|
+
continue
|
|
170
|
+
setattr(module, attr_name, _wrap_with_rule(obj, attr_name, rule))
|
|
171
|
+
wrapped.append(f"{mod_name}.{attr_name}")
|
|
172
|
+
|
|
173
|
+
if recurse and hasattr(module, "__path__"):
|
|
174
|
+
for _, submod_name, _ in pkgutil.walk_packages(
|
|
175
|
+
module.__path__, prefix=mod_name + "."
|
|
176
|
+
):
|
|
177
|
+
submod = sys.modules.get(submod_name)
|
|
178
|
+
if submod is None:
|
|
179
|
+
submod = importlib.import_module(submod_name)
|
|
180
|
+
wrapped.extend(_instrument_module(submod, manifest, recurse=False))
|
|
181
|
+
|
|
182
|
+
return wrapped
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _wrap_with_rule(func: F, name: str, rule: ManifestRule) -> F:
|
|
186
|
+
"""Apply :func:`enforce` to *func* using settings from *rule*."""
|
|
187
|
+
return enforce(
|
|
188
|
+
tool_name=name,
|
|
189
|
+
policy_id=rule.policy_id,
|
|
190
|
+
context=rule.context if rule.context else None,
|
|
191
|
+
)(func)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def tool(
|
|
195
|
+
func: F | None = None,
|
|
196
|
+
*,
|
|
197
|
+
tool_name: str | None = None,
|
|
198
|
+
policy_id: str | None = None,
|
|
199
|
+
context: dict[str, Any] | None = None,
|
|
200
|
+
# Phase 2 — GDPR Article 30 processing-register matching.
|
|
201
|
+
data_categories: list[str] | None = None,
|
|
202
|
+
purpose: str | None = None,
|
|
203
|
+
processing_register_ref: str | None = None,
|
|
204
|
+
# Phase 6 — dataset lineage.
|
|
205
|
+
dataset_ref: str | None = None,
|
|
206
|
+
) -> F | Callable[[F], F]:
|
|
207
|
+
"""Decorator to enforce Vault clearance on a single function.
|
|
208
|
+
|
|
209
|
+
Use this as an escape hatch for functions that live outside the modules
|
|
210
|
+
passed to :func:`auto_instrument`. If a manifest has been loaded its
|
|
211
|
+
rules are applied first; explicit keyword arguments always take precedence.
|
|
212
|
+
|
|
213
|
+
Works with or without call parentheses::
|
|
214
|
+
|
|
215
|
+
@ledgix.tool
|
|
216
|
+
def my_fn(amount: float):
|
|
217
|
+
token = ledgix.current_token()
|
|
218
|
+
...
|
|
219
|
+
|
|
220
|
+
@ledgix.tool(policy_id="financial-high-risk")
|
|
221
|
+
def stripe_charge(amount: float, customer_id: str):
|
|
222
|
+
token = ledgix.current_token()
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
func: The function to wrap (populated automatically when the decorator
|
|
227
|
+
is used without parentheses).
|
|
228
|
+
tool_name: Override the tool name used in the clearance request
|
|
229
|
+
(defaults to ``func.__name__``).
|
|
230
|
+
policy_id: Policy ID override (manifest match used when omitted).
|
|
231
|
+
context: Extra key/value pairs forwarded to the clearance request.
|
|
232
|
+
data_categories: Personal-data categories this action will touch
|
|
233
|
+
(Phase 2 register matching).
|
|
234
|
+
purpose: Purpose of processing (Phase 2 register matching).
|
|
235
|
+
processing_register_ref: Optional UUID hint of the matching register.
|
|
236
|
+
dataset_ref: Logical dataset ref this action reads/writes (Phase 6).
|
|
237
|
+
"""
|
|
238
|
+
def decorator(f: F) -> F:
|
|
239
|
+
resolved_name = tool_name or f.__name__
|
|
240
|
+
resolved_policy = policy_id
|
|
241
|
+
resolved_context: dict[str, Any] = dict(context or {})
|
|
242
|
+
|
|
243
|
+
# Apply manifest rule if available, explicit kwargs take precedence.
|
|
244
|
+
if _manifest is not None:
|
|
245
|
+
rule = _manifest.match(resolved_name)
|
|
246
|
+
if rule is not None:
|
|
247
|
+
if resolved_policy is None:
|
|
248
|
+
resolved_policy = rule.policy_id
|
|
249
|
+
resolved_context = {**rule.context, **resolved_context}
|
|
250
|
+
|
|
251
|
+
return enforce(
|
|
252
|
+
tool_name=resolved_name,
|
|
253
|
+
policy_id=resolved_policy,
|
|
254
|
+
context=resolved_context or None,
|
|
255
|
+
data_categories=data_categories,
|
|
256
|
+
purpose=purpose,
|
|
257
|
+
processing_register_ref=processing_register_ref,
|
|
258
|
+
dataset_ref=dataset_ref,
|
|
259
|
+
)(f)
|
|
260
|
+
|
|
261
|
+
if func is not None:
|
|
262
|
+
# @ledgix.tool — called without parentheses
|
|
263
|
+
return decorator(func)
|
|
264
|
+
# @ledgix.tool(...) — called with arguments
|
|
265
|
+
return decorator
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# New low-code decorator: enforce()
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def enforce(
|
|
273
|
+
*,
|
|
274
|
+
tool_name: str | None = None,
|
|
275
|
+
policy_id: str | None = None,
|
|
276
|
+
context: dict[str, Any] | None = None,
|
|
277
|
+
on_review_pending: Callable[[PendingApproval], None] | None = None,
|
|
278
|
+
# Phase 2 — GDPR Article 30 processing-register matching.
|
|
279
|
+
data_categories: list[str] | None = None,
|
|
280
|
+
purpose: str | None = None,
|
|
281
|
+
processing_register_ref: str | None = None,
|
|
282
|
+
# Phase 6 — dataset lineage.
|
|
283
|
+
dataset_ref: str | None = None,
|
|
284
|
+
) -> Callable[[F], F]:
|
|
285
|
+
"""Decorator that enforces Vault clearance before a function executes.
|
|
286
|
+
|
|
287
|
+
Requires :func:`configure` to have been called at startup. The A-JWT
|
|
288
|
+
token is stored in a context variable and can be retrieved inside the
|
|
289
|
+
decorated function via :func:`current_token`::
|
|
290
|
+
|
|
291
|
+
import bylaw_python as ledgix
|
|
292
|
+
|
|
293
|
+
ledgix.configure(agent_id="finance-agent")
|
|
294
|
+
|
|
295
|
+
@ledgix.enforce(tool_name="stripe_refund")
|
|
296
|
+
def process_refund(amount: float, reason: str):
|
|
297
|
+
token = ledgix.current_token()
|
|
298
|
+
stripe.refund(amount=amount, metadata={"vault_token": token})
|
|
299
|
+
|
|
300
|
+
Works with both sync and async functions. Unlike :func:`vault_enforce`,
|
|
301
|
+
no ``_clearance`` kwarg is injected — the function signature is untouched.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
on_review_pending: Optional callback invoked when ``review_mode="detach"``
|
|
305
|
+
and the Vault returns a ``pending_review`` response. Receives a
|
|
306
|
+
:class:`~bylaw_python.PendingApproval` handle. After the callback
|
|
307
|
+
returns, :class:`~bylaw_python.ReviewPendingError` is re-raised so
|
|
308
|
+
the calling framework can abort the current turn.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def decorator(func: F) -> F:
|
|
312
|
+
resolved_name = tool_name or func.__name__
|
|
313
|
+
|
|
314
|
+
if inspect.iscoroutinefunction(func):
|
|
315
|
+
|
|
316
|
+
@functools.wraps(func)
|
|
317
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
318
|
+
client = _get_default_client()
|
|
319
|
+
request = ClearanceRequest(
|
|
320
|
+
tool_name=resolved_name,
|
|
321
|
+
tool_args=_extract_tool_args(func, args, kwargs),
|
|
322
|
+
agent_id=client.config.agent_id,
|
|
323
|
+
session_id=client.config.session_id,
|
|
324
|
+
context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
|
|
325
|
+
data_categories=data_categories,
|
|
326
|
+
purpose=purpose,
|
|
327
|
+
processing_register_ref=processing_register_ref,
|
|
328
|
+
dataset_ref=dataset_ref,
|
|
329
|
+
)
|
|
330
|
+
try:
|
|
331
|
+
clearance = await client.arequest_clearance(request)
|
|
332
|
+
except ReviewPendingError as exc:
|
|
333
|
+
if on_review_pending is not None:
|
|
334
|
+
on_review_pending(exc.pending_approval)
|
|
335
|
+
raise
|
|
336
|
+
token = _current_clearance.set(clearance)
|
|
337
|
+
try:
|
|
338
|
+
return await func(*args, **kwargs)
|
|
339
|
+
finally:
|
|
340
|
+
_current_clearance.reset(token)
|
|
341
|
+
|
|
342
|
+
return async_wrapper # type: ignore[return-value]
|
|
343
|
+
|
|
344
|
+
else:
|
|
345
|
+
|
|
346
|
+
@functools.wraps(func)
|
|
347
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
348
|
+
client = _get_default_client()
|
|
349
|
+
request = ClearanceRequest(
|
|
350
|
+
tool_name=resolved_name,
|
|
351
|
+
tool_args=_extract_tool_args(func, args, kwargs),
|
|
352
|
+
agent_id=client.config.agent_id,
|
|
353
|
+
session_id=client.config.session_id,
|
|
354
|
+
context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
|
|
355
|
+
data_categories=data_categories,
|
|
356
|
+
purpose=purpose,
|
|
357
|
+
processing_register_ref=processing_register_ref,
|
|
358
|
+
dataset_ref=dataset_ref,
|
|
359
|
+
)
|
|
360
|
+
try:
|
|
361
|
+
clearance = client.request_clearance(request)
|
|
362
|
+
except ReviewPendingError as exc:
|
|
363
|
+
if on_review_pending is not None:
|
|
364
|
+
on_review_pending(exc.pending_approval)
|
|
365
|
+
raise
|
|
366
|
+
token = _current_clearance.set(clearance)
|
|
367
|
+
try:
|
|
368
|
+
return func(*args, **kwargs)
|
|
369
|
+
finally:
|
|
370
|
+
_current_clearance.reset(token)
|
|
371
|
+
|
|
372
|
+
return sync_wrapper # type: ignore[return-value]
|
|
373
|
+
|
|
374
|
+
return decorator
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
# Original explicit decorator: vault_enforce() (unchanged)
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
class VaultContext:
|
|
382
|
+
"""Context manager that requests clearance before executing a block.
|
|
383
|
+
|
|
384
|
+
Sync usage::
|
|
385
|
+
|
|
386
|
+
with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
|
|
387
|
+
# ctx.clearance contains the ClearanceResponse
|
|
388
|
+
execute_refund(ctx.clearance.token)
|
|
389
|
+
|
|
390
|
+
Async usage::
|
|
391
|
+
|
|
392
|
+
async with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
|
|
393
|
+
execute_refund(ctx.clearance.token)
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def __init__(
|
|
397
|
+
self,
|
|
398
|
+
client: LedgixClient,
|
|
399
|
+
tool_name: str,
|
|
400
|
+
tool_args: dict[str, Any] | None = None,
|
|
401
|
+
*,
|
|
402
|
+
context: dict[str, Any] | None = None,
|
|
403
|
+
policy_id: str | None = None,
|
|
404
|
+
data_categories: list[str] | None = None,
|
|
405
|
+
purpose: str | None = None,
|
|
406
|
+
processing_register_ref: str | None = None,
|
|
407
|
+
dataset_ref: str | None = None,
|
|
408
|
+
) -> None:
|
|
409
|
+
self.client = client
|
|
410
|
+
self.tool_name = tool_name
|
|
411
|
+
self.tool_args = tool_args or {}
|
|
412
|
+
self.context = context or {}
|
|
413
|
+
self.policy_id = policy_id
|
|
414
|
+
self.data_categories = data_categories
|
|
415
|
+
self.purpose = purpose
|
|
416
|
+
self.processing_register_ref = processing_register_ref
|
|
417
|
+
self.dataset_ref = dataset_ref
|
|
418
|
+
self.clearance: ClearanceResponse | None = None
|
|
419
|
+
|
|
420
|
+
def _build_request(self) -> ClearanceRequest:
|
|
421
|
+
ctx = {**self.context}
|
|
422
|
+
if self.policy_id:
|
|
423
|
+
ctx["policy_id"] = self.policy_id
|
|
424
|
+
return ClearanceRequest(
|
|
425
|
+
tool_name=self.tool_name,
|
|
426
|
+
tool_args=self.tool_args,
|
|
427
|
+
agent_id=self.client.config.agent_id,
|
|
428
|
+
session_id=self.client.config.session_id,
|
|
429
|
+
context=ctx,
|
|
430
|
+
data_categories=self.data_categories,
|
|
431
|
+
purpose=self.purpose,
|
|
432
|
+
processing_register_ref=self.processing_register_ref,
|
|
433
|
+
dataset_ref=self.dataset_ref,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Sync context manager
|
|
437
|
+
def __enter__(self) -> VaultContext:
|
|
438
|
+
request = self._build_request()
|
|
439
|
+
self.clearance = self.client.request_clearance(request)
|
|
440
|
+
return self
|
|
441
|
+
|
|
442
|
+
def __exit__(self, *args: Any) -> None:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
# Async context manager
|
|
446
|
+
async def __aenter__(self) -> VaultContext:
|
|
447
|
+
request = self._build_request()
|
|
448
|
+
self.clearance = await self.client.arequest_clearance(request)
|
|
449
|
+
return self
|
|
450
|
+
|
|
451
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def vault_enforce(
|
|
456
|
+
client: LedgixClient,
|
|
457
|
+
*,
|
|
458
|
+
tool_name: str | None = None,
|
|
459
|
+
policy_id: str | None = None,
|
|
460
|
+
context: dict[str, Any] | None = None,
|
|
461
|
+
data_categories: list[str] | None = None,
|
|
462
|
+
purpose: str | None = None,
|
|
463
|
+
processing_register_ref: str | None = None,
|
|
464
|
+
dataset_ref: str | None = None,
|
|
465
|
+
) -> Callable[[F], F]:
|
|
466
|
+
"""Decorator that enforces Vault clearance before a function executes.
|
|
467
|
+
|
|
468
|
+
Works with both sync and async functions automatically.
|
|
469
|
+
|
|
470
|
+
Usage::
|
|
471
|
+
|
|
472
|
+
@vault_enforce(client, tool_name="stripe_refund")
|
|
473
|
+
def process_refund(amount: float, reason: str):
|
|
474
|
+
# This only runs if the Vault approves
|
|
475
|
+
stripe.refund(amount=amount, reason=reason)
|
|
476
|
+
|
|
477
|
+
@vault_enforce(client, tool_name="stripe_refund")
|
|
478
|
+
async def async_process_refund(amount: float, reason: str):
|
|
479
|
+
await stripe.refund(amount=amount, reason=reason)
|
|
480
|
+
|
|
481
|
+
The decorated function receives an injected ``_clearance`` keyword
|
|
482
|
+
argument containing the ``ClearanceResponse`` (with the A-JWT token).
|
|
483
|
+
|
|
484
|
+
.. note::
|
|
485
|
+
Prefer :func:`enforce` for new code — it requires no changes to the
|
|
486
|
+
function signature and uses the global client set by :func:`configure`.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
def decorator(func: F) -> F:
|
|
490
|
+
resolved_name = tool_name or func.__name__
|
|
491
|
+
|
|
492
|
+
if inspect.iscoroutinefunction(func):
|
|
493
|
+
|
|
494
|
+
@functools.wraps(func)
|
|
495
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
496
|
+
request = ClearanceRequest(
|
|
497
|
+
tool_name=resolved_name,
|
|
498
|
+
tool_args=_extract_tool_args(func, args, kwargs),
|
|
499
|
+
agent_id=client.config.agent_id,
|
|
500
|
+
session_id=client.config.session_id,
|
|
501
|
+
context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
|
|
502
|
+
data_categories=data_categories,
|
|
503
|
+
purpose=purpose,
|
|
504
|
+
processing_register_ref=processing_register_ref,
|
|
505
|
+
dataset_ref=dataset_ref,
|
|
506
|
+
)
|
|
507
|
+
clearance = await client.arequest_clearance(request)
|
|
508
|
+
kwargs["_clearance"] = clearance
|
|
509
|
+
return await func(*args, **kwargs)
|
|
510
|
+
|
|
511
|
+
return async_wrapper # type: ignore[return-value]
|
|
512
|
+
|
|
513
|
+
else:
|
|
514
|
+
|
|
515
|
+
@functools.wraps(func)
|
|
516
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
517
|
+
request = ClearanceRequest(
|
|
518
|
+
tool_name=resolved_name,
|
|
519
|
+
tool_args=_extract_tool_args(func, args, kwargs),
|
|
520
|
+
agent_id=client.config.agent_id,
|
|
521
|
+
session_id=client.config.session_id,
|
|
522
|
+
context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
|
|
523
|
+
data_categories=data_categories,
|
|
524
|
+
purpose=purpose,
|
|
525
|
+
processing_register_ref=processing_register_ref,
|
|
526
|
+
dataset_ref=dataset_ref,
|
|
527
|
+
)
|
|
528
|
+
clearance = client.request_clearance(request)
|
|
529
|
+
kwargs["_clearance"] = clearance
|
|
530
|
+
return func(*args, **kwargs)
|
|
531
|
+
|
|
532
|
+
return sync_wrapper # type: ignore[return-value]
|
|
533
|
+
|
|
534
|
+
return decorator
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _extract_tool_args(
|
|
538
|
+
func: Callable[..., Any],
|
|
539
|
+
args: tuple[Any, ...],
|
|
540
|
+
kwargs: dict[str, Any],
|
|
541
|
+
) -> dict[str, Any]:
|
|
542
|
+
"""Best-effort extraction of function arguments as a dict for the clearance request.
|
|
543
|
+
|
|
544
|
+
Skips ``self``, private parameters (prefixed with ``_``), ``*args``, and
|
|
545
|
+
``**kwargs`` captures so only named, user-visible parameters are included.
|
|
546
|
+
"""
|
|
547
|
+
try:
|
|
548
|
+
sig = inspect.signature(func)
|
|
549
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
550
|
+
bound.apply_defaults()
|
|
551
|
+
result: dict[str, Any] = {}
|
|
552
|
+
for name, value in bound.arguments.items():
|
|
553
|
+
if name.startswith("_") or name == "self":
|
|
554
|
+
continue
|
|
555
|
+
param = sig.parameters[name]
|
|
556
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
557
|
+
continue
|
|
558
|
+
result[name] = value
|
|
559
|
+
return result
|
|
560
|
+
except (TypeError, ValueError):
|
|
561
|
+
return {k: v for k, v in kwargs.items() if not k.startswith("_")}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Ledgix ALCV — Exceptions
|
|
2
|
+
# All custom exceptions for the SDK
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LedgixError(Exception):
|
|
8
|
+
"""Base exception for all Ledgix SDK errors."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClearanceDeniedError(LedgixError):
|
|
13
|
+
"""Raised when the Vault denies a tool-call clearance request.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
reason: Human-readable denial reason from the Vault.
|
|
17
|
+
request_id: The Vault's unique ID for this clearance request.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, reason: str, request_id: str | None = None) -> None:
|
|
21
|
+
self.reason = reason
|
|
22
|
+
self.request_id = request_id
|
|
23
|
+
super().__init__(f"Clearance denied: {reason}")
|
|
24
|
+
|
|
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
|
+
|
|
35
|
+
class VaultConnectionError(LedgixError):
|
|
36
|
+
"""Raised when the SDK cannot reach the Vault server."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str = "Unable to connect to the Vault server") -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TokenVerificationError(LedgixError):
|
|
43
|
+
"""Raised when A-JWT verification fails (bad signature, expired, etc.)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str = "Token verification failed") -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PolicyRegistrationError(LedgixError):
|
|
50
|
+
"""Raised when a policy registration request fails."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str = "Policy registration failed") -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ReplayDetectedError(TokenVerificationError):
|
|
57
|
+
"""Raised when an A-JWT jti has already been consumed by this SDK instance.
|
|
58
|
+
|
|
59
|
+
Each A-JWT is single-use. Presenting the same token twice in the same
|
|
60
|
+
process raises this error so callers cannot accidentally reuse a spent
|
|
61
|
+
clearance. The SDK tracks jtis for the token's remaining TTL plus a
|
|
62
|
+
30-second clock-skew buffer.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, jti: str | None = None) -> None:
|
|
66
|
+
self.jti = jti
|
|
67
|
+
suffix = f" (jti={jti})" if jti else ""
|
|
68
|
+
super(TokenVerificationError, self).__init__(f"A-JWT replay detected{suffix}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class QueueSaturatedError(LedgixError):
|
|
72
|
+
"""Raised when Vault repeatedly responds with HTTP 429 (queue near capacity).
|
|
73
|
+
|
|
74
|
+
Vault emits 429 + ``Retry-After`` from its proactive backpressure check
|
|
75
|
+
(Scale & Reliability §2.1). The SDK honors the header and does NOT count
|
|
76
|
+
these against the normal ``max_retries`` budget — they're cooperative
|
|
77
|
+
backoff, not failures. After ``max_consecutive_429`` waves with no
|
|
78
|
+
success, however, the SDK gives up with this error so callers can fail
|
|
79
|
+
fast instead of looping indefinitely while the Vault is melting.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
attempts: How many consecutive 429s were received before giving up.
|
|
83
|
+
last_retry_after: The last ``Retry-After`` value the server emitted (seconds).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, attempts: int, last_retry_after: float | None = None) -> None:
|
|
87
|
+
self.attempts = attempts
|
|
88
|
+
self.last_retry_after = last_retry_after
|
|
89
|
+
suffix = f" (last Retry-After={last_retry_after}s)" if last_retry_after is not None else ""
|
|
90
|
+
super().__init__(
|
|
91
|
+
f"Vault clearance queue saturated after {attempts} consecutive 429 responses{suffix}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ReviewPendingError(LedgixError):
|
|
96
|
+
"""Raised in ``review_mode="detach"`` when a clearance enters pending-review.
|
|
97
|
+
|
|
98
|
+
The attached :attr:`pending_approval` handle lets the caller poll or cancel
|
|
99
|
+
the review without blocking the current thread/coroutine.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, pending_approval: "Any") -> None: # noqa: F821
|
|
103
|
+
self.pending_approval = pending_approval
|
|
104
|
+
super().__init__(f"Clearance pending review (request_id={pending_approval.request_id})")
|