ledgix-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ # Ledgix ALCV — Python SDK
2
+ # Agent-agnostic compliance shim for SOX 404 policy enforcement
3
+ #
4
+ # Usage:
5
+ # from ledgix_python import LedgixClient, vault_enforce, VaultConfig
6
+ #
7
+ # client = LedgixClient()
8
+ #
9
+ # @vault_enforce(client, tool_name="stripe_refund")
10
+ # def process_refund(amount: float, reason: str, **kwargs):
11
+ # token = kwargs.get("_clearance").token
12
+ # ...
13
+
14
+ """Ledgix ALCV — agent-agnostic compliance shim for SOX 404 enforcement."""
15
+
16
+ from .client import LedgixClient
17
+ from .config import VaultConfig
18
+ from .enforce import VaultContext, vault_enforce
19
+ from .exceptions import (
20
+ ClearanceDeniedError,
21
+ PolicyRegistrationError,
22
+ LedgixError,
23
+ TokenVerificationError,
24
+ VaultConnectionError,
25
+ )
26
+ from .models import (
27
+ ClearanceRequest,
28
+ ClearanceResponse,
29
+ PolicyRegistration,
30
+ PolicyRegistrationResponse,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ # Core
37
+ "LedgixClient",
38
+ "VaultConfig",
39
+ # Enforcement
40
+ "vault_enforce",
41
+ "VaultContext",
42
+ # Models
43
+ "ClearanceRequest",
44
+ "ClearanceResponse",
45
+ "PolicyRegistration",
46
+ "PolicyRegistrationResponse",
47
+ # Exceptions
48
+ "LedgixError",
49
+ "ClearanceDeniedError",
50
+ "VaultConnectionError",
51
+ "TokenVerificationError",
52
+ "PolicyRegistrationError",
53
+ ]
@@ -0,0 +1 @@
1
+ # Ledgix ALCV — Framework Adapters
@@ -0,0 +1,95 @@
1
+ # Ledgix ALCV — CrewAI Adapter
2
+ # Wraps CrewAI tools with Vault clearance enforcement
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Type
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from ..client import LedgixClient
11
+ from ..exceptions import ClearanceDeniedError
12
+ from ..models import ClearanceRequest
13
+
14
+ try:
15
+ from crewai.tools import BaseTool as CrewAIBaseTool
16
+ except ImportError as exc:
17
+ raise ImportError(
18
+ "CrewAI adapter requires crewai. "
19
+ "Install with: pip install ledgix-python[crewai]"
20
+ ) from exc
21
+
22
+
23
+ class LedgixCrewAITool(CrewAIBaseTool):
24
+ """Wraps a CrewAI tool with Vault clearance enforcement.
25
+
26
+ Usage::
27
+
28
+ from crewai.tools import BaseTool
29
+ from ledgix_python.adapters.crewai import LedgixCrewAITool
30
+
31
+ class MyTool(BaseTool):
32
+ name = "search"
33
+ description = "Search the web"
34
+
35
+ def _run(self, query: str) -> str:
36
+ return f"Results for {query}"
37
+
38
+ guarded = LedgixCrewAITool.wrap(client, MyTool())
39
+ """
40
+
41
+ name: str = ""
42
+ description: str = ""
43
+ _inner_tool: CrewAIBaseTool
44
+ _client: LedgixClient
45
+ _policy_id: str | None
46
+
47
+ class Config:
48
+ arbitrary_types_allowed = True
49
+ underscore_attrs_are_private = True
50
+
51
+ def __init__(
52
+ self,
53
+ inner_tool: CrewAIBaseTool,
54
+ client: LedgixClient,
55
+ *,
56
+ policy_id: str | None = None,
57
+ ) -> None:
58
+ super().__init__(
59
+ name=f"ledgix_{inner_tool.name}",
60
+ description=inner_tool.description,
61
+ )
62
+ self._inner_tool = inner_tool
63
+ self._client = client
64
+ self._policy_id = policy_id
65
+
66
+ @classmethod
67
+ def wrap(
68
+ cls,
69
+ client: LedgixClient,
70
+ tool: CrewAIBaseTool,
71
+ *,
72
+ policy_id: str | None = None,
73
+ ) -> LedgixCrewAITool:
74
+ """Convenience factory to wrap a CrewAI tool."""
75
+ return cls(inner_tool=tool, client=client, policy_id=policy_id)
76
+
77
+ def _run(self, **kwargs: Any) -> Any:
78
+ ctx: dict[str, Any] = {}
79
+ if self._policy_id:
80
+ ctx["policy_id"] = self._policy_id
81
+
82
+ request = ClearanceRequest(
83
+ tool_name=self._inner_tool.name,
84
+ tool_args=kwargs,
85
+ agent_id=self._client.config.agent_id,
86
+ session_id=self._client.config.session_id,
87
+ context=ctx,
88
+ )
89
+
90
+ try:
91
+ self._client.request_clearance(request)
92
+ except ClearanceDeniedError as exc:
93
+ return f"BLOCKED: Vault denied this action — {exc.reason}"
94
+
95
+ return self._inner_tool._run(**kwargs)
@@ -0,0 +1,156 @@
1
+ # Ledgix ALCV — LangChain Adapter
2
+ # Provides a callback handler and tool wrapper for LangChain integration
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from ..client import LedgixClient
9
+ from ..exceptions import ClearanceDeniedError
10
+ from ..models import ClearanceRequest
11
+
12
+ try:
13
+ from langchain_core.callbacks import BaseCallbackHandler
14
+ from langchain_core.tools import BaseTool, ToolException
15
+ except ImportError as exc:
16
+ raise ImportError(
17
+ "LangChain adapter requires langchain-core. "
18
+ "Install with: pip install ledgix-python[langchain]"
19
+ ) from exc
20
+
21
+
22
+ class LedgixCallbackHandler(BaseCallbackHandler):
23
+ """LangChain callback handler that intercepts tool calls for Vault clearance.
24
+
25
+ Usage::
26
+
27
+ from ledgix_python.adapters.langchain import LedgixCallbackHandler
28
+
29
+ handler = LedgixCallbackHandler(client)
30
+ agent = create_agent(callbacks=[handler])
31
+ """
32
+
33
+ def __init__(self, client: LedgixClient, *, policy_id: str | None = None) -> None:
34
+ self.client = client
35
+ self.policy_id = policy_id
36
+
37
+ def on_tool_start(
38
+ self,
39
+ serialized: dict[str, Any],
40
+ input_str: str,
41
+ *,
42
+ run_id: Any = None,
43
+ parent_run_id: Any = None,
44
+ tags: list[str] | None = None,
45
+ metadata: dict[str, Any] | None = None,
46
+ inputs: dict[str, Any] | None = None,
47
+ **kwargs: Any,
48
+ ) -> None:
49
+ """Intercept tool start and request Vault clearance."""
50
+ tool_name = serialized.get("name", "unknown_tool")
51
+ tool_args = inputs or {"input": input_str}
52
+
53
+ ctx: dict[str, Any] = {}
54
+ if self.policy_id:
55
+ ctx["policy_id"] = self.policy_id
56
+ if metadata:
57
+ ctx["langchain_metadata"] = metadata
58
+
59
+ request = ClearanceRequest(
60
+ tool_name=tool_name,
61
+ tool_args=tool_args,
62
+ agent_id=self.client.config.agent_id,
63
+ session_id=self.client.config.session_id,
64
+ context=ctx,
65
+ )
66
+
67
+ # This will raise ClearanceDeniedError if denied
68
+ self.client.request_clearance(request)
69
+
70
+
71
+ class LedgixTool(BaseTool):
72
+ """Wraps an existing LangChain tool with Vault clearance enforcement.
73
+
74
+ Usage::
75
+
76
+ from langchain_community.tools import SomeTool
77
+ from ledgix_python.adapters.langchain import LedgixTool
78
+
79
+ guarded_tool = LedgixTool.wrap(client, SomeTool(), policy_id="refund-policy")
80
+ """
81
+
82
+ name: str = ""
83
+ description: str = ""
84
+ _inner_tool: BaseTool
85
+ _client: LedgixClient
86
+ _policy_id: str | None
87
+
88
+ class Config:
89
+ arbitrary_types_allowed = True
90
+ underscore_attrs_are_private = True
91
+
92
+ def __init__(
93
+ self,
94
+ inner_tool: BaseTool,
95
+ client: LedgixClient,
96
+ *,
97
+ policy_id: str | None = None,
98
+ ) -> None:
99
+ super().__init__(
100
+ name=f"ledgix_{inner_tool.name}",
101
+ description=inner_tool.description,
102
+ )
103
+ self._inner_tool = inner_tool
104
+ self._client = client
105
+ self._policy_id = policy_id
106
+
107
+ @classmethod
108
+ def wrap(
109
+ cls,
110
+ client: LedgixClient,
111
+ tool: BaseTool,
112
+ *,
113
+ policy_id: str | None = None,
114
+ ) -> LedgixTool:
115
+ """Convenience factory to wrap a tool."""
116
+ return cls(inner_tool=tool, client=client, policy_id=policy_id)
117
+
118
+ def _run(self, *args: Any, **kwargs: Any) -> Any:
119
+ ctx: dict[str, Any] = {}
120
+ if self._policy_id:
121
+ ctx["policy_id"] = self._policy_id
122
+
123
+ request = ClearanceRequest(
124
+ tool_name=self._inner_tool.name,
125
+ tool_args=kwargs or ({"input": args[0]} if args else {}),
126
+ agent_id=self._client.config.agent_id,
127
+ session_id=self._client.config.session_id,
128
+ context=ctx,
129
+ )
130
+
131
+ try:
132
+ self._client.request_clearance(request)
133
+ except ClearanceDeniedError as exc:
134
+ raise ToolException(f"Vault denied: {exc.reason}") from exc
135
+
136
+ return self._inner_tool._run(*args, **kwargs)
137
+
138
+ async def _arun(self, *args: Any, **kwargs: Any) -> Any:
139
+ ctx: dict[str, Any] = {}
140
+ if self._policy_id:
141
+ ctx["policy_id"] = self._policy_id
142
+
143
+ request = ClearanceRequest(
144
+ tool_name=self._inner_tool.name,
145
+ tool_args=kwargs or ({"input": args[0]} if args else {}),
146
+ agent_id=self._client.config.agent_id,
147
+ session_id=self._client.config.session_id,
148
+ context=ctx,
149
+ )
150
+
151
+ try:
152
+ await self._client.arequest_clearance(request)
153
+ except ClearanceDeniedError as exc:
154
+ raise ToolException(f"Vault denied: {exc.reason}") from exc
155
+
156
+ return await self._inner_tool._arun(*args, **kwargs)
@@ -0,0 +1,85 @@
1
+ # Ledgix ALCV — LlamaIndex Adapter
2
+ # Wraps LlamaIndex tools with Vault clearance enforcement
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from ..client import LedgixClient
9
+ from ..exceptions import ClearanceDeniedError
10
+ from ..models import ClearanceRequest
11
+
12
+ try:
13
+ from llama_index.core.tools import FunctionTool, ToolMetadata, ToolOutput
14
+ except ImportError as exc:
15
+ raise ImportError(
16
+ "LlamaIndex adapter requires llama-index-core. "
17
+ "Install with: pip install ledgix-python[llamaindex]"
18
+ ) from exc
19
+
20
+
21
+ class LedgixToolWrapper:
22
+ """Wraps a LlamaIndex tool with Vault clearance enforcement.
23
+
24
+ Usage::
25
+
26
+ from llama_index.core.tools import FunctionTool
27
+ from ledgix_python.adapters.llamaindex import LedgixToolWrapper
28
+
29
+ def my_tool(query: str) -> str:
30
+ return f"Result for {query}"
31
+
32
+ tool = FunctionTool.from_defaults(fn=my_tool, name="search")
33
+ guarded = LedgixToolWrapper(client, tool)
34
+
35
+ # Use guarded.tool in your LlamaIndex agent
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ client: LedgixClient,
41
+ tool: FunctionTool,
42
+ *,
43
+ policy_id: str | None = None,
44
+ ) -> None:
45
+ self._client = client
46
+ self._inner_tool = tool
47
+ self._policy_id = policy_id
48
+
49
+ # Create the wrapped tool
50
+ self.tool = FunctionTool.from_defaults(
51
+ fn=self._guarded_call,
52
+ name=f"ledgix_{tool.metadata.name}",
53
+ description=tool.metadata.description or "",
54
+ )
55
+
56
+ def _guarded_call(self, **kwargs: Any) -> Any:
57
+ """Wrapper that requests clearance before calling the inner tool."""
58
+ ctx: dict[str, Any] = {}
59
+ if self._policy_id:
60
+ ctx["policy_id"] = self._policy_id
61
+
62
+ request = ClearanceRequest(
63
+ tool_name=self._inner_tool.metadata.name,
64
+ tool_args=kwargs,
65
+ agent_id=self._client.config.agent_id,
66
+ session_id=self._client.config.session_id,
67
+ context=ctx,
68
+ )
69
+
70
+ self._client.request_clearance(request)
71
+ return self._inner_tool.call(**kwargs)
72
+
73
+
74
+ def wrap_tool(
75
+ client: LedgixClient,
76
+ tool: FunctionTool,
77
+ *,
78
+ policy_id: str | None = None,
79
+ ) -> FunctionTool:
80
+ """Convenience function to wrap a LlamaIndex tool.
81
+
82
+ Returns the guarded FunctionTool ready for use in an agent.
83
+ """
84
+ wrapper = LedgixToolWrapper(client, tool, policy_id=policy_id)
85
+ return wrapper.tool
@@ -0,0 +1,314 @@
1
+ # Ledgix ALCV — Client
2
+ # Sync + async HTTP client for Vault communication and A-JWT verification
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ import httpx
10
+ import jwt
11
+
12
+ from .config import VaultConfig
13
+ from .exceptions import (
14
+ ClearanceDeniedError,
15
+ PolicyRegistrationError,
16
+ TokenVerificationError,
17
+ VaultConnectionError,
18
+ )
19
+ from .models import (
20
+ ClearanceRequest,
21
+ ClearanceResponse,
22
+ PolicyRegistration,
23
+ PolicyRegistrationResponse,
24
+ )
25
+
26
+
27
+ class LedgixClient:
28
+ """Sync + async client for the ALCV Vault.
29
+
30
+ Usage (sync)::
31
+
32
+ client = LedgixClient()
33
+ resp = client.request_clearance(ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45}))
34
+
35
+ Usage (async)::
36
+
37
+ client = LedgixClient()
38
+ resp = await client.arequest_clearance(ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45}))
39
+ """
40
+
41
+ def __init__(self, config: VaultConfig | None = None) -> None:
42
+ self.config = config or VaultConfig()
43
+ self._sync_client: httpx.Client | None = None
44
+ self._async_client: httpx.AsyncClient | None = None
45
+ self._jwks_cache: dict[str, Any] | None = None
46
+
47
+ # ------------------------------------------------------------------
48
+ # Internal HTTP helpers
49
+ # ------------------------------------------------------------------
50
+
51
+ def _headers(self) -> dict[str, str]:
52
+ headers: dict[str, str] = {"Content-Type": "application/json"}
53
+ if self.config.vault_api_key:
54
+ headers["X-Vault-API-Key"] = self.config.vault_api_key
55
+ return headers
56
+
57
+ def _get_sync_client(self) -> httpx.Client:
58
+ if self._sync_client is None or self._sync_client.is_closed:
59
+ self._sync_client = httpx.Client(
60
+ base_url=self.config.vault_url,
61
+ headers=self._headers(),
62
+ timeout=self.config.vault_timeout,
63
+ )
64
+ return self._sync_client
65
+
66
+ def _get_async_client(self) -> httpx.AsyncClient:
67
+ if self._async_client is None or self._async_client.is_closed:
68
+ self._async_client = httpx.AsyncClient(
69
+ base_url=self.config.vault_url,
70
+ headers=self._headers(),
71
+ timeout=self.config.vault_timeout,
72
+ )
73
+ return self._async_client
74
+
75
+ # ------------------------------------------------------------------
76
+ # Clearance — sync
77
+ # ------------------------------------------------------------------
78
+
79
+ def request_clearance(self, request: ClearanceRequest) -> ClearanceResponse:
80
+ """Send a clearance request to the Vault (sync).
81
+
82
+ Raises:
83
+ ClearanceDeniedError: If the Vault denies the request.
84
+ VaultConnectionError: If the Vault is unreachable.
85
+ """
86
+ try:
87
+ response = self._get_sync_client().post(
88
+ "/request-clearance",
89
+ content=request.model_dump_json(),
90
+ )
91
+ response.raise_for_status()
92
+ except httpx.ConnectError as exc:
93
+ raise VaultConnectionError(str(exc)) from exc
94
+ except httpx.HTTPStatusError as exc:
95
+ raise VaultConnectionError(
96
+ f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
97
+ ) from exc
98
+
99
+ clearance = ClearanceResponse.model_validate(response.json())
100
+
101
+ if not clearance.approved:
102
+ raise ClearanceDeniedError(
103
+ reason=clearance.reason,
104
+ request_id=clearance.request_id,
105
+ )
106
+
107
+ if self.config.verify_jwt and clearance.token:
108
+ self.verify_token(clearance.token)
109
+
110
+ return clearance
111
+
112
+ # ------------------------------------------------------------------
113
+ # Clearance — async
114
+ # ------------------------------------------------------------------
115
+
116
+ async def arequest_clearance(self, request: ClearanceRequest) -> ClearanceResponse:
117
+ """Send a clearance request to the Vault (async).
118
+
119
+ Raises:
120
+ ClearanceDeniedError: If the Vault denies the request.
121
+ VaultConnectionError: If the Vault is unreachable.
122
+ """
123
+ try:
124
+ response = await self._get_async_client().post(
125
+ "/request-clearance",
126
+ content=request.model_dump_json(),
127
+ )
128
+ response.raise_for_status()
129
+ except httpx.ConnectError as exc:
130
+ raise VaultConnectionError(str(exc)) from exc
131
+ except httpx.HTTPStatusError as exc:
132
+ raise VaultConnectionError(
133
+ f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
134
+ ) from exc
135
+
136
+ clearance = ClearanceResponse.model_validate(response.json())
137
+
138
+ if not clearance.approved:
139
+ raise ClearanceDeniedError(
140
+ reason=clearance.reason,
141
+ request_id=clearance.request_id,
142
+ )
143
+
144
+ if self.config.verify_jwt and clearance.token:
145
+ await self.averify_token(clearance.token)
146
+
147
+ return clearance
148
+
149
+ # ------------------------------------------------------------------
150
+ # Policy registration
151
+ # ------------------------------------------------------------------
152
+
153
+ def register_policy(self, policy: PolicyRegistration) -> PolicyRegistrationResponse:
154
+ """Register a policy with the Vault (sync)."""
155
+ try:
156
+ response = self._get_sync_client().post(
157
+ "/register-policy",
158
+ content=policy.model_dump_json(),
159
+ )
160
+ response.raise_for_status()
161
+ except httpx.ConnectError as exc:
162
+ raise VaultConnectionError(str(exc)) from exc
163
+ except httpx.HTTPStatusError as exc:
164
+ raise PolicyRegistrationError(
165
+ f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
166
+ ) from exc
167
+
168
+ return PolicyRegistrationResponse.model_validate(response.json())
169
+
170
+ async def aregister_policy(self, policy: PolicyRegistration) -> PolicyRegistrationResponse:
171
+ """Register a policy with the Vault (async)."""
172
+ try:
173
+ response = await self._get_async_client().post(
174
+ "/register-policy",
175
+ content=policy.model_dump_json(),
176
+ )
177
+ response.raise_for_status()
178
+ except httpx.ConnectError as exc:
179
+ raise VaultConnectionError(str(exc)) from exc
180
+ except httpx.HTTPStatusError as exc:
181
+ raise PolicyRegistrationError(
182
+ f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
183
+ ) from exc
184
+
185
+ return PolicyRegistrationResponse.model_validate(response.json())
186
+
187
+ # ------------------------------------------------------------------
188
+ # JWKS + A-JWT verification
189
+ # ------------------------------------------------------------------
190
+
191
+ def fetch_jwks(self) -> dict[str, Any]:
192
+ """Fetch the Vault's JWKS (JSON Web Key Set) for token verification (sync)."""
193
+ try:
194
+ response = self._get_sync_client().get("/.well-known/jwks.json")
195
+ response.raise_for_status()
196
+ except httpx.ConnectError as exc:
197
+ raise VaultConnectionError(str(exc)) from exc
198
+ except httpx.HTTPStatusError as exc:
199
+ raise VaultConnectionError(
200
+ f"Failed to fetch JWKS: HTTP {exc.response.status_code}"
201
+ ) from exc
202
+
203
+ self._jwks_cache = response.json()
204
+ return self._jwks_cache
205
+
206
+ async def afetch_jwks(self) -> dict[str, Any]:
207
+ """Fetch the Vault's JWKS for token verification (async)."""
208
+ try:
209
+ response = await self._get_async_client().get("/.well-known/jwks.json")
210
+ response.raise_for_status()
211
+ except httpx.ConnectError as exc:
212
+ raise VaultConnectionError(str(exc)) from exc
213
+ except httpx.HTTPStatusError as exc:
214
+ raise VaultConnectionError(
215
+ f"Failed to fetch JWKS: HTTP {exc.response.status_code}"
216
+ ) from exc
217
+
218
+ self._jwks_cache = response.json()
219
+ return self._jwks_cache
220
+
221
+ def verify_token(self, token: str) -> dict[str, Any]:
222
+ """Verify an A-JWT using the Vault's public key (sync).
223
+
224
+ Returns the decoded token payload on success.
225
+
226
+ Raises:
227
+ TokenVerificationError: If the token is invalid, expired, or
228
+ the JWKS cannot be fetched.
229
+ """
230
+ return self._verify_token_internal(token, sync=True)
231
+
232
+ 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.
238
+
239
+ Note: For async callers this is still synchronous internally
240
+ because PyJWT is sync. The async variant pre-fetches JWKS
241
+ asynchronously before calling this.
242
+ """
243
+ if self._jwks_cache is None:
244
+ if sync:
245
+ self.fetch_jwks()
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()
250
+
251
+ if not self._jwks_cache:
252
+ raise TokenVerificationError("No JWKS available from Vault")
253
+
254
+ try:
255
+ # Extract the first key from the JWKS
256
+ jwks = self._jwks_cache
257
+ if "keys" not in jwks or not jwks["keys"]:
258
+ raise TokenVerificationError("JWKS contains no keys")
259
+
260
+ # Build a PyJWT key from the JWK
261
+ key_data = jwks["keys"][0]
262
+ public_key = jwt.algorithms.OKPAlgorithm.from_jwk(json.dumps(key_data))
263
+
264
+ decoded = jwt.decode(
265
+ token,
266
+ public_key,
267
+ algorithms=["EdDSA"],
268
+ options={"verify_exp": True},
269
+ )
270
+ return decoded
271
+
272
+ except jwt.ExpiredSignatureError as exc:
273
+ raise TokenVerificationError("A-JWT has expired") from exc
274
+ except jwt.InvalidTokenError as exc:
275
+ raise TokenVerificationError(f"Invalid A-JWT: {exc}") from exc
276
+ except Exception as exc:
277
+ raise TokenVerificationError(f"Token verification failed: {exc}") from exc
278
+
279
+ # ------------------------------------------------------------------
280
+ # Lifecycle
281
+ # ------------------------------------------------------------------
282
+
283
+ def close(self) -> None:
284
+ """Close the underlying HTTP clients."""
285
+ if self._sync_client and not self._sync_client.is_closed:
286
+ self._sync_client.close()
287
+ if self._async_client and not self._async_client.is_closed:
288
+ # Can't await in sync context; schedule close if event loop exists
289
+ import asyncio
290
+
291
+ try:
292
+ loop = asyncio.get_running_loop()
293
+ loop.create_task(self._async_client.aclose())
294
+ except RuntimeError:
295
+ pass # No running loop; client will be GC'd
296
+
297
+ async def aclose(self) -> None:
298
+ """Close the underlying HTTP clients (async)."""
299
+ if self._sync_client and not self._sync_client.is_closed:
300
+ self._sync_client.close()
301
+ if self._async_client and not self._async_client.is_closed:
302
+ await self._async_client.aclose()
303
+
304
+ def __enter__(self) -> LedgixClient:
305
+ return self
306
+
307
+ def __exit__(self, *args: Any) -> None:
308
+ self.close()
309
+
310
+ async def __aenter__(self) -> LedgixClient:
311
+ return self
312
+
313
+ async def __aexit__(self, *args: Any) -> None:
314
+ await self.aclose()
@@ -0,0 +1,39 @@
1
+ # Ledgix ALCV — Configuration
2
+ # Environment-driven configuration via pydantic-settings
3
+
4
+ from __future__ import annotations
5
+
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class VaultConfig(BaseSettings):
10
+ """Configuration for connecting to the ALCV Vault.
11
+
12
+ Values are loaded from environment variables prefixed with ``LEDGIX_``,
13
+ e.g. ``LEDGIX_VAULT_URL``, or can be passed directly to the constructor.
14
+ """
15
+
16
+ model_config = SettingsConfigDict(
17
+ env_prefix="LEDGIX_",
18
+ env_file=".env",
19
+ env_file_encoding="utf-8",
20
+ extra="ignore",
21
+ )
22
+
23
+ vault_url: str = "http://localhost:8000"
24
+ """Base URL of the ALCV Vault server."""
25
+
26
+ vault_api_key: str = ""
27
+ """API key sent as ``X-Vault-API-Key`` header for Shim→Vault auth."""
28
+
29
+ vault_timeout: float = 30.0
30
+ """HTTP request timeout in seconds."""
31
+
32
+ verify_jwt: bool = True
33
+ """Whether to verify A-JWTs returned by the Vault using its JWKS endpoint."""
34
+
35
+ agent_id: str = "default-agent"
36
+ """Identifier for the agent using this SDK instance."""
37
+
38
+ session_id: str = ""
39
+ """Optional session identifier for grouping related clearance requests."""
@@ -0,0 +1,164 @@
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 functools
8
+ import inspect
9
+ from typing import Any, Callable, TypeVar
10
+
11
+ from .client import LedgixClient
12
+ from .exceptions import ClearanceDeniedError
13
+ from .models import ClearanceRequest, ClearanceResponse
14
+
15
+ F = TypeVar("F", bound=Callable[..., Any])
16
+
17
+
18
+ class VaultContext:
19
+ """Context manager that requests clearance before executing a block.
20
+
21
+ Sync usage::
22
+
23
+ with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
24
+ # ctx.clearance contains the ClearanceResponse
25
+ execute_refund(ctx.clearance.token)
26
+
27
+ Async usage::
28
+
29
+ async with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
30
+ execute_refund(ctx.clearance.token)
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ client: LedgixClient,
36
+ tool_name: str,
37
+ tool_args: dict[str, Any] | None = None,
38
+ *,
39
+ context: dict[str, Any] | None = None,
40
+ policy_id: str | None = None,
41
+ ) -> None:
42
+ self.client = client
43
+ self.tool_name = tool_name
44
+ self.tool_args = tool_args or {}
45
+ self.context = context or {}
46
+ self.policy_id = policy_id
47
+ self.clearance: ClearanceResponse | None = None
48
+
49
+ def _build_request(self) -> ClearanceRequest:
50
+ ctx = {**self.context}
51
+ if self.policy_id:
52
+ ctx["policy_id"] = self.policy_id
53
+ return ClearanceRequest(
54
+ tool_name=self.tool_name,
55
+ tool_args=self.tool_args,
56
+ agent_id=self.client.config.agent_id,
57
+ session_id=self.client.config.session_id,
58
+ context=ctx,
59
+ )
60
+
61
+ # Sync context manager
62
+ def __enter__(self) -> VaultContext:
63
+ request = self._build_request()
64
+ self.clearance = self.client.request_clearance(request)
65
+ return self
66
+
67
+ def __exit__(self, *args: Any) -> None:
68
+ pass
69
+
70
+ # Async context manager
71
+ async def __aenter__(self) -> VaultContext:
72
+ request = self._build_request()
73
+ self.clearance = await self.client.arequest_clearance(request)
74
+ return self
75
+
76
+ async def __aexit__(self, *args: Any) -> None:
77
+ pass
78
+
79
+
80
+ def vault_enforce(
81
+ client: LedgixClient,
82
+ *,
83
+ tool_name: str | None = None,
84
+ policy_id: str | None = None,
85
+ context: dict[str, Any] | None = None,
86
+ ) -> Callable[[F], F]:
87
+ """Decorator that enforces Vault clearance before a function executes.
88
+
89
+ Works with both sync and async functions automatically.
90
+
91
+ Usage::
92
+
93
+ @vault_enforce(client, tool_name="stripe_refund")
94
+ def process_refund(amount: float, reason: str):
95
+ # This only runs if the Vault approves
96
+ stripe.refund(amount=amount, reason=reason)
97
+
98
+ @vault_enforce(client, tool_name="stripe_refund")
99
+ async def async_process_refund(amount: float, reason: str):
100
+ await stripe.refund(amount=amount, reason=reason)
101
+
102
+ The decorated function receives an injected ``_clearance`` keyword
103
+ argument containing the ``ClearanceResponse`` (with the A-JWT token).
104
+ """
105
+
106
+ def decorator(func: F) -> F:
107
+ resolved_name = tool_name or func.__name__
108
+
109
+ if inspect.iscoroutinefunction(func):
110
+
111
+ @functools.wraps(func)
112
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
113
+ request = ClearanceRequest(
114
+ tool_name=resolved_name,
115
+ tool_args=_extract_tool_args(func, args, kwargs),
116
+ agent_id=client.config.agent_id,
117
+ session_id=client.config.session_id,
118
+ context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
119
+ )
120
+ clearance = await client.arequest_clearance(request)
121
+ kwargs["_clearance"] = clearance
122
+ return await func(*args, **kwargs)
123
+
124
+ return async_wrapper # type: ignore[return-value]
125
+
126
+ else:
127
+
128
+ @functools.wraps(func)
129
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
130
+ request = ClearanceRequest(
131
+ tool_name=resolved_name,
132
+ tool_args=_extract_tool_args(func, args, kwargs),
133
+ agent_id=client.config.agent_id,
134
+ session_id=client.config.session_id,
135
+ context={**(context or {}), **({"policy_id": policy_id} if policy_id else {})},
136
+ )
137
+ clearance = client.request_clearance(request)
138
+ kwargs["_clearance"] = clearance
139
+ return func(*args, **kwargs)
140
+
141
+ return sync_wrapper # type: ignore[return-value]
142
+
143
+ return decorator
144
+
145
+
146
+ def _extract_tool_args(
147
+ func: Callable[..., Any],
148
+ args: tuple[Any, ...],
149
+ kwargs: dict[str, Any],
150
+ ) -> dict[str, Any]:
151
+ """Best-effort extraction of function arguments as a dict for the clearance request."""
152
+ try:
153
+ sig = inspect.signature(func)
154
+ bound = sig.bind_partial(*args, **kwargs)
155
+ bound.apply_defaults()
156
+ # Filter out internal kwargs
157
+ return {
158
+ k: v
159
+ for k, v in bound.arguments.items()
160
+ if not k.startswith("_") and k != "self"
161
+ }
162
+ except Exception:
163
+ # Fallback: just return kwargs
164
+ return {k: v for k, v in kwargs.items() if not k.startswith("_")}
@@ -0,0 +1,44 @@
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 VaultConnectionError(LedgixError):
27
+ """Raised when the SDK cannot reach the Vault server."""
28
+
29
+ def __init__(self, message: str = "Unable to connect to the Vault server") -> None:
30
+ super().__init__(message)
31
+
32
+
33
+ class TokenVerificationError(LedgixError):
34
+ """Raised when A-JWT verification fails (bad signature, expired, etc.)."""
35
+
36
+ def __init__(self, message: str = "Token verification failed") -> None:
37
+ super().__init__(message)
38
+
39
+
40
+ class PolicyRegistrationError(LedgixError):
41
+ """Raised when a policy registration request fails."""
42
+
43
+ def __init__(self, message: str = "Policy registration failed") -> None:
44
+ super().__init__(message)
@@ -0,0 +1,56 @@
1
+ # Ledgix ALCV — Data Models
2
+ # Pydantic models for Vault API request/response payloads
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class ClearanceRequest(BaseModel):
12
+ """Payload sent to the Vault's ``/request-clearance`` endpoint."""
13
+
14
+ tool_name: str = Field(..., description="Name of the tool the agent wants to invoke")
15
+ tool_args: dict[str, Any] = Field(
16
+ default_factory=dict,
17
+ description="Arguments the agent will pass to the tool",
18
+ )
19
+ agent_id: str = Field(default="default-agent", description="Identifier for the calling agent")
20
+ session_id: str = Field(default="", description="Session grouping identifier")
21
+ context: dict[str, Any] = Field(
22
+ default_factory=dict,
23
+ description="Additional context for the Vault's policy judge (e.g. conversation history)",
24
+ )
25
+
26
+
27
+ class ClearanceResponse(BaseModel):
28
+ """Response from the Vault's ``/request-clearance`` endpoint."""
29
+
30
+ approved: bool = Field(..., description="Whether the tool call was approved")
31
+ token: str | None = Field(default=None, description="Signed A-JWT if approved, None if denied")
32
+ reason: str = Field(default="", description="Human-readable explanation of the decision")
33
+ request_id: str = Field(default="", description="Vault-assigned unique ID for this request")
34
+
35
+
36
+ class PolicyRegistration(BaseModel):
37
+ """Payload for registering a policy with the Vault."""
38
+
39
+ policy_id: str = Field(..., description="Unique identifier for the policy")
40
+ description: str = Field(default="", description="Human-readable description of the policy")
41
+ rules: list[str] = Field(
42
+ default_factory=list,
43
+ description="List of plain-English rules (e.g. 'Refunds must not exceed $100')",
44
+ )
45
+ tools: list[str] = Field(
46
+ default_factory=list,
47
+ description="Tool names this policy applies to (empty = all tools)",
48
+ )
49
+
50
+
51
+ class PolicyRegistrationResponse(BaseModel):
52
+ """Response from the Vault's ``/register-policy`` endpoint."""
53
+
54
+ policy_id: str = Field(..., description="Confirmed policy ID")
55
+ status: str = Field(default="registered", description="Registration status")
56
+ message: str = Field(default="", description="Additional information")
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: ledgix-python
3
+ Version: 0.1.0
4
+ Summary: Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault
5
+ Project-URL: Homepage, https://github.com/ledgix-dev/python-sdk
6
+ Project-URL: Documentation, https://docs.ledgix.dev
7
+ Project-URL: Repository, https://github.com/ledgix-dev/python-sdk
8
+ Author-email: Ledgix <team@ledgix.dev>
9
+ License: MIT
10
+ Keywords: agents,ai,compliance,security,sox404,vault
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.25.0
24
+ Requires-Dist: pydantic-settings>=2.0.0
25
+ Requires-Dist: pydantic>=2.0.0
26
+ Requires-Dist: pyjwt[crypto]>=2.8.0
27
+ Provides-Extra: crewai
28
+ Requires-Dist: crewai>=0.1.0; extra == 'crewai'
29
+ Provides-Extra: dev
30
+ Requires-Dist: build>=1.0.0; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
33
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
34
+ Requires-Dist: respx>=0.21.0; extra == 'dev'
35
+ Provides-Extra: langchain
36
+ Requires-Dist: langchain-core>=0.1.0; extra == 'langchain'
37
+ Provides-Extra: llamaindex
38
+ Requires-Dist: llama-index-core>=0.10.0; extra == 'llamaindex'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # Ledgix ALCV — Python SDK
42
+
43
+ [![PyPI](https://img.shields.io/pypi/v/ledgix-python)](https://pypi.org/project/ledgix-python/)
44
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://python.org)
45
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
46
+
47
+ Agent-agnostic compliance shim for SOX 404 policy enforcement. Intercepts AI agent tool calls, validates them against your policies via the ALCV Vault, and ensures only approved actions receive a cryptographically signed A-JWT (Agentic JSON Web Token).
48
+
49
+ ## Quick Start
50
+
51
+ ```bash
52
+ pip install ledgix-python
53
+ ```
54
+
55
+ ```python
56
+ from ledgix_python import LedgixClient, vault_enforce
57
+
58
+ client = LedgixClient() # Reads LEDGIX_VAULT_URL, LEDGIX_VAULT_API_KEY from env
59
+
60
+ @vault_enforce(client, tool_name="stripe_refund")
61
+ def process_refund(amount: float, reason: str, **kwargs):
62
+ token = kwargs["_clearance"].token # Signed A-JWT
63
+ return stripe.refund(amount=amount, metadata={"vault_token": token})
64
+ ```
65
+
66
+ **That's it.** Three lines to SOX 404-compliant tool calls.
67
+
68
+ ## Configuration
69
+
70
+ Set environment variables (prefix: `LEDGIX_`):
71
+
72
+ | Variable | Default | Description |
73
+ |---|---|---|
74
+ | `LEDGIX_VAULT_URL` | `http://localhost:8000` | Vault server URL |
75
+ | `LEDGIX_VAULT_API_KEY` | `""` | API key for Vault auth |
76
+ | `LEDGIX_VAULT_TIMEOUT` | `30.0` | Request timeout (seconds) |
77
+ | `LEDGIX_VERIFY_JWT` | `true` | Verify A-JWT signatures |
78
+ | `LEDGIX_AGENT_ID` | `default-agent` | Agent identifier |
79
+
80
+ Or pass a `VaultConfig` directly:
81
+
82
+ ```python
83
+ from ledgix_python import LedgixClient, VaultConfig
84
+
85
+ config = VaultConfig(vault_url="https://vault.mycompany.com", vault_api_key="sk-...")
86
+ client = LedgixClient(config=config)
87
+ ```
88
+
89
+ ## Framework Adapters
90
+
91
+ ### LangChain
92
+
93
+ ```bash
94
+ pip install ledgix-python[langchain]
95
+ ```
96
+
97
+ ```python
98
+ from ledgix_python.adapters.langchain import LedgixCallbackHandler, LedgixTool
99
+
100
+ # Option 1: Callback handler (intercepts ALL tool calls)
101
+ handler = LedgixCallbackHandler(client)
102
+ agent = create_agent(callbacks=[handler])
103
+
104
+ # Option 2: Wrap individual tools
105
+ guarded_tool = LedgixTool.wrap(client, my_tool, policy_id="refund-policy")
106
+ ```
107
+
108
+ ### LlamaIndex
109
+
110
+ ```bash
111
+ pip install ledgix-python[llamaindex]
112
+ ```
113
+
114
+ ```python
115
+ from ledgix_python.adapters.llamaindex import wrap_tool
116
+
117
+ guarded_tool = wrap_tool(client, my_function_tool, policy_id="refund-policy")
118
+ ```
119
+
120
+ ### CrewAI
121
+
122
+ ```bash
123
+ pip install ledgix-python[crewai]
124
+ ```
125
+
126
+ ```python
127
+ from ledgix_python.adapters.crewai import LedgixCrewAITool
128
+
129
+ guarded_tool = LedgixCrewAITool.wrap(client, my_tool, policy_id="refund-policy")
130
+ ```
131
+
132
+ ## Context Manager
133
+
134
+ ```python
135
+ from ledgix_python import VaultContext
136
+
137
+ with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
138
+ print(ctx.clearance.token) # Use the A-JWT
139
+
140
+ # Async
141
+ async with VaultContext(client, "stripe_refund", {"amount": 45}) as ctx:
142
+ print(ctx.clearance.token)
143
+ ```
144
+
145
+ ## Error Handling
146
+
147
+ ```python
148
+ from ledgix_python import ClearanceDeniedError, VaultConnectionError, TokenVerificationError
149
+
150
+ try:
151
+ result = process_refund(amount=5000, reason="...")
152
+ except ClearanceDeniedError as e:
153
+ print(f"Blocked: {e.reason} (request: {e.request_id})")
154
+ except VaultConnectionError:
155
+ print("Cannot reach Vault — fail-closed")
156
+ except TokenVerificationError:
157
+ print("A-JWT signature invalid")
158
+ ```
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ git clone https://github.com/ledgix-dev/python-sdk.git
164
+ cd python-sdk
165
+ python -m venv .venv && source .venv/bin/activate
166
+ pip install -e ".[dev]"
167
+ pytest tests/ -v --cov
168
+ ```
169
+
170
+ ## Demo
171
+
172
+ ```bash
173
+ python demo.py
174
+ ```
175
+
176
+ ## License
177
+
178
+ MIT
@@ -0,0 +1,13 @@
1
+ ledgix_python/__init__.py,sha256=ljYh7w1vbtixarFcR7gThL4Zxi48nzytEKq_v87WtQU,1277
2
+ ledgix_python/client.py,sha256=P4NJc7cTBmgf-r863uxA-OJcKFhcOnuvtzL1UjwF79k,11682
3
+ ledgix_python/config.py,sha256=RrAJ9ilbDio_jLp4EsbBUFOvEFSx9k302wqA1hO39Fk,1186
4
+ ledgix_python/enforce.py,sha256=PaYE9WkgujU02NWAcoK6HnH7mf-2Gy3chezgZoMlbrw,5434
5
+ ledgix_python/exceptions.py,sha256=dhE-qyBctTpyY6omi82UMeLss-iIlrlHaM5qBzFrsv0,1338
6
+ ledgix_python/models.py,sha256=0SSQX2mNx2GF0SVwkVUgFdIRHjSaaDb2-tdCQ2nr2gM,2273
7
+ ledgix_python/adapters/__init__.py,sha256=qI-boxRb6Qgy7SkC3cTt1rXEH7geZzzV7ynNje-W6iQ,37
8
+ ledgix_python/adapters/crewai.py,sha256=9kgxq_38RM6JWI540e7_MaZtpO8o-zdwT9Xkijq-KJw,2598
9
+ ledgix_python/adapters/langchain.py,sha256=Wqb8kkgIwjHmxvsD79FTo9riYs8bGwV5wN9MoMDEkHk,4767
10
+ ledgix_python/adapters/llamaindex.py,sha256=ffJ8Y8ge_x4BjD8ezm7ZEnJDpcbiVIx11InhCiJadSY,2467
11
+ ledgix_python-0.1.0.dist-info/METADATA,sha256=s8sjMsBtzLd7bQ4l6hHoj-g1MdIA-azBvR2Mum0Io-k,5202
12
+ ledgix_python-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ ledgix_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any