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/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
jwt_issuer: str = "alcv-vault"
|
|
36
|
+
"""Expected issuer for Vault A-JWTs."""
|
|
37
|
+
|
|
38
|
+
jwt_audience: str = "ledgix-sdk"
|
|
39
|
+
"""Expected audience for Vault A-JWTs."""
|
|
40
|
+
|
|
41
|
+
agent_id: str = "default-agent"
|
|
42
|
+
"""Identifier for the agent using this SDK instance."""
|
|
43
|
+
|
|
44
|
+
session_id: str = ""
|
|
45
|
+
"""Optional session identifier for grouping related clearance requests."""
|
|
46
|
+
|
|
47
|
+
review_poll_interval: float = 2.0
|
|
48
|
+
"""Polling interval in seconds while waiting for manual review."""
|
|
49
|
+
|
|
50
|
+
review_timeout: float = 900.0
|
|
51
|
+
"""Maximum wait time in seconds for a pending manual review decision."""
|
|
52
|
+
|
|
53
|
+
review_mode: str = "block"
|
|
54
|
+
"""How to handle pending manual reviews. ``"block"`` (default): poll until
|
|
55
|
+
a decision arrives or timeout expires. ``"detach"``: return a
|
|
56
|
+
:class:`~bylaw_python.PendingApproval` immediately so the caller can
|
|
57
|
+
resume later via :meth:`~bylaw_python.PendingApproval.wait_async`."""
|
|
58
|
+
|
|
59
|
+
max_retries: int = 3
|
|
60
|
+
"""Number of retry attempts for transient failures (connection errors, 5xx responses)."""
|
|
61
|
+
|
|
62
|
+
retry_base_delay: float = 0.5
|
|
63
|
+
"""Base delay in seconds for exponential backoff between retries (full jitter applied)."""
|
|
64
|
+
|
|
65
|
+
decision_cache_enabled: bool = False
|
|
66
|
+
"""Enable the in-process decision cache. Off by default — opt-in for safety.
|
|
67
|
+
When enabled, approved decisions are memoized; subsequent identical tool
|
|
68
|
+
calls skip the LLM judge and call /mint-token for a fresh A-JWT instead.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
decision_cache_ttl_seconds: float = 60.0
|
|
72
|
+
"""TTL (seconds) for cached decision envelopes."""
|
|
73
|
+
|
|
74
|
+
decision_cache_max_entries: int = 1000
|
|
75
|
+
"""Maximum number of decision envelopes to keep in memory."""
|
|
76
|
+
|
|
77
|
+
principal_id: str | None = None
|
|
78
|
+
"""Advisory OIDC ``sub`` of the human on whose behalf the agent acts.
|
|
79
|
+
Sent as ``human_principal`` in every clearance request. Can be overridden
|
|
80
|
+
per-call via ``on_behalf_of`` argument. Env: ``LEDGIX_PRINCIPAL_ID``."""
|
|
81
|
+
|
|
82
|
+
jwks_ttl_seconds: int = 300
|
|
83
|
+
"""How long (seconds) the cached JWKS is considered fresh before a key-miss
|
|
84
|
+
triggers a refetch. Default 5 minutes matches the Vault's rotation cadence.
|
|
85
|
+
Env: ``LEDGIX_JWKS_TTL_SECONDS``."""
|
|
86
|
+
|
|
87
|
+
replay_cache_size: int = 10_000
|
|
88
|
+
"""Maximum number of consumed A-JWT jtis held in the in-process replay
|
|
89
|
+
cache. When the limit is reached the oldest entries are evicted (LRU-TTL).
|
|
90
|
+
Env: ``LEDGIX_REPLAY_CACHE_SIZE``."""
|
|
91
|
+
|
|
92
|
+
max_token_lifetime_seconds: float = 330.0
|
|
93
|
+
"""TTL for entries in the replay cache (seconds). Should be at least
|
|
94
|
+
``VAULT_JWT_TTL + 30`` to cover clock skew. Default 330 = 5 min TTL + 30s.
|
|
95
|
+
Env: ``LEDGIX_MAX_TOKEN_LIFETIME_SECONDS``."""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Ledgix ALCV — Client-side counterparty hints
|
|
2
|
+
#
|
|
3
|
+
# Mirrors vault/internal/counterparty: best-effort extraction of the
|
|
4
|
+
# destination provider/URI/account from a tool name + tool_args dict.
|
|
5
|
+
# The Vault re-runs its own extractor chain, so this is a hint to
|
|
6
|
+
# pre-populate the wire fields when the SDK has unambiguous signal.
|
|
7
|
+
#
|
|
8
|
+
# Caller-supplied destination_* always wins on both sides of the wire.
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_PROVIDER_HOST_PREFIXES = ("www.", "api.", "api-")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _provider_from_host(host: str) -> str:
|
|
20
|
+
"""Strip common gateway prefixes so regional subdomains group together.
|
|
21
|
+
Does not attempt eTLD+1 parsing — provider taxonomy is curated upstream."""
|
|
22
|
+
host = host.lower().split(":", 1)[0]
|
|
23
|
+
for prefix in _PROVIDER_HOST_PREFIXES:
|
|
24
|
+
if host.startswith(prefix):
|
|
25
|
+
return host[len(prefix):]
|
|
26
|
+
return host
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _string_arg(tool_args: dict[str, Any], key: str) -> str:
|
|
30
|
+
value = tool_args.get(key)
|
|
31
|
+
return value if isinstance(value, str) else ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _stripe(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
35
|
+
if "stripe" not in tool_name:
|
|
36
|
+
return None
|
|
37
|
+
out: dict[str, str] = {
|
|
38
|
+
"destination_uri": "https://api.stripe.com",
|
|
39
|
+
"destination_provider": "stripe",
|
|
40
|
+
}
|
|
41
|
+
api_key = _string_arg(tool_args, "api_key")
|
|
42
|
+
if api_key.startswith("sk_") and len(api_key) >= 12:
|
|
43
|
+
out["destination_account_ref"] = api_key[:12]
|
|
44
|
+
elif api_key.startswith("sk_"):
|
|
45
|
+
out["destination_account_ref"] = api_key
|
|
46
|
+
account = _string_arg(tool_args, "account")
|
|
47
|
+
if account:
|
|
48
|
+
out["destination_account_ref"] = account
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _twilio(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
53
|
+
if "twilio" not in tool_name:
|
|
54
|
+
return None
|
|
55
|
+
out: dict[str, str] = {
|
|
56
|
+
"destination_uri": "https://api.twilio.com",
|
|
57
|
+
"destination_provider": "twilio",
|
|
58
|
+
}
|
|
59
|
+
sid = _string_arg(tool_args, "account_sid")
|
|
60
|
+
if sid:
|
|
61
|
+
out["destination_account_ref"] = sid
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _slack(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
66
|
+
if "slack" not in tool_name:
|
|
67
|
+
return None
|
|
68
|
+
out: dict[str, str] = {
|
|
69
|
+
"destination_uri": "https://slack.com/api",
|
|
70
|
+
"destination_provider": "slack",
|
|
71
|
+
}
|
|
72
|
+
team = _string_arg(tool_args, "team_id") or _string_arg(tool_args, "workspace")
|
|
73
|
+
if team:
|
|
74
|
+
out["destination_account_ref"] = team
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _bedrock(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
79
|
+
if "bedrock" not in tool_name:
|
|
80
|
+
return None
|
|
81
|
+
out: dict[str, str] = {
|
|
82
|
+
"destination_uri": "https://bedrock-runtime.amazonaws.com",
|
|
83
|
+
"destination_provider": "aws-bedrock",
|
|
84
|
+
}
|
|
85
|
+
model_id = _string_arg(tool_args, "model_id") or _string_arg(tool_args, "model")
|
|
86
|
+
if model_id:
|
|
87
|
+
out["destination_account_ref"] = model_id
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _openai(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
92
|
+
if "openai" not in tool_name and "gpt" not in tool_name:
|
|
93
|
+
return None
|
|
94
|
+
out: dict[str, str] = {
|
|
95
|
+
"destination_uri": "https://api.openai.com",
|
|
96
|
+
"destination_provider": "openai",
|
|
97
|
+
}
|
|
98
|
+
org = _string_arg(tool_args, "organization") or _string_arg(tool_args, "org_id")
|
|
99
|
+
if org:
|
|
100
|
+
out["destination_account_ref"] = org
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _anthropic(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
105
|
+
if "anthropic" not in tool_name and "claude" not in tool_name:
|
|
106
|
+
return None
|
|
107
|
+
out: dict[str, str] = {
|
|
108
|
+
"destination_uri": "https://api.anthropic.com",
|
|
109
|
+
"destination_provider": "anthropic",
|
|
110
|
+
}
|
|
111
|
+
org = _string_arg(tool_args, "organization")
|
|
112
|
+
if org:
|
|
113
|
+
out["destination_account_ref"] = org
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _generic_http(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
|
|
118
|
+
for key in ("url", "endpoint", "uri", "host"):
|
|
119
|
+
raw = _string_arg(tool_args, key)
|
|
120
|
+
if not raw:
|
|
121
|
+
continue
|
|
122
|
+
parsed = urlparse(raw)
|
|
123
|
+
if not parsed.netloc:
|
|
124
|
+
continue
|
|
125
|
+
return {
|
|
126
|
+
"destination_uri": raw,
|
|
127
|
+
"destination_provider": _provider_from_host(parsed.netloc),
|
|
128
|
+
}
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_EXTRACTORS = (_stripe, _twilio, _slack, _bedrock, _openai, _anthropic, _generic_http)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract(tool_name: str, tool_args: dict[str, Any] | None) -> dict[str, str]:
|
|
136
|
+
"""Return any inferred destination_* fields. Empty dict on no match."""
|
|
137
|
+
if not tool_name:
|
|
138
|
+
return {}
|
|
139
|
+
name_lower = tool_name.lower()
|
|
140
|
+
args = tool_args or {}
|
|
141
|
+
for ex in _EXTRACTORS:
|
|
142
|
+
result = ex(name_lower, args)
|
|
143
|
+
if result:
|
|
144
|
+
return result
|
|
145
|
+
return {}
|