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/manifest.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Ledgix ALCV — Manifest Layer
|
|
2
|
+
# Schema, loading, and pattern matching for config-driven auto-instrumentation.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import fnmatch
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
MANIFEST_FILENAMES = ("ledgix.yaml", "ledgix.yml", "ledgix.json")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ManifestRule:
|
|
17
|
+
"""A single enforcement rule declared in the manifest.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
tool: Glob pattern matched against function names (e.g. ``"stripe_*"``).
|
|
21
|
+
policy_id: Policy to enforce for matching tools.
|
|
22
|
+
context: Extra key/value pairs forwarded to the clearance request context.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
tool: str
|
|
26
|
+
policy_id: str | None = None
|
|
27
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def matches(self, name: str) -> bool:
|
|
30
|
+
"""Return ``True`` if *name* matches this rule's glob pattern."""
|
|
31
|
+
return fnmatch.fnmatch(name, self.tool)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Manifest:
|
|
36
|
+
"""Parsed enforcement manifest.
|
|
37
|
+
|
|
38
|
+
Contains an ordered list of :class:`ManifestRule` objects. Rules are
|
|
39
|
+
evaluated in declaration order — the first match wins.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
rules: list[ManifestRule]
|
|
43
|
+
source: str = "<inline>"
|
|
44
|
+
|
|
45
|
+
def match(self, name: str) -> ManifestRule | None:
|
|
46
|
+
"""Return the first rule whose pattern matches *name*, or ``None``."""
|
|
47
|
+
for rule in self.rules:
|
|
48
|
+
if rule.matches(name):
|
|
49
|
+
return rule
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"Manifest(rules={len(self.rules)}, source={self.source!r})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_manifest(
|
|
57
|
+
source: str | Path | dict[str, Any] | None = None,
|
|
58
|
+
) -> Manifest:
|
|
59
|
+
"""Load an enforcement manifest from a file, an inline dict, or auto-discovery.
|
|
60
|
+
|
|
61
|
+
Supported file formats:
|
|
62
|
+
|
|
63
|
+
* **YAML** (``.yaml`` / ``.yml``) — requires ``pyyaml`` (``pip install pyyaml``
|
|
64
|
+
or ``pip install 'bylaw-python[yaml]'``)
|
|
65
|
+
* **JSON** (``.json``) — no extra dependencies
|
|
66
|
+
|
|
67
|
+
When *source* is ``None`` the function searches the current working
|
|
68
|
+
directory for ``ledgix.yaml``, ``ledgix.yml``, then ``ledgix.json`` in
|
|
69
|
+
that order.
|
|
70
|
+
|
|
71
|
+
Manifest schema::
|
|
72
|
+
|
|
73
|
+
enforce:
|
|
74
|
+
- tool: "stripe_*"
|
|
75
|
+
policy_id: "financial-high-risk"
|
|
76
|
+
- tool: "db_write*"
|
|
77
|
+
policy_id: "data-mutation"
|
|
78
|
+
context:
|
|
79
|
+
risk_level: "high"
|
|
80
|
+
- tool: "*" # catch-all (optional)
|
|
81
|
+
policy_id: "default"
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
source: File path, inline ``dict``, or ``None`` for auto-discovery.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Parsed :class:`Manifest`.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
FileNotFoundError: If *source* is ``None`` and no manifest file exists,
|
|
91
|
+
or if an explicit path does not exist.
|
|
92
|
+
ImportError: If a YAML file is given but ``pyyaml`` is not installed.
|
|
93
|
+
ValueError: If the file extension is not ``.yaml``, ``.yml``, or ``.json``.
|
|
94
|
+
"""
|
|
95
|
+
if source is None:
|
|
96
|
+
path = _find_default_manifest()
|
|
97
|
+
data = _parse_file(path)
|
|
98
|
+
src_label = str(path)
|
|
99
|
+
elif isinstance(source, dict):
|
|
100
|
+
data = source
|
|
101
|
+
src_label = "<inline>"
|
|
102
|
+
else:
|
|
103
|
+
path = Path(source)
|
|
104
|
+
if not path.exists():
|
|
105
|
+
raise FileNotFoundError(f"Ledgix manifest not found: {path}")
|
|
106
|
+
data = _parse_file(path)
|
|
107
|
+
src_label = str(path)
|
|
108
|
+
|
|
109
|
+
rules = [
|
|
110
|
+
ManifestRule(
|
|
111
|
+
tool=entry["tool"],
|
|
112
|
+
policy_id=entry.get("policy_id"),
|
|
113
|
+
context=entry.get("context") or {},
|
|
114
|
+
)
|
|
115
|
+
for entry in data.get("enforce", [])
|
|
116
|
+
]
|
|
117
|
+
return Manifest(rules=rules, source=src_label)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Internal helpers
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def _find_default_manifest() -> Path:
|
|
125
|
+
cwd = Path.cwd()
|
|
126
|
+
for name in MANIFEST_FILENAMES:
|
|
127
|
+
candidate = cwd / name
|
|
128
|
+
if candidate.exists():
|
|
129
|
+
return candidate
|
|
130
|
+
raise FileNotFoundError(
|
|
131
|
+
"No Ledgix manifest found in the current directory. "
|
|
132
|
+
f"Create one of: {', '.join(MANIFEST_FILENAMES)}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_file(path: Path) -> dict[str, Any]:
|
|
137
|
+
if path.suffix in (".yaml", ".yml"):
|
|
138
|
+
try:
|
|
139
|
+
import yaml # type: ignore[import-untyped]
|
|
140
|
+
except ImportError as exc:
|
|
141
|
+
raise ImportError(
|
|
142
|
+
"PyYAML is required to load YAML manifests. "
|
|
143
|
+
"Install it with: pip install pyyaml "
|
|
144
|
+
"or: pip install 'bylaw-python[yaml]'"
|
|
145
|
+
) from exc
|
|
146
|
+
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
147
|
+
if path.suffix == ".json":
|
|
148
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Unsupported manifest format: {path.suffix!r}. "
|
|
151
|
+
"Use .yaml, .yml, or .json."
|
|
152
|
+
)
|
bylaw_python/models.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
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, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
_MISSING = object()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClearanceRequest(BaseModel):
|
|
14
|
+
"""Payload sent to the Vault's ``/request-clearance`` endpoint."""
|
|
15
|
+
|
|
16
|
+
tool_name: str = Field(..., description="Name of the tool the agent wants to invoke")
|
|
17
|
+
tool_args: dict[str, Any] = Field(
|
|
18
|
+
default_factory=dict,
|
|
19
|
+
description="Arguments the agent will pass to the tool",
|
|
20
|
+
)
|
|
21
|
+
agent_id: str = Field(default="default-agent", description="Identifier for the calling agent")
|
|
22
|
+
session_id: str = Field(default="", description="Session grouping identifier")
|
|
23
|
+
context: dict[str, Any] = Field(
|
|
24
|
+
default_factory=dict,
|
|
25
|
+
description="Additional context for the Vault's policy judge (e.g. conversation history)",
|
|
26
|
+
)
|
|
27
|
+
human_principal: str | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description="Advisory OIDC sub of the human on whose behalf the agent acts",
|
|
30
|
+
)
|
|
31
|
+
parent_jti: str | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
description="JTI of the parent A-JWT; present on delegated sub-agent requests",
|
|
34
|
+
)
|
|
35
|
+
destination_uri: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Canonical URI the action will be sent to (e.g. https://api.openai.com/v1/chat/completions)",
|
|
38
|
+
)
|
|
39
|
+
destination_provider: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="Canonical provider key (e.g. openai, stripe, anthropic, aws-bedrock)",
|
|
42
|
+
)
|
|
43
|
+
destination_account_ref: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="Account/org/workspace ref within the provider (e.g. Stripe acct id, Slack team id)",
|
|
46
|
+
)
|
|
47
|
+
# Phase 2 — GDPR Article 30 processing-register matching.
|
|
48
|
+
# When supplied, the Vault's pre-LLM validator chain checks for an active
|
|
49
|
+
# processing register that covers (data_categories ⊇ requested,
|
|
50
|
+
# purpose ∈ register.purposes, recipient ∈ register.recipients). Unmatched
|
|
51
|
+
# requests are denied with reason_code='processing_no_register_match'.
|
|
52
|
+
data_categories: list[str] | None = Field(
|
|
53
|
+
default=None,
|
|
54
|
+
description="Personal-data categories this action will touch (e.g. ['customer_email','transaction_amount'])",
|
|
55
|
+
)
|
|
56
|
+
purpose: str | None = Field(
|
|
57
|
+
default=None,
|
|
58
|
+
description="Purpose of processing (e.g. 'fraud_detection', 'billing'); must be in matched register's purposes",
|
|
59
|
+
)
|
|
60
|
+
processing_register_ref: str | None = Field(
|
|
61
|
+
default=None,
|
|
62
|
+
description="Optional UUID hint of which register this action anchors to; Vault still does authoritative match",
|
|
63
|
+
)
|
|
64
|
+
# Phase 6 — dataset lineage. When supplied, dataset sheets auto-derive
|
|
65
|
+
# row counts, schema fingerprints, and consent-basis breakdowns from
|
|
66
|
+
# ledger replay scoped to events with this ref.
|
|
67
|
+
dataset_ref: str | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Logical dataset reference this action reads/writes (e.g. 'prod_customer_support_kb', S3 path, table name)",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
ConfidenceBucket = Literal["extra_high", "high", "medium", "low", "none"]
|
|
74
|
+
DecisionStatus = Literal["approved", "denied", "approved_pending_review"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ClearanceResponse(BaseModel):
|
|
78
|
+
"""Response from the Vault's ``/request-clearance`` endpoint.
|
|
79
|
+
|
|
80
|
+
As of v1.0 the wire format is bucket-only. The legacy ``approved``,
|
|
81
|
+
``confidence``, and ``minimum_confidence_score`` fields have been
|
|
82
|
+
removed; consumers read ``decision_status`` and ``confidence_bucket``
|
|
83
|
+
instead. See ``docs/MIGRATION_0.4.md`` for the migration guide.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
status: str = Field(default="denied", description="Vault lifecycle: processing, approved, denied, or pending_review")
|
|
87
|
+
decision_status: DecisionStatus = Field(
|
|
88
|
+
default="denied",
|
|
89
|
+
description="Categorical decision: approved | denied | approved_pending_review",
|
|
90
|
+
)
|
|
91
|
+
requires_manual_review: bool = Field(default=False, description="Whether the request is pending human review")
|
|
92
|
+
token: str | None = Field(default=None, description="Signed A-JWT if approved, None if denied")
|
|
93
|
+
reason: str = Field(default="", description="Human-readable explanation of the decision")
|
|
94
|
+
request_id: str = Field(default="", description="Vault-assigned unique ID for this request")
|
|
95
|
+
confidence_bucket: ConfidenceBucket = Field(
|
|
96
|
+
default="none",
|
|
97
|
+
description="Categorical confidence: extra_high | high | medium | low | none",
|
|
98
|
+
)
|
|
99
|
+
minimum_confidence_bucket: ConfidenceBucket = Field(
|
|
100
|
+
default="high",
|
|
101
|
+
description="Client-configured minimum confidence bucket for auto approval",
|
|
102
|
+
)
|
|
103
|
+
policy_version_id: str | None = Field(
|
|
104
|
+
default=None,
|
|
105
|
+
description="UUID of the policy version the decision was evaluated against",
|
|
106
|
+
)
|
|
107
|
+
policy_content_hash: str | None = Field(
|
|
108
|
+
default=None,
|
|
109
|
+
description="Content hash of the policy version the decision was evaluated against",
|
|
110
|
+
)
|
|
111
|
+
reason_code: str | None = Field(
|
|
112
|
+
default=None,
|
|
113
|
+
description="Machine-readable denial code, e.g. 'spend_cap_exceeded'",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_approved(self) -> bool:
|
|
118
|
+
"""Convenience: True iff the policy permits the action.
|
|
119
|
+
|
|
120
|
+
Returns True for both ``approved`` and ``approved_pending_review``.
|
|
121
|
+
Use this in place of the legacy ``approved`` boolean. Note that
|
|
122
|
+
``approved_pending_review`` does NOT mean the agent can proceed
|
|
123
|
+
immediately — it means the policy permits the action subject to
|
|
124
|
+
human review.
|
|
125
|
+
"""
|
|
126
|
+
return self.decision_status in ("approved", "approved_pending_review")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PolicyRegistration(BaseModel):
|
|
130
|
+
"""Payload for registering a policy with the Vault."""
|
|
131
|
+
|
|
132
|
+
policy_id: str = Field(..., description="Unique identifier for the policy")
|
|
133
|
+
description: str = Field(default="", description="Human-readable description of the policy")
|
|
134
|
+
rules: list[str] = Field(
|
|
135
|
+
default_factory=list,
|
|
136
|
+
description="List of plain-English rules (e.g. 'Refunds must not exceed $100')",
|
|
137
|
+
)
|
|
138
|
+
tools: list[str] = Field(
|
|
139
|
+
default_factory=list,
|
|
140
|
+
description="Tool names this policy applies to (empty = all tools)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class PolicyRegistrationResponse(BaseModel):
|
|
145
|
+
"""Response from the Vault's ``/register-policy`` endpoint."""
|
|
146
|
+
|
|
147
|
+
policy_id: str = Field(..., description="Confirmed policy ID")
|
|
148
|
+
status: str = Field(default="registered", description="Registration status")
|
|
149
|
+
message: str = Field(default="", description="Additional information")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class LedgerEntry(BaseModel):
|
|
153
|
+
"""Ledger entry returned by the Vault's ledger endpoints."""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
156
|
+
|
|
157
|
+
seq: int
|
|
158
|
+
event_uuid: str
|
|
159
|
+
request_id: str
|
|
160
|
+
agent_id: str = ""
|
|
161
|
+
policy_id: str = ""
|
|
162
|
+
policy_version_id: str = ""
|
|
163
|
+
policy_content_hash: str = ""
|
|
164
|
+
intent_hash: str = ""
|
|
165
|
+
tool_name: str
|
|
166
|
+
tool_args: dict[str, Any] = Field(default_factory=dict)
|
|
167
|
+
raw_tool_args: Any = Field(default_factory=lambda: _MISSING, exclude=True)
|
|
168
|
+
action_category: str = ""
|
|
169
|
+
action_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
170
|
+
raw_action_metadata: Any = Field(default_factory=lambda: _MISSING, exclude=True)
|
|
171
|
+
reason: str = ""
|
|
172
|
+
citations: list[dict[str, Any]] = Field(default_factory=list)
|
|
173
|
+
raw_citations: Any = Field(default_factory=lambda: _MISSING, exclude=True)
|
|
174
|
+
evidence_chunks: list[dict[str, Any]] = Field(default_factory=list)
|
|
175
|
+
raw_evidence_chunks: Any = Field(default_factory=lambda: _MISSING, exclude=True)
|
|
176
|
+
# Legacy float kept for canonical_version=1 hash verification of old rows.
|
|
177
|
+
# New rows under canonical_version=2 also carry confidence_bucket and
|
|
178
|
+
# decision_status as their canonical signal.
|
|
179
|
+
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="Legacy bucket midpoint; prefer confidence_bucket")
|
|
180
|
+
confidence_bucket: ConfidenceBucket | None = Field(
|
|
181
|
+
default=None,
|
|
182
|
+
description="Categorical confidence; populated for canonical_version>=2 events",
|
|
183
|
+
)
|
|
184
|
+
decision_status: DecisionStatus | None = Field(
|
|
185
|
+
default=None,
|
|
186
|
+
description="Categorical decision; populated for canonical_version>=2 events",
|
|
187
|
+
)
|
|
188
|
+
approved: bool = Field(
|
|
189
|
+
default=False,
|
|
190
|
+
description="Legacy boolean; derived for new rows. Prefer decision_status.",
|
|
191
|
+
)
|
|
192
|
+
accepted_at: str = Field(validation_alias=AliasChoices("accepted_at", "decided_at"))
|
|
193
|
+
canonical_version: int = 1
|
|
194
|
+
event_hash: str
|
|
195
|
+
leaf_hash: str
|
|
196
|
+
leaf_index: int | None = None
|
|
197
|
+
checkpoint_id: int | None = None
|
|
198
|
+
receipt_algorithm: str = Field(
|
|
199
|
+
default="",
|
|
200
|
+
validation_alias=AliasChoices("receipt_algorithm", "signature_algorithm"),
|
|
201
|
+
)
|
|
202
|
+
receipt_key_id: str = Field(
|
|
203
|
+
default="",
|
|
204
|
+
validation_alias=AliasChoices("receipt_key_id", "signer_key_id"),
|
|
205
|
+
)
|
|
206
|
+
receipt_signature: str = Field(
|
|
207
|
+
default="",
|
|
208
|
+
validation_alias=AliasChoices("receipt_signature", "row_signature"),
|
|
209
|
+
)
|
|
210
|
+
receipt_payload: str = Field(default="")
|
|
211
|
+
|
|
212
|
+
@model_validator(mode="before")
|
|
213
|
+
@classmethod
|
|
214
|
+
def _capture_raw_verification_fields(cls, value: Any) -> Any:
|
|
215
|
+
if not isinstance(value, dict):
|
|
216
|
+
return value
|
|
217
|
+
data = dict(value)
|
|
218
|
+
if "raw_tool_args" not in data and "tool_args" in data:
|
|
219
|
+
data["raw_tool_args"] = data.get("tool_args")
|
|
220
|
+
if "raw_action_metadata" not in data and "action_metadata" in data:
|
|
221
|
+
data["raw_action_metadata"] = data.get("action_metadata")
|
|
222
|
+
if "raw_citations" not in data and "citations" in data:
|
|
223
|
+
data["raw_citations"] = data.get("citations")
|
|
224
|
+
if "raw_evidence_chunks" not in data and "evidence_chunks" in data:
|
|
225
|
+
data["raw_evidence_chunks"] = data.get("evidence_chunks")
|
|
226
|
+
return data
|
|
227
|
+
|
|
228
|
+
@field_validator("tool_args", "action_metadata", mode="before")
|
|
229
|
+
@classmethod
|
|
230
|
+
def _normalize_nullable_dicts(cls, value: Any) -> Any:
|
|
231
|
+
if value is None:
|
|
232
|
+
return {}
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
@field_validator("citations", "evidence_chunks", mode="before")
|
|
236
|
+
@classmethod
|
|
237
|
+
def _normalize_nullable_lists(cls, value: Any) -> Any:
|
|
238
|
+
if value is None:
|
|
239
|
+
return []
|
|
240
|
+
return value
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class LedgerCheckpoint(BaseModel):
|
|
244
|
+
"""Signed checkpoint returned by the Vault."""
|
|
245
|
+
|
|
246
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
247
|
+
|
|
248
|
+
checkpoint_id: int
|
|
249
|
+
microblock_id: int = 0
|
|
250
|
+
tree_size: int
|
|
251
|
+
root_hash: str = Field(validation_alias=AliasChoices("root_hash", "head_row_hash"))
|
|
252
|
+
checkpoint_hash: str = Field(
|
|
253
|
+
validation_alias=AliasChoices("checkpoint_hash", "manifest_hash"),
|
|
254
|
+
)
|
|
255
|
+
prev_checkpoint_hash: str = Field(
|
|
256
|
+
default="",
|
|
257
|
+
validation_alias=AliasChoices("prev_checkpoint_hash", "prev_manifest_hash"),
|
|
258
|
+
)
|
|
259
|
+
signature_algorithm: str = Field(
|
|
260
|
+
default="",
|
|
261
|
+
validation_alias=AliasChoices("signature_algorithm", "signature_algorithm"),
|
|
262
|
+
)
|
|
263
|
+
signer_key_id: str = ""
|
|
264
|
+
checkpoint_signature: str = Field(
|
|
265
|
+
default="",
|
|
266
|
+
validation_alias=AliasChoices("checkpoint_signature", "manifest_signature"),
|
|
267
|
+
)
|
|
268
|
+
checkpoint_payload: str = Field(
|
|
269
|
+
default="",
|
|
270
|
+
validation_alias=AliasChoices("checkpoint_payload", "manifest_payload"),
|
|
271
|
+
)
|
|
272
|
+
signed_at: str = Field(validation_alias=AliasChoices("signed_at", "generated_at", "period_start"))
|
|
273
|
+
mmd_seconds: int = 30
|
|
274
|
+
export_target: str = ""
|
|
275
|
+
export_uri: str = ""
|
|
276
|
+
export_status: str = ""
|
|
277
|
+
exported_at: str | None = None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
LedgerManifest = LedgerCheckpoint
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class LedgerKeyVersion(BaseModel):
|
|
284
|
+
key_id: str
|
|
285
|
+
algorithm: str
|
|
286
|
+
public_jwk: str = ""
|
|
287
|
+
active_from: str
|
|
288
|
+
retired_at: str | None = None
|
|
289
|
+
attestation_payload: str = ""
|
|
290
|
+
attestation_signature: str = ""
|
|
291
|
+
attestation_key_id: str = ""
|
|
292
|
+
attestation_status: str = ""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class InclusionProof(BaseModel):
|
|
296
|
+
event_uuid: str
|
|
297
|
+
request_id: str
|
|
298
|
+
event_hash: str
|
|
299
|
+
leaf_hash: str
|
|
300
|
+
leaf_index: int
|
|
301
|
+
tree_size: int
|
|
302
|
+
path: list[str] = Field(default_factory=list)
|
|
303
|
+
checkpoint: LedgerCheckpoint
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ConsistencyProof(BaseModel):
|
|
307
|
+
from_checkpoint: LedgerCheckpoint
|
|
308
|
+
to_checkpoint: LedgerCheckpoint
|
|
309
|
+
path: list[str] = Field(default_factory=list)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class LedgerProofBundle(BaseModel):
|
|
313
|
+
event: LedgerEntry
|
|
314
|
+
inclusion: InclusionProof
|
|
315
|
+
consistency: ConsistencyProof | None = None
|
|
316
|
+
keys: list[LedgerKeyVersion] = Field(default_factory=list)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class LedgerVerificationResult(BaseModel):
|
|
320
|
+
"""Result of independent offline ledger verification."""
|
|
321
|
+
|
|
322
|
+
intact: bool
|
|
323
|
+
verified_entries: int
|
|
324
|
+
verified_checkpoints: int = 0
|
|
325
|
+
verified_manifests: int
|
|
326
|
+
latest_leaf_hash: str | None = None
|
|
327
|
+
latest_checkpoint_hash: str | None = None
|
|
328
|
+
latest_manifest_hash: str | None = None
|
|
329
|
+
coverage_note: str | None = None
|
|
330
|
+
error: str | None = None
|
bylaw_python/pending.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Ledgix ALCV — PendingApproval
|
|
2
|
+
# Handle for detached manual-review decisions
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .client import LedgixClient
|
|
11
|
+
from .models import ClearanceResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PendingApproval:
|
|
15
|
+
"""Handle for a clearance request that entered ``pending_review`` status.
|
|
16
|
+
|
|
17
|
+
Obtained when ``review_mode="detach"`` is set on :class:`~bylaw_python.VaultConfig`
|
|
18
|
+
and the Vault returns a ``pending_review`` response.
|
|
19
|
+
|
|
20
|
+
Usage (async)::
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
result = await client.arequest_clearance(request)
|
|
24
|
+
except ReviewPendingError as exc:
|
|
25
|
+
pending = exc.pending_approval
|
|
26
|
+
# store pending.request_id, come back later
|
|
27
|
+
result = await pending.wait_async(timeout=1800)
|
|
28
|
+
|
|
29
|
+
Usage (sync)::
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
result = client.request_clearance(request)
|
|
33
|
+
except ReviewPendingError as exc:
|
|
34
|
+
result = exc.pending_approval.wait(timeout=1800)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
request_id: str,
|
|
40
|
+
client: "LedgixClient",
|
|
41
|
+
initial_response: "ClearanceResponse",
|
|
42
|
+
) -> None:
|
|
43
|
+
self._request_id = request_id
|
|
44
|
+
self._client = client
|
|
45
|
+
self._initial_response = initial_response
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def request_id(self) -> str:
|
|
49
|
+
"""The Vault's unique ID for this clearance request."""
|
|
50
|
+
return self._request_id
|
|
51
|
+
|
|
52
|
+
def wait(self, timeout: float | None = None) -> "ClearanceResponse":
|
|
53
|
+
"""Block until the reviewer decides, then return the :class:`ClearanceResponse`.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
timeout: Maximum seconds to wait. Defaults to the client's
|
|
57
|
+
``review_timeout`` config value.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ManualReviewTimeoutError: If no decision arrives within *timeout*.
|
|
61
|
+
ClearanceDeniedError: If the reviewer denies the request.
|
|
62
|
+
"""
|
|
63
|
+
from .exceptions import ClearanceDeniedError, ManualReviewTimeoutError
|
|
64
|
+
from .models import ClearanceResponse as CR
|
|
65
|
+
|
|
66
|
+
deadline = time.monotonic() + (timeout if timeout is not None else self._client.config.review_timeout)
|
|
67
|
+
poll = self._client.config.review_poll_interval
|
|
68
|
+
|
|
69
|
+
while time.monotonic() < deadline:
|
|
70
|
+
time.sleep(poll)
|
|
71
|
+
response = self._client._get_sync_client().get(
|
|
72
|
+
f"/clearance-status/{self._request_id}"
|
|
73
|
+
)
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
clearance = CR.model_validate(response.json())
|
|
76
|
+
if clearance.status not in {"processing", "pending_review"}:
|
|
77
|
+
if not clearance.is_approved:
|
|
78
|
+
raise ClearanceDeniedError(
|
|
79
|
+
reason=clearance.reason,
|
|
80
|
+
request_id=clearance.request_id,
|
|
81
|
+
)
|
|
82
|
+
return clearance
|
|
83
|
+
|
|
84
|
+
raise ManualReviewTimeoutError(self._request_id)
|
|
85
|
+
|
|
86
|
+
async def wait_async(self, timeout: float | None = None) -> "ClearanceResponse":
|
|
87
|
+
"""Async variant of :meth:`wait`."""
|
|
88
|
+
import asyncio
|
|
89
|
+
|
|
90
|
+
from .exceptions import ClearanceDeniedError, ManualReviewTimeoutError
|
|
91
|
+
from .models import ClearanceResponse as CR
|
|
92
|
+
|
|
93
|
+
deadline = time.monotonic() + (timeout if timeout is not None else self._client.config.review_timeout)
|
|
94
|
+
poll = self._client.config.review_poll_interval
|
|
95
|
+
|
|
96
|
+
while time.monotonic() < deadline:
|
|
97
|
+
await asyncio.sleep(poll)
|
|
98
|
+
response = await self._client._get_async_client().get(
|
|
99
|
+
f"/clearance-status/{self._request_id}"
|
|
100
|
+
)
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
clearance = CR.model_validate(response.json())
|
|
103
|
+
if clearance.status not in {"processing", "pending_review"}:
|
|
104
|
+
if not clearance.is_approved:
|
|
105
|
+
raise ClearanceDeniedError(
|
|
106
|
+
reason=clearance.reason,
|
|
107
|
+
request_id=clearance.request_id,
|
|
108
|
+
)
|
|
109
|
+
return clearance
|
|
110
|
+
|
|
111
|
+
raise ManualReviewTimeoutError(self._request_id)
|
|
112
|
+
|
|
113
|
+
def cancel(self) -> None:
|
|
114
|
+
"""Cancel the pending review by posting a denial decision.
|
|
115
|
+
|
|
116
|
+
Records a ``review.cancelled_by_agent`` entry in the Vault ledger.
|
|
117
|
+
"""
|
|
118
|
+
self._client._get_sync_client().post(
|
|
119
|
+
f"/reviews/{self._request_id}/decision",
|
|
120
|
+
json={"approved": False, "review_reason": "cancelled by agent"},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def acancel(self) -> None:
|
|
124
|
+
"""Async variant of :meth:`cancel`."""
|
|
125
|
+
await self._client._get_async_client().post(
|
|
126
|
+
f"/reviews/{self._request_id}/decision",
|
|
127
|
+
json={"approved": False, "review_reason": "cancelled by agent"},
|
|
128
|
+
)
|
bylaw_python/webhook.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Ledgix ALCV — Webhook verification helper
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify_webhook(body: bytes | str, signature: str, secret: str) -> bool:
|
|
10
|
+
"""Verify the HMAC-SHA256 signature on an inbound Ledgix webhook.
|
|
11
|
+
|
|
12
|
+
The Vault signs every delivery with ``X-Ledgix-Signature: sha256=<hex>``.
|
|
13
|
+
Pass the raw request body, that header value, and your endpoint's signing
|
|
14
|
+
secret to verify authenticity before processing the event.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
body: Raw request body (bytes or str). Use the unparsed body exactly
|
|
18
|
+
as received — do not re-encode from a parsed JSON object.
|
|
19
|
+
signature: Value of the ``X-Ledgix-Signature`` header.
|
|
20
|
+
secret: Signing secret for this webhook endpoint (from the dashboard).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
``True`` if the signature is valid, ``False`` otherwise.
|
|
24
|
+
|
|
25
|
+
Example::
|
|
26
|
+
|
|
27
|
+
from flask import request
|
|
28
|
+
import bylaw_python as ledgix
|
|
29
|
+
|
|
30
|
+
@app.route("/webhook", methods=["POST"])
|
|
31
|
+
def handle_webhook():
|
|
32
|
+
if not ledgix.verify_webhook(request.data, request.headers["X-Ledgix-Signature"], SECRET):
|
|
33
|
+
return "Forbidden", 403
|
|
34
|
+
event = request.get_json()
|
|
35
|
+
...
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(body, str):
|
|
38
|
+
body = body.encode("utf-8")
|
|
39
|
+
|
|
40
|
+
if signature.startswith("sha256="):
|
|
41
|
+
signature = signature[len("sha256="):]
|
|
42
|
+
|
|
43
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
44
|
+
return hmac.compare_digest(expected, signature)
|