agentguard-python-sdk 0.1.0__tar.gz → 0.1.2__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.
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/PKG-INFO +6 -4
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/README.md +54 -54
- agentguard_python_sdk-0.1.2/agentguard/__init__.py +4 -0
- agentguard_python_sdk-0.1.2/agentguard/auth.py +54 -0
- agentguard_python_sdk-0.1.2/agentguard/client.py +407 -0
- agentguard_python_sdk-0.1.2/agentguard/config.py +34 -0
- agentguard_python_sdk-0.1.2/agentguard/consent.py +79 -0
- agentguard_python_sdk-0.1.2/agentguard/errors.py +83 -0
- agentguard_python_sdk-0.1.2/agentguard/observability.py +52 -0
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/agentguard/types.py +25 -25
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/agentguard_python_sdk.egg-info/PKG-INFO +6 -4
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/agentguard_python_sdk.egg-info/SOURCES.txt +4 -0
- agentguard_python_sdk-0.1.2/agentguard_python_sdk.egg-info/requires.txt +4 -0
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/pyproject.toml +24 -22
- agentguard_python_sdk-0.1.0/agentguard/__init__.py +0 -3
- agentguard_python_sdk-0.1.0/agentguard/client.py +0 -126
- agentguard_python_sdk-0.1.0/agentguard/consent.py +0 -58
- agentguard_python_sdk-0.1.0/agentguard_python_sdk.egg-info/requires.txt +0 -2
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/agentguard_python_sdk.egg-info/dependency_links.txt +0 -0
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/agentguard_python_sdk.egg-info/top_level.txt +0 -0
- {agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/setup.cfg +0 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentguard-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A production-grade middleware for AI agents to perform on-chain payments and verifiable consent.
|
|
5
5
|
Author: AgentGuard Team
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.9
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
9
|
Requires-Dist: httpx>=0.27.0
|
|
10
10
|
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Requires-Dist: pynacl>=1.5.0
|
|
12
|
+
Requires-Dist: py-algorand-sdk>=2.0.0
|
|
11
13
|
|
|
12
14
|
# AgentGuard SDK
|
|
13
15
|
|
|
@@ -18,7 +20,7 @@ By isolating private keys in a hardened **MCP Server (Vault)** and using the **A
|
|
|
18
20
|
## Installation
|
|
19
21
|
|
|
20
22
|
```bash
|
|
21
|
-
pip install agentguard
|
|
23
|
+
pip install agentguard-python-sdk
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
## Quick Start (3-Line Usage)
|
|
@@ -34,7 +36,7 @@ async def main():
|
|
|
34
36
|
# 2. Perform a secure payment (includes automatic DPDP consent hashing)
|
|
35
37
|
receipt = await client.pay_and_fetch(
|
|
36
38
|
resource_url="https://api.stock.com/reliance",
|
|
37
|
-
amount_algo=0.05,
|
|
39
|
+
amount_algo="0.05",
|
|
38
40
|
purpose="Financial Analysis"
|
|
39
41
|
)
|
|
40
42
|
print(f"Payment Successful! TX ID: {receipt.tx_id}")
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
# AgentGuard SDK
|
|
2
|
-
|
|
3
|
-
AgentGuard is a production-grade middleware that enables AI agents to perform on-chain payments and generate verifiable consent proofs in compliance with DPDP (Digital Personal Data Protection) standards.
|
|
4
|
-
|
|
5
|
-
By isolating private keys in a hardened **MCP Server (Vault)** and using the **AgentGuard Backend (Dispatcher)** as a secure proxy, the SDK allows developers to add monetization and compliance to their agents with zero blockchain complexity.
|
|
6
|
-
|
|
7
|
-
## Installation
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
pip install agentguard
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Quick Start (3-Line Usage)
|
|
14
|
-
|
|
15
|
-
```python
|
|
16
|
-
import asyncio
|
|
17
|
-
from agentguard import AgentGuardClient
|
|
18
|
-
|
|
19
|
-
async def main():
|
|
20
|
-
# 1. Initialize the client
|
|
21
|
-
async with AgentGuardClient(wallet_address="YOUR_WALLET_ADDRESS") as client:
|
|
22
|
-
|
|
23
|
-
# 2. Perform a secure payment (includes automatic DPDP consent hashing)
|
|
24
|
-
receipt = await client.pay_and_fetch(
|
|
25
|
-
resource_url="https://api.stock.com/reliance",
|
|
26
|
-
amount_algo=0.05,
|
|
27
|
-
purpose="Financial Analysis"
|
|
28
|
-
)
|
|
29
|
-
print(f"Payment Successful! TX ID: {receipt.tx_id}")
|
|
30
|
-
|
|
31
|
-
# 3. Verify compliance audit trail
|
|
32
|
-
proof = await client.verify(receipt.tx_id)
|
|
33
|
-
print(f"On-Chain Verified: {proof.verified}")
|
|
34
|
-
|
|
35
|
-
if __name__ == "__main__":
|
|
36
|
-
asyncio.run(main())
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## Architecture
|
|
40
|
-
|
|
41
|
-
1. **SDK**: Generates UUID nonces and SHA256 consent hashes.
|
|
42
|
-
2. **Backend**: Proxies requests to the internal vault and maintains audit records.
|
|
43
|
-
3. **MCP Server (Vault)**: A hardened, isolated environment that signs transactions using Algorand.
|
|
44
|
-
4. **Algorand Blockchain**: Provides the irrefutable proof-of-consent and payment settlement.
|
|
45
|
-
|
|
46
|
-
## Features
|
|
47
|
-
|
|
48
|
-
- **Async First**: Built on `httpx` for high-performance agentic loops.
|
|
49
|
-
- **Zero-Trust**: Private keys never leave the vault.
|
|
50
|
-
- **DPDP Compliant**: Automatic cryptographic logging of purpose-bound consent.
|
|
51
|
-
- **Simple Audit**: Direct links to blockchain explorers for every transaction.
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
© 2026 AgentGuard Team. Built for the Algorand Ecosystem.
|
|
1
|
+
# AgentGuard SDK
|
|
2
|
+
|
|
3
|
+
AgentGuard is a production-grade middleware that enables AI agents to perform on-chain payments and generate verifiable consent proofs in compliance with DPDP (Digital Personal Data Protection) standards.
|
|
4
|
+
|
|
5
|
+
By isolating private keys in a hardened **MCP Server (Vault)** and using the **AgentGuard Backend (Dispatcher)** as a secure proxy, the SDK allows developers to add monetization and compliance to their agents with zero blockchain complexity.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install agentguard-python-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start (3-Line Usage)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from agentguard import AgentGuardClient
|
|
18
|
+
|
|
19
|
+
async def main():
|
|
20
|
+
# 1. Initialize the client
|
|
21
|
+
async with AgentGuardClient(wallet_address="YOUR_WALLET_ADDRESS") as client:
|
|
22
|
+
|
|
23
|
+
# 2. Perform a secure payment (includes automatic DPDP consent hashing)
|
|
24
|
+
receipt = await client.pay_and_fetch(
|
|
25
|
+
resource_url="https://api.stock.com/reliance",
|
|
26
|
+
amount_algo="0.05",
|
|
27
|
+
purpose="Financial Analysis"
|
|
28
|
+
)
|
|
29
|
+
print(f"Payment Successful! TX ID: {receipt.tx_id}")
|
|
30
|
+
|
|
31
|
+
# 3. Verify compliance audit trail
|
|
32
|
+
proof = await client.verify(receipt.tx_id)
|
|
33
|
+
print(f"On-Chain Verified: {proof.verified}")
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
1. **SDK**: Generates UUID nonces and SHA256 consent hashes.
|
|
42
|
+
2. **Backend**: Proxies requests to the internal vault and maintains audit records.
|
|
43
|
+
3. **MCP Server (Vault)**: A hardened, isolated environment that signs transactions using Algorand.
|
|
44
|
+
4. **Algorand Blockchain**: Provides the irrefutable proof-of-consent and payment settlement.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **Async First**: Built on `httpx` for high-performance agentic loops.
|
|
49
|
+
- **Zero-Trust**: Private keys never leave the vault.
|
|
50
|
+
- **DPDP Compliant**: Automatic cryptographic logging of purpose-bound consent.
|
|
51
|
+
- **Simple Audit**: Direct links to blockchain explorers for every transaction.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
© 2026 AgentGuard Team. Built for the Algorand Ecosystem.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unicodedata
|
|
3
|
+
import hashlib
|
|
4
|
+
import base64
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
import nacl.signing
|
|
8
|
+
import nacl.encoding
|
|
9
|
+
|
|
10
|
+
def canonicalize_payload(payload: Dict[str, Any]) -> bytes:
|
|
11
|
+
"""
|
|
12
|
+
Deterministic canonicalization (Priority 5).
|
|
13
|
+
1. NFC Normalize all strings recursively.
|
|
14
|
+
2. Sort keys.
|
|
15
|
+
3. No whitespace (separators=(',', ':')).
|
|
16
|
+
4. UTF-8 encoding.
|
|
17
|
+
"""
|
|
18
|
+
def normalize_rec(obj):
|
|
19
|
+
if isinstance(obj, str):
|
|
20
|
+
return unicodedata.normalize("NFC", obj)
|
|
21
|
+
if isinstance(obj, dict):
|
|
22
|
+
return {normalize_rec(k): normalize_rec(v) for k, v in obj.items()}
|
|
23
|
+
if isinstance(obj, list):
|
|
24
|
+
return [normalize_rec(i) for i in obj]
|
|
25
|
+
return obj
|
|
26
|
+
|
|
27
|
+
norm_payload = normalize_rec(payload)
|
|
28
|
+
canonical_str = json.dumps(
|
|
29
|
+
norm_payload,
|
|
30
|
+
sort_keys=True,
|
|
31
|
+
separators=(",", ":"),
|
|
32
|
+
ensure_ascii=False
|
|
33
|
+
)
|
|
34
|
+
return canonical_str.encode("utf-8")
|
|
35
|
+
|
|
36
|
+
def sign_request(payload: Dict[str, Any], private_key_b64: str) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Signs a canonical payload using ED25519.
|
|
39
|
+
Expects private_key_b64 (32-byte seed or 64-byte expanded key in b64).
|
|
40
|
+
"""
|
|
41
|
+
# Decoding private key
|
|
42
|
+
# Most Algorand private keys are the 32-byte seed.
|
|
43
|
+
# NaCl signing keys are initialized from the 32-byte seed.
|
|
44
|
+
seed = base64.b64decode(private_key_b64)
|
|
45
|
+
if len(seed) > 32:
|
|
46
|
+
seed = seed[:32] # Algorand SK is often seed + pubkey
|
|
47
|
+
|
|
48
|
+
signing_key = nacl.signing.SigningKey(seed)
|
|
49
|
+
|
|
50
|
+
canonical_bytes = canonicalize_payload(payload)
|
|
51
|
+
signature = signing_key.sign(canonical_bytes)
|
|
52
|
+
|
|
53
|
+
# Return detached signature in base64
|
|
54
|
+
return base64.b64encode(signature.signature).decode("utf-8")
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import uuid
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import warnings
|
|
6
|
+
import asyncio
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import json
|
|
10
|
+
import hashlib
|
|
11
|
+
import base64
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from decimal import Decimal, InvalidOperation
|
|
14
|
+
from typing import Optional, Dict, Any
|
|
15
|
+
from .consent import generate_consent_proof
|
|
16
|
+
from . import auth
|
|
17
|
+
from .types import AgentGuardReceipt, AuditProof
|
|
18
|
+
from .errors import map_backend_error, TransportError, AgentGuardError, VerificationError
|
|
19
|
+
from .config import AgentGuardConfig
|
|
20
|
+
from .observability import start_span
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("agentguard-sdk")
|
|
23
|
+
|
|
24
|
+
def parse_algo_amount_to_microalgos(amount: str) -> int:
|
|
25
|
+
"""
|
|
26
|
+
Parses a string ALGO amount into integer microAlgos.
|
|
27
|
+
|
|
28
|
+
STRICTLY rejects:
|
|
29
|
+
- Non-string inputs
|
|
30
|
+
- Scientific notation (e.g. 1e-6)
|
|
31
|
+
- Negative values
|
|
32
|
+
- More than 6 decimal places
|
|
33
|
+
- Unnecessary leading zeros (e.g. 00.1)
|
|
34
|
+
|
|
35
|
+
ARCHITECTURAL BOUNDARY:
|
|
36
|
+
- Decimal is used ONLY here for the one-time string-to-int conversion.
|
|
37
|
+
- The rest of the system MUST use integer microalgos.
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(amount, str):
|
|
40
|
+
raise ValueError(f"Amount must be a string (e.g. '0.25'), got {type(amount).__name__}")
|
|
41
|
+
|
|
42
|
+
# Strict Regex: (0 OR [1-9] followed by digits) followed optionally by a decimal and up to 6 digits
|
|
43
|
+
# Blocks: '00.1', '.5', '1e-1', '01'
|
|
44
|
+
if not re.match(r"^(0|[1-9]\d*)(\.\d{1,6})?$", amount):
|
|
45
|
+
raise ValueError(f"Invalid ALGO format: '{amount}'. Use '0.05' not '00.05' or '1e-2'.")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Atomic conversion at the trust boundary
|
|
49
|
+
d_amount = Decimal(amount)
|
|
50
|
+
microalgos = int(d_amount * Decimal("1000000"))
|
|
51
|
+
return microalgos
|
|
52
|
+
except (InvalidOperation, ValueError) as e:
|
|
53
|
+
raise ValueError(f"Conversion failed: {e}")
|
|
54
|
+
|
|
55
|
+
class AgentGuardClient:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
wallet_address: str,
|
|
59
|
+
config: Optional[AgentGuardConfig] = None,
|
|
60
|
+
private_key: str = None,
|
|
61
|
+
backend_url: Optional[str] = None,
|
|
62
|
+
indexer_url: Optional[str] = None
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Initialize the AgentGuard client with a structured config object or direct overrides.
|
|
66
|
+
"""
|
|
67
|
+
self.config = config or AgentGuardConfig()
|
|
68
|
+
|
|
69
|
+
# Apply direct overrides if provided
|
|
70
|
+
if backend_url:
|
|
71
|
+
self.config.backend_url = backend_url
|
|
72
|
+
if indexer_url:
|
|
73
|
+
self.config.indexer_url = indexer_url
|
|
74
|
+
|
|
75
|
+
self.wallet_address = wallet_address
|
|
76
|
+
self.base_url = self.config.backend_url.rstrip("/")
|
|
77
|
+
self.private_key = private_key
|
|
78
|
+
|
|
79
|
+
# Priority 6: Segmented Timeouts injected from Config
|
|
80
|
+
self.timeout = httpx.Timeout(
|
|
81
|
+
connect=self.config.timeout_connect,
|
|
82
|
+
read=self.config.timeout_read,
|
|
83
|
+
write=self.config.timeout_write,
|
|
84
|
+
pool=self.config.timeout_pool
|
|
85
|
+
)
|
|
86
|
+
self.async_client = httpx.AsyncClient(timeout=self.timeout)
|
|
87
|
+
|
|
88
|
+
# Configure logging based on config
|
|
89
|
+
logging.getLogger("agentguard-sdk").setLevel(self.config.logging_level)
|
|
90
|
+
|
|
91
|
+
async def pay_and_fetch(
|
|
92
|
+
self,
|
|
93
|
+
resource_url: str,
|
|
94
|
+
purpose: str,
|
|
95
|
+
amount_algo: Optional[str] = None,
|
|
96
|
+
microalgos: Optional[int] = None,
|
|
97
|
+
idempotency_key: Optional[str] = None,
|
|
98
|
+
nonce: Optional[str] = None,
|
|
99
|
+
timestamp: Optional[int] = None,
|
|
100
|
+
correlation_id: Optional[str] = None
|
|
101
|
+
) -> AgentGuardReceipt:
|
|
102
|
+
"""
|
|
103
|
+
Asynchronously perform an on-chain payment and return a typed receipt.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
resource_url: The URL/ID of the resource being accessed.
|
|
107
|
+
purpose: The purpose of the data processing.
|
|
108
|
+
amount_algo: The amount in ALGO as a string (e.g. '0.05').
|
|
109
|
+
microalgos: Alternatively, the exact amount in microAlgos (int).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
AgentGuardReceipt: A structured receipt with transaction details.
|
|
113
|
+
"""
|
|
114
|
+
# 1. Determine and Validate Amount
|
|
115
|
+
if amount_algo is not None and microalgos is not None:
|
|
116
|
+
raise ValueError("Provide either amount_algo or microalgos, not both.")
|
|
117
|
+
|
|
118
|
+
if amount_algo is not None:
|
|
119
|
+
actual_microalgos = parse_algo_amount_to_microalgos(amount_algo)
|
|
120
|
+
elif microalgos is not None:
|
|
121
|
+
if not isinstance(microalgos, int) or microalgos < 0:
|
|
122
|
+
raise ValueError(f"microalgos must be a non-negative integer, got {microalgos}")
|
|
123
|
+
actual_microalgos = microalgos
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError("Either amount_algo or microalgos must be provided.")
|
|
126
|
+
|
|
127
|
+
# 2. Idempotency & Replay Protection (PHASE C)
|
|
128
|
+
# We generate these early so they can be persisted across retries if needed.
|
|
129
|
+
idem_key = idempotency_key or str(uuid.uuid4())
|
|
130
|
+
act_nonce = nonce or str(uuid.uuid4())
|
|
131
|
+
act_correlation_id = correlation_id or str(idem_key)
|
|
132
|
+
|
|
133
|
+
# 3. Generate Consent Proof (SHA256 of metadata including microalgos)
|
|
134
|
+
# If timestamp is provided (from a previous retry), we reuse it.
|
|
135
|
+
consent_hash, act_timestamp = generate_consent_proof(
|
|
136
|
+
principal_id=self.wallet_address,
|
|
137
|
+
resource_url=resource_url,
|
|
138
|
+
purpose=purpose,
|
|
139
|
+
nonce=act_nonce,
|
|
140
|
+
microalgos=actual_microalgos,
|
|
141
|
+
timestamp=timestamp # Pass through for retry consistency
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# 4. Payload Construction
|
|
145
|
+
payload = {
|
|
146
|
+
"resource_url": resource_url,
|
|
147
|
+
"microalgos": actual_microalgos,
|
|
148
|
+
"purpose": purpose,
|
|
149
|
+
"principal_id": self.wallet_address,
|
|
150
|
+
"consent_hash": consent_hash,
|
|
151
|
+
"nonce": act_nonce,
|
|
152
|
+
"timestamp": act_timestamp,
|
|
153
|
+
"idempotency_key": idem_key,
|
|
154
|
+
"correlation_id": act_correlation_id
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
headers = {
|
|
158
|
+
"X-Idempotency-Key": idem_key,
|
|
159
|
+
"X-Correlation-ID": act_correlation_id
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# 5. Cryptographic Signing (Priority 5)
|
|
163
|
+
if self.private_key:
|
|
164
|
+
signature = auth.sign_request(payload, self.private_key)
|
|
165
|
+
headers.update({
|
|
166
|
+
"X-AgentGuard-Signature": signature,
|
|
167
|
+
"X-AgentGuard-Timestamp": str(act_timestamp),
|
|
168
|
+
"X-AgentGuard-Key-Id": self.wallet_address,
|
|
169
|
+
"X-AgentGuard-Nonce": act_nonce
|
|
170
|
+
})
|
|
171
|
+
# Priority 6: Signing verified via Span attributes if needed
|
|
172
|
+
|
|
173
|
+
# 6. Request with Retries
|
|
174
|
+
span = start_span("sdk.pay_request", act_correlation_id)
|
|
175
|
+
span.set_attribute("wallet", self.wallet_address)
|
|
176
|
+
span.set_attribute("microalgos", actual_microalgos)
|
|
177
|
+
span.set_attribute("idempotency_key", idem_key)
|
|
178
|
+
|
|
179
|
+
max_attempts = self.config.max_retries
|
|
180
|
+
base_delay = self.config.retry_base_delay
|
|
181
|
+
|
|
182
|
+
for attempt in range(max_attempts):
|
|
183
|
+
attempt_span = start_span(f"sdk.request_attempt_{attempt + 1}", act_correlation_id, parent_id=span.span_id)
|
|
184
|
+
try:
|
|
185
|
+
response = await self.async_client.post(f"{self.base_url}/pay", json=payload, headers=headers)
|
|
186
|
+
|
|
187
|
+
if response.status_code == 200:
|
|
188
|
+
res_data = response.json()
|
|
189
|
+
attempt_span.end()
|
|
190
|
+
span.set_attribute("tx_id", res_data["tx_id"])
|
|
191
|
+
span.end()
|
|
192
|
+
return AgentGuardReceipt(
|
|
193
|
+
tx_id=res_data["tx_id"],
|
|
194
|
+
consent_hash=res_data["consent_hash"],
|
|
195
|
+
audit_url=res_data["audit_url"],
|
|
196
|
+
data=res_data.get("data", {})
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
try:
|
|
200
|
+
error_data = response.json()
|
|
201
|
+
except:
|
|
202
|
+
error_data = {"message": response.text}
|
|
203
|
+
|
|
204
|
+
attempt_span.end(error=f"http_{response.status_code}: {error_data.get('message')}")
|
|
205
|
+
|
|
206
|
+
# Transient errors -> retry
|
|
207
|
+
if response.status_code in [502, 503, 504, 429]:
|
|
208
|
+
if attempt < max_attempts - 1:
|
|
209
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
|
|
210
|
+
await asyncio.sleep(delay)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
span.end(error=f"backend_rejection_{response.status_code}")
|
|
214
|
+
raise map_backend_error(response.status_code, error_data)
|
|
215
|
+
|
|
216
|
+
except httpx.RequestError as e:
|
|
217
|
+
attempt_span.end(error=str(e))
|
|
218
|
+
if attempt < max_attempts - 1:
|
|
219
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
|
|
220
|
+
await asyncio.sleep(delay)
|
|
221
|
+
continue
|
|
222
|
+
span.end(error="transport_failure")
|
|
223
|
+
raise TransportError(f"Could not connect to AgentGuard Backend: {e}")
|
|
224
|
+
except Exception as e:
|
|
225
|
+
attempt_span.end(error=str(e))
|
|
226
|
+
span.end(error="unexpected_sdk_error")
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
async def verify(self, tx_id: str, verify_onchain: bool = False, local_proof: Optional[Dict[str, Any]] = None) -> AuditProof:
|
|
230
|
+
"""
|
|
231
|
+
Asynchronously verify a transaction against the blockchain audit trail.
|
|
232
|
+
|
|
233
|
+
If verify_onchain is True, the SDK performs an independent check against the
|
|
234
|
+
Algorand indexer and re-computes the proof hash locally.
|
|
235
|
+
|
|
236
|
+
If local_proof is provided, the SDK uses it to recompute the hash even if the
|
|
237
|
+
backend has no record of the transaction (Split-Brain Recovery).
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
# 1. Fetch Backend's Assertion (Metadata + Proof)
|
|
241
|
+
res_data = {}
|
|
242
|
+
backend_failed = False
|
|
243
|
+
try:
|
|
244
|
+
response = await self.async_client.get(f"{self.base_url}/verify/{tx_id}")
|
|
245
|
+
response.raise_for_status()
|
|
246
|
+
res_data = response.json()
|
|
247
|
+
except Exception as e:
|
|
248
|
+
if not verify_onchain:
|
|
249
|
+
# In legacy mode, we must have the backend response
|
|
250
|
+
raise
|
|
251
|
+
logger.warning(f"Backend lookup for {tx_id} failed: {e}. Attempting independent recovery.")
|
|
252
|
+
backend_failed = True
|
|
253
|
+
|
|
254
|
+
# 2. Independent Logic
|
|
255
|
+
if verify_onchain:
|
|
256
|
+
logger.info(f"PERFORMING INDEPENDENT ON-CHAIN VERIFICATION FOR {tx_id}")
|
|
257
|
+
|
|
258
|
+
# A. Recompute Hash locally
|
|
259
|
+
# Content source: Backend provided proof OR user provided local_proof
|
|
260
|
+
canonical_json = res_data.get("canonical_json")
|
|
261
|
+
|
|
262
|
+
if not canonical_json and local_proof:
|
|
263
|
+
# [Priority 8 BONUS] Reconstruct canonical JSON from raw local proof
|
|
264
|
+
from .auth import canonicalize_payload
|
|
265
|
+
canonical_bytes = canonicalize_payload(local_proof)
|
|
266
|
+
canonical_json = canonical_bytes.decode("utf-8")
|
|
267
|
+
|
|
268
|
+
if not canonical_json:
|
|
269
|
+
reason = "Backend missing proof" if not backend_failed else "Backend unreachable"
|
|
270
|
+
raise VerificationError(f"{reason} and no local_proof provided for recovery.")
|
|
271
|
+
|
|
272
|
+
local_hash = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
|
|
273
|
+
|
|
274
|
+
# B. Interrogate Blockchain directly
|
|
275
|
+
on_chain_hash = await self._fetch_on_chain_proof(tx_id)
|
|
276
|
+
|
|
277
|
+
# C. Compare
|
|
278
|
+
if local_hash != on_chain_hash:
|
|
279
|
+
# [SECURITY BOUNDARY] The Backend is lying.
|
|
280
|
+
logger.error(f"TRUSTLESS VERIFICATION FAILED: Local Hash {local_hash} != On-chain Hash {on_chain_hash}")
|
|
281
|
+
return AuditProof(
|
|
282
|
+
verified=False,
|
|
283
|
+
tx_id=tx_id,
|
|
284
|
+
principal_id=res_data.get("principal_id", "N/A"),
|
|
285
|
+
consent_hash=local_hash,
|
|
286
|
+
on_chain_hash=on_chain_hash,
|
|
287
|
+
explorer_url=self.config.backend_url, # Placeholder
|
|
288
|
+
message="CRITICAL FAILURE: Local proof does not match blockchain evidence."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
logger.info("TRUSTLESS VERIFICATION SUCCESS: Blockchain evidence matches backend assertion.")
|
|
292
|
+
return AuditProof(
|
|
293
|
+
verified=True,
|
|
294
|
+
tx_id=tx_id,
|
|
295
|
+
principal_id=res_data.get("principal_id", "N/A"),
|
|
296
|
+
consent_hash=local_hash,
|
|
297
|
+
on_chain_hash=on_chain_hash,
|
|
298
|
+
explorer_url=res_data.get("explorer_url", ""),
|
|
299
|
+
message="Trustless independent verification successful."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Legacy/Backend-trusting behavior
|
|
303
|
+
return AuditProof(
|
|
304
|
+
verified=res_data["verified"],
|
|
305
|
+
tx_id=res_data["tx_id"],
|
|
306
|
+
principal_id=res_data["principal_id"],
|
|
307
|
+
consent_hash=res_data["consent_hash"],
|
|
308
|
+
on_chain_hash=res_data["on_chain_hash"],
|
|
309
|
+
explorer_url=res_data["explorer_url"],
|
|
310
|
+
message=res_data["message"]
|
|
311
|
+
)
|
|
312
|
+
except httpx.HTTPStatusError as e:
|
|
313
|
+
try:
|
|
314
|
+
error_data = e.response.json()
|
|
315
|
+
except Exception:
|
|
316
|
+
error_data = {"message": e.response.text, "code": "ERR_UNKNOWN"}
|
|
317
|
+
raise map_backend_error(e.response.status_code, error_data, original_exception=e) from e
|
|
318
|
+
except httpx.RequestError as e:
|
|
319
|
+
raise TransportError(f"Verification transport failed: {e}", original_exception=e) from e
|
|
320
|
+
except Exception as e:
|
|
321
|
+
if isinstance(e, AgentGuardError): raise
|
|
322
|
+
raise VerificationError(f"Independent verification failed: {str(e)}", original_exception=e)
|
|
323
|
+
|
|
324
|
+
async def _fetch_on_chain_proof(self, tx_id: str) -> str:
|
|
325
|
+
"""
|
|
326
|
+
Directly queries the Algorand Indexer to extract the proof hash.
|
|
327
|
+
Supports atomic group detection.
|
|
328
|
+
"""
|
|
329
|
+
indexer_url = self.config.indexer_url.rstrip("/")
|
|
330
|
+
try:
|
|
331
|
+
# 1. Fetch TX
|
|
332
|
+
resp = await self.async_client.get(f"{indexer_url}/transactions/{tx_id}")
|
|
333
|
+
resp.raise_for_status()
|
|
334
|
+
tx_data = resp.json().get("transaction", {})
|
|
335
|
+
|
|
336
|
+
# 2. Extract proof or hunt in group
|
|
337
|
+
relevant_txn = tx_data
|
|
338
|
+
group_id = tx_data.get("group")
|
|
339
|
+
|
|
340
|
+
if group_id and not tx_data.get("application-transaction"):
|
|
341
|
+
# If this was one part of a group (like the payment part), find the App call
|
|
342
|
+
import urllib.parse
|
|
343
|
+
safe_group = urllib.parse.quote(group_id)
|
|
344
|
+
g_resp = await self.async_client.get(f"{indexer_url}/transactions?group-id={safe_group}")
|
|
345
|
+
if g_resp.status_code == 200:
|
|
346
|
+
group_txns = g_resp.json().get("transactions", [])
|
|
347
|
+
for g_tx in group_txns:
|
|
348
|
+
if g_tx.get("application-transaction"):
|
|
349
|
+
relevant_txn = g_tx
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
app_txn = relevant_txn.get("application-transaction", {})
|
|
353
|
+
args = app_txn.get("application-args", [])
|
|
354
|
+
|
|
355
|
+
if not args:
|
|
356
|
+
raise VerificationError("Blockchain record found but no AppArgs proof exists.")
|
|
357
|
+
|
|
358
|
+
# Decode proof (Arg 0 is Sha256 hex as base64-encoded bytes on wire)
|
|
359
|
+
on_chain_hash_hex = base64.b64decode(args[0]).decode("utf-8")
|
|
360
|
+
return on_chain_hash_hex
|
|
361
|
+
|
|
362
|
+
except httpx.HTTPStatusError as e:
|
|
363
|
+
if e.response.status_code == 404:
|
|
364
|
+
raise VerificationError(f"Transaction {tx_id} not found on blockchain indexer.")
|
|
365
|
+
raise TransportError(f"Indexer connection failed: {e.response.status_code}")
|
|
366
|
+
except Exception as e:
|
|
367
|
+
raise VerificationError(f"Failed to extract blockchain proof: {str(e)}")
|
|
368
|
+
|
|
369
|
+
async def register_budget(self, limit_algo: Optional[str] = None, limit_microalgos: Optional[int] = None) -> Dict[str, Any]:
|
|
370
|
+
"""
|
|
371
|
+
Convenience method to register/update the user's budget.
|
|
372
|
+
"""
|
|
373
|
+
if limit_algo is not None:
|
|
374
|
+
actual_limit = parse_algo_amount_to_microalgos(limit_algo)
|
|
375
|
+
elif limit_microalgos is not None:
|
|
376
|
+
actual_limit = limit_microalgos
|
|
377
|
+
else:
|
|
378
|
+
raise ValueError("Either limit_algo or limit_microalgos must be provided.")
|
|
379
|
+
|
|
380
|
+
payload = {
|
|
381
|
+
"wallet_address": self.wallet_address,
|
|
382
|
+
"budget_microalgos": actual_limit
|
|
383
|
+
}
|
|
384
|
+
try:
|
|
385
|
+
response = await self.async_client.post(f"{self.base_url}/user/register", json=payload)
|
|
386
|
+
response.raise_for_status()
|
|
387
|
+
return response.json()
|
|
388
|
+
except httpx.HTTPStatusError as e:
|
|
389
|
+
try:
|
|
390
|
+
error_data = e.response.json()
|
|
391
|
+
except Exception:
|
|
392
|
+
error_data = {"message": e.response.text, "code": "ERR_UNKNOWN"}
|
|
393
|
+
raise map_backend_error(e.response.status_code, error_data, original_exception=e) from e
|
|
394
|
+
except httpx.RequestError as e:
|
|
395
|
+
raise TransportError(f"Budget registration transport failed: {e}", original_exception=e) from e
|
|
396
|
+
|
|
397
|
+
async def close(self):
|
|
398
|
+
"""Close the underlying HTTP client."""
|
|
399
|
+
await self.async_client.aclose()
|
|
400
|
+
|
|
401
|
+
async def __aenter__(self):
|
|
402
|
+
return self
|
|
403
|
+
|
|
404
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
405
|
+
await self.close()
|
|
406
|
+
|
|
407
|
+
# Removed _log_event because it's replaced by Span system
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
class AgentGuardConfig(BaseSettings):
|
|
6
|
+
"""
|
|
7
|
+
Typed configuration for the AgentGuard SDK.
|
|
8
|
+
Supports environment variable overrides with AGENTGUARD_ prefix.
|
|
9
|
+
"""
|
|
10
|
+
environment: str = Field("testnet", pattern="^(local|development|testnet|production)$")
|
|
11
|
+
backend_url: str = Field("https://agentguard-backend.onrender.com")
|
|
12
|
+
|
|
13
|
+
# Transport Policy
|
|
14
|
+
timeout_connect: float = 5.0
|
|
15
|
+
timeout_read: float = 30.0
|
|
16
|
+
timeout_write: float = 10.0
|
|
17
|
+
timeout_pool: float = 5.0
|
|
18
|
+
|
|
19
|
+
# Retry Policy
|
|
20
|
+
max_retries: int = 3
|
|
21
|
+
retry_base_delay: float = 1.0
|
|
22
|
+
|
|
23
|
+
# Observability
|
|
24
|
+
logging_level: str = "INFO"
|
|
25
|
+
tracing_enabled: bool = True
|
|
26
|
+
|
|
27
|
+
# [Priority 8] Trustless Verification
|
|
28
|
+
indexer_url: str = Field("https://testnet-idx.algonode.cloud/v2")
|
|
29
|
+
verify_timeout: float = 30.0
|
|
30
|
+
verify_max_retries: int = 3
|
|
31
|
+
|
|
32
|
+
class Config:
|
|
33
|
+
env_prefix = "AGENTGUARD_"
|
|
34
|
+
case_sensitive = False
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import unicodedata
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
|
|
8
|
+
# Configure logging
|
|
9
|
+
logger = logging.getLogger("agentguard-sdk")
|
|
10
|
+
|
|
11
|
+
def generate_consent(
|
|
12
|
+
principal_id: str,
|
|
13
|
+
purpose: str,
|
|
14
|
+
item: str,
|
|
15
|
+
nonce: str,
|
|
16
|
+
microalgos: int,
|
|
17
|
+
data_fiduciary: str = "AlgoBharat Agentic Node",
|
|
18
|
+
timestamp: int = None
|
|
19
|
+
) -> Dict[str, Any]:
|
|
20
|
+
"""
|
|
21
|
+
Creates a user consent object dictionary containing DPDP compliant fields.
|
|
22
|
+
Includes microalgos (int) and hash_version for future-proofing.
|
|
23
|
+
|
|
24
|
+
Representation Rule:
|
|
25
|
+
- Internal: microalgos (int) is the single source of truth.
|
|
26
|
+
- Strings: All metadata is normalized to NFC.
|
|
27
|
+
"""
|
|
28
|
+
if timestamp is None:
|
|
29
|
+
timestamp = int(time.time())
|
|
30
|
+
|
|
31
|
+
# Normalize strings to NFC at the creation boundary
|
|
32
|
+
consent_obj = {
|
|
33
|
+
"principal_id": unicodedata.normalize("NFC", principal_id),
|
|
34
|
+
"data_fiduciary": unicodedata.normalize("NFC", data_fiduciary),
|
|
35
|
+
"purpose": unicodedata.normalize("NFC", purpose),
|
|
36
|
+
"item": unicodedata.normalize("NFC", item),
|
|
37
|
+
"microalgos": microalgos,
|
|
38
|
+
"timestamp": timestamp,
|
|
39
|
+
"nonce": nonce,
|
|
40
|
+
"hash_version": 2 # Current Refactor Version
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logger.info(f"Generated DPDP consent (v2) for principal={principal_id} at {timestamp}")
|
|
44
|
+
return consent_obj
|
|
45
|
+
|
|
46
|
+
def hash_consent(consent: Dict[str, Any]) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Canonical hashing implementation.
|
|
49
|
+
MUST REMAINT SYNCED with Backend and MCP Server.
|
|
50
|
+
|
|
51
|
+
Rules:
|
|
52
|
+
1. sort_keys=True
|
|
53
|
+
2. separators=(',', ':')
|
|
54
|
+
3. ensure_ascii=False
|
|
55
|
+
4. Encoding: utf-8
|
|
56
|
+
"""
|
|
57
|
+
# Convert consent dict to JSON string with strict canonical form:
|
|
58
|
+
# - Sorted keys
|
|
59
|
+
# - No spaces (separators=(",", ":"))
|
|
60
|
+
# Use separators=(",", ":") and ensure_ascii=False for identical bytes across boundaries
|
|
61
|
+
consent_json = json.dumps(consent, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
62
|
+
consent_hash = hashlib.sha256(consent_json.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
logger.info(f"Consent hash generated: {consent_hash}")
|
|
65
|
+
return consent_hash
|
|
66
|
+
|
|
67
|
+
def generate_consent_proof(principal_id: str, resource_url: str, purpose: str, nonce: str, microalgos: int, timestamp: int = None) -> tuple[str, int]:
|
|
68
|
+
"""
|
|
69
|
+
High-level helper to generate a consent hash and return the timestamp used.
|
|
70
|
+
"""
|
|
71
|
+
consent = generate_consent(
|
|
72
|
+
principal_id=principal_id,
|
|
73
|
+
purpose=purpose,
|
|
74
|
+
item=resource_url,
|
|
75
|
+
nonce=nonce,
|
|
76
|
+
microalgos=microalgos,
|
|
77
|
+
timestamp=timestamp
|
|
78
|
+
)
|
|
79
|
+
return hash_consent(consent), consent["timestamp"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
class AgentGuardError(Exception):
|
|
2
|
+
"""Base exception for all AgentGuard SDK errors."""
|
|
3
|
+
def __init__(self, message: str, code: str, retryable: bool = False, original_exception=None):
|
|
4
|
+
super().__init__(message)
|
|
5
|
+
self.message = message
|
|
6
|
+
self.code = code
|
|
7
|
+
self.retryable = retryable
|
|
8
|
+
self.original_exception = original_exception
|
|
9
|
+
|
|
10
|
+
class ValidationError(AgentGuardError):
|
|
11
|
+
def __init__(self, message: str, original_exception=None):
|
|
12
|
+
super().__init__(message, "ERR_VALIDATION_FAILURE", False, original_exception)
|
|
13
|
+
|
|
14
|
+
class TransportError(AgentGuardError):
|
|
15
|
+
def __init__(self, message: str, original_exception=None):
|
|
16
|
+
super().__init__(message, "ERR_TRANSPORT_FAILURE", True, original_exception)
|
|
17
|
+
|
|
18
|
+
# Specific errors based on Backend contract
|
|
19
|
+
class AuthenticationError(AgentGuardError):
|
|
20
|
+
def __init__(self, message: str, code: str = "ERR_AUTH_FAILURE", retryable: bool = False, original_exception=None):
|
|
21
|
+
super().__init__(message, code, retryable, original_exception)
|
|
22
|
+
|
|
23
|
+
class InvalidSignatureError(AuthenticationError):
|
|
24
|
+
def __init__(self, message: str, original_exception=None):
|
|
25
|
+
super().__init__(message, "ERR_INVALID_SIGNATURE", False, original_exception)
|
|
26
|
+
|
|
27
|
+
class ExpiredSignatureError(AuthenticationError):
|
|
28
|
+
def __init__(self, message: str, original_exception=None):
|
|
29
|
+
super().__init__(message, "ERR_EXPIRED_SIGNATURE", False, original_exception)
|
|
30
|
+
|
|
31
|
+
class PaymentError(AgentGuardError):
|
|
32
|
+
def __init__(self, message: str, code: str = "ERR_PAYMENT_FAILURE", retryable: bool = False, original_exception=None):
|
|
33
|
+
super().__init__(message, code, retryable, original_exception)
|
|
34
|
+
|
|
35
|
+
class ReplayAttackError(PaymentError):
|
|
36
|
+
def __init__(self, message: str, original_exception=None):
|
|
37
|
+
super().__init__(message, "ERR_REPLAY_ATTACK", False, original_exception)
|
|
38
|
+
|
|
39
|
+
class DuplicatePaymentError(PaymentError):
|
|
40
|
+
def __init__(self, message: str, original_exception=None):
|
|
41
|
+
super().__init__(message, "ERR_DUPLICATE_PAYMENT", False, original_exception)
|
|
42
|
+
|
|
43
|
+
class BudgetExceededError(PaymentError):
|
|
44
|
+
def __init__(self, message: str, original_exception=None):
|
|
45
|
+
super().__init__(message, "ERR_BUDGET_EXCEEDED", False, original_exception)
|
|
46
|
+
|
|
47
|
+
class VerificationError(AgentGuardError):
|
|
48
|
+
def __init__(self, message: str, original_exception=None):
|
|
49
|
+
super().__init__(message, "ERR_VERIFICATION_FAILURE", False, original_exception)
|
|
50
|
+
|
|
51
|
+
class UnknownExecutionStateError(PaymentError):
|
|
52
|
+
def __init__(self, message: str, original_exception=None):
|
|
53
|
+
super().__init__(message, "ERR_UNKNOWN_EXECUTION_STATE", False, original_exception)
|
|
54
|
+
|
|
55
|
+
def map_backend_error(status_code: int, error_data: dict, original_exception=None) -> AgentGuardError:
|
|
56
|
+
"""Maps the standardized backend JSONError payload to SDK exceptions."""
|
|
57
|
+
if not isinstance(error_data, dict):
|
|
58
|
+
return TransportError(f"Unexpected response format from backend: {error_data}", original_exception)
|
|
59
|
+
|
|
60
|
+
code = error_data.get("code", "ERR_UNKNOWN")
|
|
61
|
+
message = error_data.get("message", "Unknown error occurred")
|
|
62
|
+
retryable = error_data.get("retryable", False)
|
|
63
|
+
|
|
64
|
+
if code == "ERR_VALIDATION_FAILURE":
|
|
65
|
+
return ValidationError(message, original_exception)
|
|
66
|
+
elif code == "ERR_REPLAY_ATTACK":
|
|
67
|
+
return ReplayAttackError(message, original_exception)
|
|
68
|
+
elif code == "ERR_DUPLICATE_PAYMENT":
|
|
69
|
+
return DuplicatePaymentError(message, original_exception)
|
|
70
|
+
elif code == "ERR_BUDGET_EXCEEDED":
|
|
71
|
+
return BudgetExceededError(message, original_exception)
|
|
72
|
+
elif code == "ERR_UNKNOWN_EXECUTION_STATE":
|
|
73
|
+
return UnknownExecutionStateError(message, original_exception)
|
|
74
|
+
elif code == "ERR_AUTH_FAILURE":
|
|
75
|
+
return AuthenticationError(message, code, retryable, original_exception)
|
|
76
|
+
elif code == "ERR_INVALID_SIGNATURE":
|
|
77
|
+
return InvalidSignatureError(message, original_exception)
|
|
78
|
+
elif code == "ERR_EXPIRED_SIGNATURE":
|
|
79
|
+
return ExpiredSignatureError(message, original_exception)
|
|
80
|
+
elif "ERR_PAYMENT" in code:
|
|
81
|
+
return PaymentError(message, code, retryable, original_exception)
|
|
82
|
+
else:
|
|
83
|
+
return AgentGuardError(message, code, retryable, original_exception)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
|
|
8
|
+
# [PRIORITY 9] SDK OBSERVABILITY CORE
|
|
9
|
+
|
|
10
|
+
class Span:
|
|
11
|
+
def __init__(self, name: str, trace_id: str, parent_id: Optional[str] = None):
|
|
12
|
+
self.name = name
|
|
13
|
+
self.trace_id = trace_id
|
|
14
|
+
self.span_id = str(uuid.uuid4())[:8]
|
|
15
|
+
self.parent_id = parent_id
|
|
16
|
+
self.start_time = time.time()
|
|
17
|
+
self.metadata = {}
|
|
18
|
+
|
|
19
|
+
def set_attribute(self, key: str, value: Any):
|
|
20
|
+
self.metadata[key] = value
|
|
21
|
+
|
|
22
|
+
def end(self, error: Optional[str] = None):
|
|
23
|
+
duration = (time.time() - self.start_time) * 1000
|
|
24
|
+
log_data = {
|
|
25
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
26
|
+
"level": "INFO" if not error else "ERROR",
|
|
27
|
+
"service": "agentguard-sdk",
|
|
28
|
+
"event": "span_end",
|
|
29
|
+
"span_name": self.name,
|
|
30
|
+
"span_id": self.span_id,
|
|
31
|
+
"parent_id": self.parent_id,
|
|
32
|
+
"trace_id": self.trace_id,
|
|
33
|
+
"duration_ms": duration,
|
|
34
|
+
"error": error,
|
|
35
|
+
"metadata": self.metadata
|
|
36
|
+
}
|
|
37
|
+
logging.getLogger("agentguard-sdk").info(json.dumps(log_data))
|
|
38
|
+
|
|
39
|
+
def start_span(name: str, trace_id: str, parent_id: Optional[str] = None) -> Span:
|
|
40
|
+
span = Span(name, trace_id, parent_id)
|
|
41
|
+
log_data = {
|
|
42
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
43
|
+
"level": "INFO",
|
|
44
|
+
"service": "agentguard-sdk",
|
|
45
|
+
"event": "span_start",
|
|
46
|
+
"span_name": name,
|
|
47
|
+
"span_id": span.span_id,
|
|
48
|
+
"parent_id": parent_id,
|
|
49
|
+
"trace_id": trace_id
|
|
50
|
+
}
|
|
51
|
+
logging.getLogger("agentguard-sdk").info(json.dumps(log_data))
|
|
52
|
+
return span
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
|
-
from typing import Dict, Any
|
|
3
|
-
|
|
4
|
-
@dataclass
|
|
5
|
-
class AgentGuardReceipt:
|
|
6
|
-
"""
|
|
7
|
-
Structured response for a successful AgentGuard payment.
|
|
8
|
-
"""
|
|
9
|
-
tx_id: str
|
|
10
|
-
consent_hash: str
|
|
11
|
-
audit_url: str
|
|
12
|
-
data: Dict[str, Any] = field(default_factory=dict)
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class AuditProof:
|
|
16
|
-
"""
|
|
17
|
-
Structured response for a DPDP compliance audit.
|
|
18
|
-
"""
|
|
19
|
-
verified: bool
|
|
20
|
-
tx_id: str
|
|
21
|
-
principal_id: str
|
|
22
|
-
consent_hash: str
|
|
23
|
-
on_chain_hash: str
|
|
24
|
-
explorer_url: str
|
|
25
|
-
message: str
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class AgentGuardReceipt:
|
|
6
|
+
"""
|
|
7
|
+
Structured response for a successful AgentGuard payment.
|
|
8
|
+
"""
|
|
9
|
+
tx_id: str
|
|
10
|
+
consent_hash: str
|
|
11
|
+
audit_url: str
|
|
12
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AuditProof:
|
|
16
|
+
"""
|
|
17
|
+
Structured response for a DPDP compliance audit.
|
|
18
|
+
"""
|
|
19
|
+
verified: bool
|
|
20
|
+
tx_id: str
|
|
21
|
+
principal_id: str
|
|
22
|
+
consent_hash: str
|
|
23
|
+
on_chain_hash: str
|
|
24
|
+
explorer_url: str
|
|
25
|
+
message: str
|
{agentguard_python_sdk-0.1.0 → agentguard_python_sdk-0.1.2}/agentguard_python_sdk.egg-info/PKG-INFO
RENAMED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentguard-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A production-grade middleware for AI agents to perform on-chain payments and verifiable consent.
|
|
5
5
|
Author: AgentGuard Team
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.9
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
9
|
Requires-Dist: httpx>=0.27.0
|
|
10
10
|
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Requires-Dist: pynacl>=1.5.0
|
|
12
|
+
Requires-Dist: py-algorand-sdk>=2.0.0
|
|
11
13
|
|
|
12
14
|
# AgentGuard SDK
|
|
13
15
|
|
|
@@ -18,7 +20,7 @@ By isolating private keys in a hardened **MCP Server (Vault)** and using the **A
|
|
|
18
20
|
## Installation
|
|
19
21
|
|
|
20
22
|
```bash
|
|
21
|
-
pip install agentguard
|
|
23
|
+
pip install agentguard-python-sdk
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
## Quick Start (3-Line Usage)
|
|
@@ -34,7 +36,7 @@ async def main():
|
|
|
34
36
|
# 2. Perform a secure payment (includes automatic DPDP consent hashing)
|
|
35
37
|
receipt = await client.pay_and_fetch(
|
|
36
38
|
resource_url="https://api.stock.com/reliance",
|
|
37
|
-
amount_algo=0.05,
|
|
39
|
+
amount_algo="0.05",
|
|
38
40
|
purpose="Financial Analysis"
|
|
39
41
|
)
|
|
40
42
|
print(f"Payment Successful! TX ID: {receipt.tx_id}")
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
pyproject.toml
|
|
3
3
|
agentguard/__init__.py
|
|
4
|
+
agentguard/auth.py
|
|
4
5
|
agentguard/client.py
|
|
6
|
+
agentguard/config.py
|
|
5
7
|
agentguard/consent.py
|
|
8
|
+
agentguard/errors.py
|
|
9
|
+
agentguard/observability.py
|
|
6
10
|
agentguard/types.py
|
|
7
11
|
agentguard_python_sdk.egg-info/PKG-INFO
|
|
8
12
|
agentguard_python_sdk.egg-info/SOURCES.txt
|
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61.0"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "agentguard-python-sdk"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "A production-grade middleware for AI agents to perform on-chain payments and verifiable consent."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.9"
|
|
11
|
-
license =
|
|
12
|
-
authors = [
|
|
13
|
-
{name = "AgentGuard Team"}
|
|
14
|
-
]
|
|
15
|
-
dependencies = [
|
|
16
|
-
"httpx>=0.27.0",
|
|
17
|
-
"pydantic>=2.0.0"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentguard-python-sdk"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "A production-grade middleware for AI agents to perform on-chain payments and verifiable consent."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "AgentGuard Team"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"httpx>=0.27.0",
|
|
17
|
+
"pydantic>=2.0.0",
|
|
18
|
+
"pynacl>=1.5.0",
|
|
19
|
+
"py-algorand-sdk>=2.0.0"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["."]
|
|
24
|
+
include = ["agentguard*"]
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import httpx
|
|
2
|
-
import uuid
|
|
3
|
-
import logging
|
|
4
|
-
from typing import Optional, Dict, Any
|
|
5
|
-
from .consent import generate_consent_proof
|
|
6
|
-
from .types import AgentGuardReceipt, AuditProof
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger("agentguard-sdk")
|
|
9
|
-
|
|
10
|
-
class AgentGuardClient:
|
|
11
|
-
def __init__(self, wallet_address: str, base_url: str = "https://agentguard-backend.onrender.com"):
|
|
12
|
-
"""
|
|
13
|
-
Initialize the AgentGuard production client.
|
|
14
|
-
|
|
15
|
-
Args:
|
|
16
|
-
wallet_address: The user's wallet address (Principal ID).
|
|
17
|
-
base_url: The URL of the AgentGuard Backend.
|
|
18
|
-
"""
|
|
19
|
-
self.wallet_address = wallet_address
|
|
20
|
-
self.base_url = base_url.rstrip("/")
|
|
21
|
-
self.async_client = httpx.AsyncClient(timeout=30.0)
|
|
22
|
-
|
|
23
|
-
async def pay_and_fetch(
|
|
24
|
-
self,
|
|
25
|
-
resource_url: str,
|
|
26
|
-
amount_algo: float,
|
|
27
|
-
purpose: str
|
|
28
|
-
) -> AgentGuardReceipt:
|
|
29
|
-
"""
|
|
30
|
-
Asynchronously perform an on-chain payment and return a typed receipt.
|
|
31
|
-
|
|
32
|
-
Args:
|
|
33
|
-
resource_url: The URL/ID of the resource being accessed.
|
|
34
|
-
amount_algo: The amount to pay in ALGO.
|
|
35
|
-
purpose: The purpose of the data processing.
|
|
36
|
-
|
|
37
|
-
Returns:
|
|
38
|
-
AgentGuardReceipt: A structured receipt with transaction details.
|
|
39
|
-
"""
|
|
40
|
-
# 1. Generate Nonce for replay protection
|
|
41
|
-
nonce = str(uuid.uuid4())
|
|
42
|
-
|
|
43
|
-
# 2. Generate Consent Proof (SHA256 of metadata)
|
|
44
|
-
consent_hash, timestamp = generate_consent_proof(
|
|
45
|
-
principal_id=self.wallet_address,
|
|
46
|
-
resource_url=resource_url,
|
|
47
|
-
purpose=purpose,
|
|
48
|
-
nonce=nonce
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
# 3. Request Payment via Backend Proxy
|
|
52
|
-
payload = {
|
|
53
|
-
"resource_url": resource_url,
|
|
54
|
-
"amount_algo": amount_algo,
|
|
55
|
-
"purpose": purpose,
|
|
56
|
-
"principal_id": self.wallet_address,
|
|
57
|
-
"consent_hash": consent_hash,
|
|
58
|
-
"nonce": nonce,
|
|
59
|
-
"timestamp": timestamp
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
try:
|
|
63
|
-
response = await self.async_client.post(f"{self.base_url}/pay", json=payload)
|
|
64
|
-
response.raise_for_status()
|
|
65
|
-
res_data = response.json()
|
|
66
|
-
|
|
67
|
-
return AgentGuardReceipt(
|
|
68
|
-
tx_id=res_data["tx_id"],
|
|
69
|
-
consent_hash=res_data["consent_hash"],
|
|
70
|
-
audit_url=res_data["audit_url"],
|
|
71
|
-
data=res_data.get("data", {})
|
|
72
|
-
)
|
|
73
|
-
except httpx.HTTPStatusError as e:
|
|
74
|
-
logger.error(f"Payment failed: {e.response.text}")
|
|
75
|
-
raise Exception(f"Payment failed: {e.response.text}")
|
|
76
|
-
except Exception as e:
|
|
77
|
-
logger.error(f"Connection failed: {e}")
|
|
78
|
-
raise Exception(f"Could not connect to AgentGuard Backend: {e}")
|
|
79
|
-
|
|
80
|
-
async def verify(self, tx_id: str) -> AuditProof:
|
|
81
|
-
"""
|
|
82
|
-
Asynchronously verify a transaction against the blockchain audit trail.
|
|
83
|
-
"""
|
|
84
|
-
try:
|
|
85
|
-
response = await self.async_client.get(f"{self.base_url}/verify/{tx_id}")
|
|
86
|
-
response.raise_for_status()
|
|
87
|
-
res_data = response.json()
|
|
88
|
-
|
|
89
|
-
return AuditProof(
|
|
90
|
-
verified=res_data["verified"],
|
|
91
|
-
tx_id=res_data["tx_id"],
|
|
92
|
-
principal_id=res_data["principal_id"],
|
|
93
|
-
consent_hash=res_data["consent_hash"],
|
|
94
|
-
on_chain_hash=res_data["on_chain_hash"],
|
|
95
|
-
explorer_url=res_data["explorer_url"],
|
|
96
|
-
message=res_data["message"]
|
|
97
|
-
)
|
|
98
|
-
except Exception as e:
|
|
99
|
-
logger.error(f"Verification failed: {e}")
|
|
100
|
-
raise Exception(f"Verification failed: {e}")
|
|
101
|
-
|
|
102
|
-
async def register_budget(self, limit_algo: float) -> Dict[str, Any]:
|
|
103
|
-
"""
|
|
104
|
-
Convenience method to register/update the user's budget.
|
|
105
|
-
"""
|
|
106
|
-
payload = {
|
|
107
|
-
"wallet_address": self.wallet_address,
|
|
108
|
-
"budget_algo": limit_algo
|
|
109
|
-
}
|
|
110
|
-
try:
|
|
111
|
-
response = await self.async_client.post(f"{self.base_url}/user/register", json=payload)
|
|
112
|
-
response.raise_for_status()
|
|
113
|
-
return response.json()
|
|
114
|
-
except Exception as e:
|
|
115
|
-
logger.error(f"Budget registration failed: {e}")
|
|
116
|
-
raise Exception(f"Budget registration failed: {e}")
|
|
117
|
-
|
|
118
|
-
async def close(self):
|
|
119
|
-
"""Close the underlying HTTP client."""
|
|
120
|
-
await self.async_client.aclose()
|
|
121
|
-
|
|
122
|
-
async def __aenter__(self):
|
|
123
|
-
return self
|
|
124
|
-
|
|
125
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
126
|
-
await self.close()
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import time
|
|
2
|
-
import json
|
|
3
|
-
import hashlib
|
|
4
|
-
import logging
|
|
5
|
-
from typing import Dict, Any
|
|
6
|
-
|
|
7
|
-
# Configure logging
|
|
8
|
-
logger = logging.getLogger("agentguard-sdk")
|
|
9
|
-
|
|
10
|
-
def generate_consent(principal_id: str, purpose: str, item: str, nonce: str, data_fiduciary: str = "AgentGuard Platform") -> Dict[str, Any]:
|
|
11
|
-
"""
|
|
12
|
-
Creates a user consent object dictionary containing DPDP compliant fields.
|
|
13
|
-
"""
|
|
14
|
-
timestamp = int(time.time())
|
|
15
|
-
|
|
16
|
-
consent_obj = {
|
|
17
|
-
"principal_id": principal_id,
|
|
18
|
-
"data_fiduciary": data_fiduciary,
|
|
19
|
-
"purpose": purpose,
|
|
20
|
-
"item": item,
|
|
21
|
-
"timestamp": timestamp,
|
|
22
|
-
"nonce": nonce
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
logger.info(f"Generated DPDP consent for principal={principal_id} at {timestamp}")
|
|
26
|
-
return consent_obj
|
|
27
|
-
|
|
28
|
-
def hash_consent(consent: Dict[str, Any]) -> str:
|
|
29
|
-
"""
|
|
30
|
-
Converts a consent dictionary into a sorted JSON string
|
|
31
|
-
and generates a SHA256 hash of it for on-chain logging.
|
|
32
|
-
|
|
33
|
-
Args:
|
|
34
|
-
consent: The consent dictionary.
|
|
35
|
-
|
|
36
|
-
Returns:
|
|
37
|
-
str: SHA256 hash string.
|
|
38
|
-
"""
|
|
39
|
-
# Convert consent dict to JSON string with sorted keys to ensure determinism
|
|
40
|
-
consent_json = json.dumps(consent, sort_keys=True)
|
|
41
|
-
|
|
42
|
-
# Generate SHA256 hash
|
|
43
|
-
consent_hash = hashlib.sha256(consent_json.encode("utf-8")).hexdigest()
|
|
44
|
-
|
|
45
|
-
logger.info(f"Consent hash generated: {consent_hash}")
|
|
46
|
-
return consent_hash
|
|
47
|
-
|
|
48
|
-
def generate_consent_proof(principal_id: str, resource_url: str, purpose: str, nonce: str) -> tuple[str, int]:
|
|
49
|
-
"""
|
|
50
|
-
High-level helper to generate a consent hash and return the timestamp used.
|
|
51
|
-
"""
|
|
52
|
-
consent = generate_consent(
|
|
53
|
-
principal_id=principal_id,
|
|
54
|
-
purpose=purpose,
|
|
55
|
-
item=resource_url,
|
|
56
|
-
nonce=nonce
|
|
57
|
-
)
|
|
58
|
-
return hash_consent(consent), consent["timestamp"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|