ledgix-python 0.1.2__tar.gz → 0.1.3__tar.gz
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-0.1.2 → ledgix_python-0.1.3}/PKG-INFO +2 -2
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/README.md +1 -1
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/pyproject.toml +1 -1
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/__init__.py +6 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/client.py +211 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/models.py +54 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_client.py +182 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/.gitignore +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/demo.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/requirements.txt +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/__init__.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/crewai.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/langchain.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/adapters/llamaindex.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/config.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/enforce.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/src/ledgix_python/exceptions.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/__init__.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/conftest.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_adapters.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_enforce.py +0 -0
- {ledgix_python-0.1.2 → ledgix_python-0.1.3}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ledgix-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault
|
|
5
5
|
Project-URL: Homepage, https://github.com/ledgix-dev/python-sdk
|
|
6
6
|
Project-URL: Documentation, https://docs.ledgix.dev
|
|
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
|
|
41
41
|
# Ledgix ALCV — Python SDK
|
|
42
42
|
|
|
43
|
-
[](https://pypi.org/project/ledgix-python/)
|
|
44
44
|
[](https://python.org)
|
|
45
45
|
[](LICENSE)
|
|
46
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Ledgix ALCV — Python SDK
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/ledgix-python/)
|
|
4
4
|
[](https://python.org)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
@@ -27,6 +27,9 @@ from .exceptions import (
|
|
|
27
27
|
from .models import (
|
|
28
28
|
ClearanceRequest,
|
|
29
29
|
ClearanceResponse,
|
|
30
|
+
LedgerEntry,
|
|
31
|
+
LedgerManifest,
|
|
32
|
+
LedgerVerificationResult,
|
|
30
33
|
PolicyRegistration,
|
|
31
34
|
PolicyRegistrationResponse,
|
|
32
35
|
)
|
|
@@ -43,6 +46,9 @@ __all__ = [
|
|
|
43
46
|
# Models
|
|
44
47
|
"ClearanceRequest",
|
|
45
48
|
"ClearanceResponse",
|
|
49
|
+
"LedgerEntry",
|
|
50
|
+
"LedgerManifest",
|
|
51
|
+
"LedgerVerificationResult",
|
|
46
52
|
"PolicyRegistration",
|
|
47
53
|
"PolicyRegistrationResponse",
|
|
48
54
|
# Exceptions
|
|
@@ -6,8 +6,11 @@ from __future__ import annotations
|
|
|
6
6
|
import json
|
|
7
7
|
import random
|
|
8
8
|
import time
|
|
9
|
+
import hashlib
|
|
10
|
+
import base64
|
|
9
11
|
from collections.abc import Awaitable, Callable
|
|
10
12
|
from typing import Any
|
|
13
|
+
from urllib.parse import urlencode
|
|
11
14
|
|
|
12
15
|
import httpx
|
|
13
16
|
import jwt
|
|
@@ -25,6 +28,9 @@ from .exceptions import (
|
|
|
25
28
|
from .models import (
|
|
26
29
|
ClearanceRequest,
|
|
27
30
|
ClearanceResponse,
|
|
31
|
+
LedgerEntry,
|
|
32
|
+
LedgerManifest,
|
|
33
|
+
LedgerVerificationResult,
|
|
28
34
|
PolicyRegistration,
|
|
29
35
|
PolicyRegistrationResponse,
|
|
30
36
|
)
|
|
@@ -316,6 +322,106 @@ class LedgixClient:
|
|
|
316
322
|
self._jwks_cache = response.json()
|
|
317
323
|
return self._jwks_cache
|
|
318
324
|
|
|
325
|
+
def fetch_ledger(self, limit: int = 100) -> list[LedgerEntry]:
|
|
326
|
+
"""Fetch recent ledger entries for the authenticated tenant (sync)."""
|
|
327
|
+
query = urlencode({"limit": max(1, min(limit, 500))})
|
|
328
|
+
try:
|
|
329
|
+
response = self._sync_retry(lambda: self._get_sync_client().get(f"/ledger?{query}"))
|
|
330
|
+
response.raise_for_status()
|
|
331
|
+
except httpx.HTTPStatusError as exc:
|
|
332
|
+
raise VaultConnectionError(
|
|
333
|
+
f"Failed to fetch ledger: HTTP {exc.response.status_code}"
|
|
334
|
+
) from exc
|
|
335
|
+
|
|
336
|
+
payload = response.json()
|
|
337
|
+
return [LedgerEntry.model_validate(item) for item in payload.get("entries", [])]
|
|
338
|
+
|
|
339
|
+
async def afetch_ledger(self, limit: int = 100) -> list[LedgerEntry]:
|
|
340
|
+
"""Fetch recent ledger entries for the authenticated tenant (async)."""
|
|
341
|
+
query = urlencode({"limit": max(1, min(limit, 500))})
|
|
342
|
+
try:
|
|
343
|
+
response = await self._async_retry(lambda: self._get_async_client().get(f"/ledger?{query}"))
|
|
344
|
+
response.raise_for_status()
|
|
345
|
+
except httpx.HTTPStatusError as exc:
|
|
346
|
+
raise VaultConnectionError(
|
|
347
|
+
f"Failed to fetch ledger: HTTP {exc.response.status_code}"
|
|
348
|
+
) from exc
|
|
349
|
+
|
|
350
|
+
payload = response.json()
|
|
351
|
+
return [LedgerEntry.model_validate(item) for item in payload.get("entries", [])]
|
|
352
|
+
|
|
353
|
+
def fetch_ledger_manifests(self, limit: int = 24) -> list[LedgerManifest]:
|
|
354
|
+
"""Fetch recent signed ledger manifests for the authenticated tenant (sync)."""
|
|
355
|
+
query = urlencode({"limit": max(1, min(limit, 500))})
|
|
356
|
+
try:
|
|
357
|
+
response = self._sync_retry(
|
|
358
|
+
lambda: self._get_sync_client().get(f"/ledger/manifests?{query}")
|
|
359
|
+
)
|
|
360
|
+
response.raise_for_status()
|
|
361
|
+
except httpx.HTTPStatusError as exc:
|
|
362
|
+
raise VaultConnectionError(
|
|
363
|
+
f"Failed to fetch ledger manifests: HTTP {exc.response.status_code}"
|
|
364
|
+
) from exc
|
|
365
|
+
|
|
366
|
+
payload = response.json()
|
|
367
|
+
return [LedgerManifest.model_validate(item) for item in payload.get("manifests", [])]
|
|
368
|
+
|
|
369
|
+
async def afetch_ledger_manifests(self, limit: int = 24) -> list[LedgerManifest]:
|
|
370
|
+
"""Fetch recent signed ledger manifests for the authenticated tenant (async)."""
|
|
371
|
+
query = urlencode({"limit": max(1, min(limit, 500))})
|
|
372
|
+
try:
|
|
373
|
+
response = await self._async_retry(
|
|
374
|
+
lambda: self._get_async_client().get(f"/ledger/manifests?{query}")
|
|
375
|
+
)
|
|
376
|
+
response.raise_for_status()
|
|
377
|
+
except httpx.HTTPStatusError as exc:
|
|
378
|
+
raise VaultConnectionError(
|
|
379
|
+
f"Failed to fetch ledger manifests: HTTP {exc.response.status_code}"
|
|
380
|
+
) from exc
|
|
381
|
+
|
|
382
|
+
payload = response.json()
|
|
383
|
+
return [LedgerManifest.model_validate(item) for item in payload.get("manifests", [])]
|
|
384
|
+
|
|
385
|
+
def verify_ledger_proof(
|
|
386
|
+
self,
|
|
387
|
+
entries: list[LedgerEntry | dict[str, Any]] | None = None,
|
|
388
|
+
manifests: list[LedgerManifest | dict[str, Any]] | None = None,
|
|
389
|
+
) -> LedgerVerificationResult:
|
|
390
|
+
"""Verify ledger row receipts and manifest signatures offline using the Vault JWKS."""
|
|
391
|
+
entries = (
|
|
392
|
+
[item if isinstance(item, LedgerEntry) else LedgerEntry.model_validate(item) for item in entries]
|
|
393
|
+
if entries is not None
|
|
394
|
+
else self.fetch_ledger()
|
|
395
|
+
)
|
|
396
|
+
manifests = (
|
|
397
|
+
[item if isinstance(item, LedgerManifest) else LedgerManifest.model_validate(item) for item in manifests]
|
|
398
|
+
if manifests is not None
|
|
399
|
+
else self.fetch_ledger_manifests()
|
|
400
|
+
)
|
|
401
|
+
if self._jwks_cache is None:
|
|
402
|
+
self.fetch_jwks()
|
|
403
|
+
return self._verify_ledger_proof(entries, manifests)
|
|
404
|
+
|
|
405
|
+
async def averify_ledger_proof(
|
|
406
|
+
self,
|
|
407
|
+
entries: list[LedgerEntry | dict[str, Any]] | None = None,
|
|
408
|
+
manifests: list[LedgerManifest | dict[str, Any]] | None = None,
|
|
409
|
+
) -> LedgerVerificationResult:
|
|
410
|
+
"""Async variant of ``verify_ledger_proof``."""
|
|
411
|
+
entries = (
|
|
412
|
+
[item if isinstance(item, LedgerEntry) else LedgerEntry.model_validate(item) for item in entries]
|
|
413
|
+
if entries is not None
|
|
414
|
+
else await self.afetch_ledger()
|
|
415
|
+
)
|
|
416
|
+
manifests = (
|
|
417
|
+
[item if isinstance(item, LedgerManifest) else LedgerManifest.model_validate(item) for item in manifests]
|
|
418
|
+
if manifests is not None
|
|
419
|
+
else await self.afetch_ledger_manifests()
|
|
420
|
+
)
|
|
421
|
+
if self._jwks_cache is None:
|
|
422
|
+
await self.afetch_jwks()
|
|
423
|
+
return self._verify_ledger_proof(entries, manifests)
|
|
424
|
+
|
|
319
425
|
def verify_token(self, token: str) -> dict[str, Any]:
|
|
320
426
|
"""Verify an A-JWT using the Vault's public key (sync).
|
|
321
427
|
|
|
@@ -374,6 +480,111 @@ class LedgixClient:
|
|
|
374
480
|
except Exception as exc:
|
|
375
481
|
raise TokenVerificationError(f"Token verification failed: {exc}") from exc
|
|
376
482
|
|
|
483
|
+
def _verify_ledger_proof(
|
|
484
|
+
self,
|
|
485
|
+
entries: list[LedgerEntry],
|
|
486
|
+
manifests: list[LedgerManifest],
|
|
487
|
+
) -> LedgerVerificationResult:
|
|
488
|
+
if not self._jwks_cache:
|
|
489
|
+
return LedgerVerificationResult(
|
|
490
|
+
intact=False,
|
|
491
|
+
verified_entries=0,
|
|
492
|
+
verified_manifests=0,
|
|
493
|
+
latest_row_hash=None,
|
|
494
|
+
latest_manifest_hash=None,
|
|
495
|
+
error="No JWKS available from Vault",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
jwks = self._jwks_cache
|
|
500
|
+
keys = jwks.get("keys") if isinstance(jwks, dict) else None
|
|
501
|
+
if not isinstance(keys, list) or not keys:
|
|
502
|
+
raise TokenVerificationError("JWKS contains no keys")
|
|
503
|
+
|
|
504
|
+
algorithm = jwt.algorithms.get_default_algorithms()["EdDSA"]
|
|
505
|
+
key_cache: dict[str, Any] = {}
|
|
506
|
+
|
|
507
|
+
def key_for_kid(kid: str) -> Any:
|
|
508
|
+
if kid in key_cache:
|
|
509
|
+
return key_cache[kid]
|
|
510
|
+
match = next(
|
|
511
|
+
(
|
|
512
|
+
item
|
|
513
|
+
for item in keys
|
|
514
|
+
if isinstance(item, dict) and item.get("kid") == kid
|
|
515
|
+
),
|
|
516
|
+
None,
|
|
517
|
+
)
|
|
518
|
+
if match is None:
|
|
519
|
+
raise TokenVerificationError(f"No public key found for kid {kid}")
|
|
520
|
+
public_key = jwt.algorithms.OKPAlgorithm.from_jwk(json.dumps(match))
|
|
521
|
+
key_cache[kid] = public_key
|
|
522
|
+
return public_key
|
|
523
|
+
|
|
524
|
+
previous_row_hash = "0" * 64
|
|
525
|
+
sorted_entries = sorted(entries, key=lambda item: item.seq)
|
|
526
|
+
for entry in sorted_entries:
|
|
527
|
+
if entry.prev_row_hash != previous_row_hash:
|
|
528
|
+
raise TokenVerificationError(f"Ledger chain broken at seq {entry.seq}")
|
|
529
|
+
if not entry.receipt_payload or not entry.row_signature or not entry.signer_key_id:
|
|
530
|
+
raise TokenVerificationError(f"Missing receipt proof data at seq {entry.seq}")
|
|
531
|
+
if not algorithm.verify(
|
|
532
|
+
self._decode_base64url(entry.receipt_payload),
|
|
533
|
+
key_for_kid(entry.signer_key_id),
|
|
534
|
+
self._decode_base64url(entry.row_signature),
|
|
535
|
+
):
|
|
536
|
+
raise TokenVerificationError(f"Ledger receipt signature invalid at seq {entry.seq}")
|
|
537
|
+
previous_row_hash = entry.row_hash
|
|
538
|
+
|
|
539
|
+
previous_manifest_hash = "sha256:" + ("0" * 64)
|
|
540
|
+
sorted_manifests = sorted(manifests, key=lambda item: item.period_start)
|
|
541
|
+
for manifest in sorted_manifests:
|
|
542
|
+
if manifest.prev_manifest_hash != previous_manifest_hash:
|
|
543
|
+
raise TokenVerificationError(
|
|
544
|
+
f"Manifest chain broken at {manifest.period_start}"
|
|
545
|
+
)
|
|
546
|
+
if not manifest.manifest_payload or not manifest.manifest_signature or not manifest.signer_key_id:
|
|
547
|
+
raise TokenVerificationError(
|
|
548
|
+
f"Missing manifest proof data at {manifest.period_start}"
|
|
549
|
+
)
|
|
550
|
+
payload_bytes = self._decode_base64url(manifest.manifest_payload)
|
|
551
|
+
if not algorithm.verify(
|
|
552
|
+
payload_bytes,
|
|
553
|
+
key_for_kid(manifest.signer_key_id),
|
|
554
|
+
self._decode_base64url(manifest.manifest_signature),
|
|
555
|
+
):
|
|
556
|
+
raise TokenVerificationError(
|
|
557
|
+
f"Manifest signature invalid at {manifest.period_start}"
|
|
558
|
+
)
|
|
559
|
+
payload_hash = hashlib.sha256(payload_bytes).hexdigest()
|
|
560
|
+
if f"sha256:{payload_hash}" != manifest.manifest_hash:
|
|
561
|
+
raise TokenVerificationError(
|
|
562
|
+
f"Manifest hash mismatch at {manifest.period_start}"
|
|
563
|
+
)
|
|
564
|
+
previous_manifest_hash = manifest.manifest_hash
|
|
565
|
+
|
|
566
|
+
return LedgerVerificationResult(
|
|
567
|
+
intact=True,
|
|
568
|
+
verified_entries=len(sorted_entries),
|
|
569
|
+
verified_manifests=len(sorted_manifests),
|
|
570
|
+
latest_row_hash=sorted_entries[-1].row_hash if sorted_entries else None,
|
|
571
|
+
latest_manifest_hash=sorted_manifests[-1].manifest_hash if sorted_manifests else None,
|
|
572
|
+
)
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
return LedgerVerificationResult(
|
|
575
|
+
intact=False,
|
|
576
|
+
verified_entries=0,
|
|
577
|
+
verified_manifests=0,
|
|
578
|
+
latest_row_hash=None,
|
|
579
|
+
latest_manifest_hash=None,
|
|
580
|
+
error=str(exc),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def _decode_base64url(value: str) -> bytes:
|
|
585
|
+
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
|
586
|
+
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
587
|
+
|
|
377
588
|
# ------------------------------------------------------------------
|
|
378
589
|
# Lifecycle
|
|
379
590
|
# ------------------------------------------------------------------
|
|
@@ -63,3 +63,57 @@ class PolicyRegistrationResponse(BaseModel):
|
|
|
63
63
|
policy_id: str = Field(..., description="Confirmed policy ID")
|
|
64
64
|
status: str = Field(default="registered", description="Registration status")
|
|
65
65
|
message: str = Field(default="", description="Additional information")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LedgerEntry(BaseModel):
|
|
69
|
+
"""Ledger entry returned by the Vault's ledger endpoints."""
|
|
70
|
+
|
|
71
|
+
seq: int
|
|
72
|
+
request_id: str
|
|
73
|
+
agent_id: str = ""
|
|
74
|
+
policy_id: str = ""
|
|
75
|
+
intent_hash: str = ""
|
|
76
|
+
tool_name: str
|
|
77
|
+
tool_args: dict[str, Any] = Field(default_factory=dict)
|
|
78
|
+
reason: str = ""
|
|
79
|
+
citations: list[dict[str, Any]] = Field(default_factory=list)
|
|
80
|
+
evidence_chunks: list[dict[str, Any]] = Field(default_factory=list)
|
|
81
|
+
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
82
|
+
approved: bool
|
|
83
|
+
decided_at: str
|
|
84
|
+
prev_row_hash: str = ""
|
|
85
|
+
row_hash: str
|
|
86
|
+
signature_algorithm: str = ""
|
|
87
|
+
signer_key_id: str = ""
|
|
88
|
+
row_signature: str = ""
|
|
89
|
+
receipt_payload: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LedgerManifest(BaseModel):
|
|
93
|
+
"""Signed chain-head manifest returned by the Vault."""
|
|
94
|
+
|
|
95
|
+
period_start: str
|
|
96
|
+
period_end_exclusive: str
|
|
97
|
+
generated_at: str
|
|
98
|
+
head_seq: int
|
|
99
|
+
head_row_hash: str
|
|
100
|
+
head_row_signature: str = ""
|
|
101
|
+
manifest_hash: str
|
|
102
|
+
prev_manifest_hash: str = ""
|
|
103
|
+
signature_algorithm: str = ""
|
|
104
|
+
signer_key_id: str = ""
|
|
105
|
+
manifest_signature: str = ""
|
|
106
|
+
manifest_payload: str = ""
|
|
107
|
+
anchor_uri: str = ""
|
|
108
|
+
anchored_at: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class LedgerVerificationResult(BaseModel):
|
|
112
|
+
"""Result of independent offline ledger verification."""
|
|
113
|
+
|
|
114
|
+
intact: bool
|
|
115
|
+
verified_entries: int
|
|
116
|
+
verified_manifests: int
|
|
117
|
+
latest_row_hash: str | None = None
|
|
118
|
+
latest_manifest_hash: str | None = None
|
|
119
|
+
error: str | None = None
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
5
7
|
import json
|
|
6
8
|
|
|
7
9
|
import httpx
|
|
@@ -309,6 +311,186 @@ class TestTokenVerification:
|
|
|
309
311
|
assert result.approved is True
|
|
310
312
|
|
|
311
313
|
|
|
314
|
+
class TestLedgerProofVerification:
|
|
315
|
+
"""Tests for ledger fetch + offline proof verification."""
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _b64url(value: bytes) -> str:
|
|
319
|
+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
|
320
|
+
|
|
321
|
+
@respx.mock
|
|
322
|
+
def test_fetch_ledger_and_manifests(self, client: LedgixClient):
|
|
323
|
+
respx.get("https://vault.test/ledger?limit=2").mock(
|
|
324
|
+
return_value=Response(
|
|
325
|
+
200,
|
|
326
|
+
json={
|
|
327
|
+
"entries": [
|
|
328
|
+
{
|
|
329
|
+
"seq": 2,
|
|
330
|
+
"request_id": "req-2",
|
|
331
|
+
"tool_name": "stripe_refund",
|
|
332
|
+
"approved": True,
|
|
333
|
+
"decided_at": "2026-03-15T12:00:00Z",
|
|
334
|
+
"row_hash": "b" * 64,
|
|
335
|
+
}
|
|
336
|
+
]
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
respx.get("https://vault.test/ledger/manifests?limit=3").mock(
|
|
341
|
+
return_value=Response(
|
|
342
|
+
200,
|
|
343
|
+
json={
|
|
344
|
+
"manifests": [
|
|
345
|
+
{
|
|
346
|
+
"period_start": "2026-03-15T12:00:00Z",
|
|
347
|
+
"period_end_exclusive": "2026-03-15T13:00:00Z",
|
|
348
|
+
"generated_at": "2026-03-15T13:00:00Z",
|
|
349
|
+
"head_seq": 2,
|
|
350
|
+
"head_row_hash": "b" * 64,
|
|
351
|
+
"manifest_hash": "sha256:" + ("c" * 64),
|
|
352
|
+
}
|
|
353
|
+
]
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
entries = client.fetch_ledger(limit=2)
|
|
359
|
+
manifests = client.fetch_ledger_manifests(limit=3)
|
|
360
|
+
|
|
361
|
+
assert len(entries) == 1
|
|
362
|
+
assert entries[0].request_id == "req-2"
|
|
363
|
+
assert len(manifests) == 1
|
|
364
|
+
assert manifests[0].head_seq == 2
|
|
365
|
+
|
|
366
|
+
@respx.mock
|
|
367
|
+
def test_verify_ledger_proof(
|
|
368
|
+
self,
|
|
369
|
+
client: LedgixClient,
|
|
370
|
+
ed25519_private_key,
|
|
371
|
+
jwks_response: dict,
|
|
372
|
+
):
|
|
373
|
+
row_payload = b'{"client_id":"demo","seq":1,"row_hash":"' + ("a" * 64).encode("ascii") + b'"}'
|
|
374
|
+
row_signature = ed25519_private_key.sign(row_payload)
|
|
375
|
+
manifest_payload = (
|
|
376
|
+
b'{"period_start":"2026-03-15T12:00:00Z","head_seq":1,"head_row_hash":"'
|
|
377
|
+
+ ("a" * 64).encode("ascii")
|
|
378
|
+
+ b'"}'
|
|
379
|
+
)
|
|
380
|
+
manifest_signature = ed25519_private_key.sign(manifest_payload)
|
|
381
|
+
manifest_hash = "sha256:" + hashlib.sha256(manifest_payload).hexdigest()
|
|
382
|
+
|
|
383
|
+
respx.get("https://vault.test/.well-known/jwks.json").mock(
|
|
384
|
+
return_value=Response(200, json=jwks_response)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
result = client.verify_ledger_proof(
|
|
388
|
+
entries=[
|
|
389
|
+
{
|
|
390
|
+
"seq": 1,
|
|
391
|
+
"request_id": "req-1",
|
|
392
|
+
"agent_id": "agent-1",
|
|
393
|
+
"policy_id": "policy-1",
|
|
394
|
+
"tool_name": "stripe_refund",
|
|
395
|
+
"tool_args": {"amount": 45},
|
|
396
|
+
"reason": "ok",
|
|
397
|
+
"approved": True,
|
|
398
|
+
"confidence": 0.91,
|
|
399
|
+
"decided_at": "2026-03-15T12:00:00Z",
|
|
400
|
+
"prev_row_hash": "0" * 64,
|
|
401
|
+
"row_hash": "a" * 64,
|
|
402
|
+
"signer_key_id": "test-key-001",
|
|
403
|
+
"row_signature": self._b64url(row_signature),
|
|
404
|
+
"receipt_payload": self._b64url(row_payload),
|
|
405
|
+
}
|
|
406
|
+
],
|
|
407
|
+
manifests=[
|
|
408
|
+
{
|
|
409
|
+
"period_start": "2026-03-15T12:00:00Z",
|
|
410
|
+
"period_end_exclusive": "2026-03-15T13:00:00Z",
|
|
411
|
+
"generated_at": "2026-03-15T13:00:00Z",
|
|
412
|
+
"head_seq": 1,
|
|
413
|
+
"head_row_hash": "a" * 64,
|
|
414
|
+
"head_row_signature": self._b64url(row_signature),
|
|
415
|
+
"manifest_hash": manifest_hash,
|
|
416
|
+
"prev_manifest_hash": "sha256:" + ("0" * 64),
|
|
417
|
+
"signer_key_id": "test-key-001",
|
|
418
|
+
"manifest_signature": self._b64url(manifest_signature),
|
|
419
|
+
"manifest_payload": self._b64url(manifest_payload),
|
|
420
|
+
"anchor_uri": "s3://ledgix-ledger/demo/2026-03-15T13:00:00Z.json",
|
|
421
|
+
"anchored_at": "2026-03-15T13:00:30Z",
|
|
422
|
+
}
|
|
423
|
+
],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
assert result.intact is True
|
|
427
|
+
assert result.verified_entries == 1
|
|
428
|
+
assert result.verified_manifests == 1
|
|
429
|
+
assert result.latest_row_hash == "a" * 64
|
|
430
|
+
|
|
431
|
+
@respx.mock
|
|
432
|
+
@pytest.mark.asyncio
|
|
433
|
+
async def test_verify_ledger_proof_async(
|
|
434
|
+
self,
|
|
435
|
+
client: LedgixClient,
|
|
436
|
+
ed25519_private_key,
|
|
437
|
+
jwks_response: dict,
|
|
438
|
+
):
|
|
439
|
+
row_payload = b'{"client_id":"demo","seq":2,"row_hash":"' + ("b" * 64).encode("ascii") + b'"}'
|
|
440
|
+
row_signature = ed25519_private_key.sign(row_payload)
|
|
441
|
+
manifest_payload = (
|
|
442
|
+
b'{"period_start":"2026-03-15T13:00:00Z","head_seq":2,"head_row_hash":"'
|
|
443
|
+
+ ("b" * 64).encode("ascii")
|
|
444
|
+
+ b'"}'
|
|
445
|
+
)
|
|
446
|
+
manifest_signature = ed25519_private_key.sign(manifest_payload)
|
|
447
|
+
manifest_hash = "sha256:" + hashlib.sha256(manifest_payload).hexdigest()
|
|
448
|
+
|
|
449
|
+
respx.get("https://vault.test/.well-known/jwks.json").mock(
|
|
450
|
+
return_value=Response(200, json=jwks_response)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
result = await client.averify_ledger_proof(
|
|
454
|
+
entries=[
|
|
455
|
+
{
|
|
456
|
+
"seq": 2,
|
|
457
|
+
"request_id": "req-2",
|
|
458
|
+
"agent_id": "agent-2",
|
|
459
|
+
"policy_id": "policy-2",
|
|
460
|
+
"tool_name": "stripe_refund",
|
|
461
|
+
"tool_args": {"amount": 60},
|
|
462
|
+
"reason": "ok",
|
|
463
|
+
"approved": True,
|
|
464
|
+
"confidence": 0.88,
|
|
465
|
+
"decided_at": "2026-03-15T13:00:00Z",
|
|
466
|
+
"prev_row_hash": "0" * 64,
|
|
467
|
+
"row_hash": "b" * 64,
|
|
468
|
+
"signer_key_id": "test-key-001",
|
|
469
|
+
"row_signature": self._b64url(row_signature),
|
|
470
|
+
"receipt_payload": self._b64url(row_payload),
|
|
471
|
+
}
|
|
472
|
+
],
|
|
473
|
+
manifests=[
|
|
474
|
+
{
|
|
475
|
+
"period_start": "2026-03-15T13:00:00Z",
|
|
476
|
+
"period_end_exclusive": "2026-03-15T14:00:00Z",
|
|
477
|
+
"generated_at": "2026-03-15T14:00:00Z",
|
|
478
|
+
"head_seq": 2,
|
|
479
|
+
"head_row_hash": "b" * 64,
|
|
480
|
+
"head_row_signature": self._b64url(row_signature),
|
|
481
|
+
"manifest_hash": manifest_hash,
|
|
482
|
+
"prev_manifest_hash": "sha256:" + ("0" * 64),
|
|
483
|
+
"signer_key_id": "test-key-001",
|
|
484
|
+
"manifest_signature": self._b64url(manifest_signature),
|
|
485
|
+
"manifest_payload": self._b64url(manifest_payload),
|
|
486
|
+
}
|
|
487
|
+
],
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
assert result.intact is True
|
|
491
|
+
assert result.latest_manifest_hash == manifest_hash
|
|
492
|
+
|
|
493
|
+
|
|
312
494
|
# ──────────────────────────────────────────────────────────────────────
|
|
313
495
|
# Client lifecycle
|
|
314
496
|
# ──────────────────────────────────────────────────────────────────────
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|