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.
- ledgix_python/__init__.py +53 -0
- ledgix_python/adapters/__init__.py +1 -0
- ledgix_python/adapters/crewai.py +95 -0
- ledgix_python/adapters/langchain.py +156 -0
- ledgix_python/adapters/llamaindex.py +85 -0
- ledgix_python/client.py +314 -0
- ledgix_python/config.py +39 -0
- ledgix_python/enforce.py +164 -0
- ledgix_python/exceptions.py +44 -0
- ledgix_python/models.py +56 -0
- ledgix_python-0.1.0.dist-info/METADATA +178 -0
- ledgix_python-0.1.0.dist-info/RECORD +13 -0
- ledgix_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
ledgix_python/client.py
ADDED
|
@@ -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()
|
ledgix_python/config.py
ADDED
|
@@ -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."""
|
ledgix_python/enforce.py
ADDED
|
@@ -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)
|
ledgix_python/models.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/ledgix-python/)
|
|
44
|
+
[](https://python.org)
|
|
45
|
+
[](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,,
|