volidator-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
volidator/client.py ADDED
@@ -0,0 +1,1129 @@
1
+ import asyncio
2
+ import base64
3
+ import contextlib
4
+ import contextvars
5
+ import json
6
+ import hashlib
7
+ import os
8
+ import re
9
+ import time
10
+ import urllib.request
11
+ import urllib.error
12
+ import urllib.parse
13
+ import threading
14
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
15
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
16
+
17
+ try:
18
+ import httpx
19
+ HAS_HTTPX = True
20
+ except ImportError:
21
+ httpx = None
22
+ HAS_HTTPX = False
23
+
24
+ # Global thread pool executor for urllib async fallbacks
25
+ import concurrent.futures
26
+ _executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
27
+
28
+ # Context variables for trace propagation and logical clocks
29
+ agent_context_store = contextvars.ContextVar("agent_context_store", default=None)
30
+ logical_clock_store = contextvars.ContextVar("logical_clock_store", default=None)
31
+
32
+
33
+ class VolidatorClient:
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ encryption_key: Optional[str] = None,
38
+ keyring: Optional[dict[str, str]] = None,
39
+ active_encryption_key_id: Optional[str] = None,
40
+ endpoint: str = "https://ingestion.volidator.com",
41
+ project_id: Optional[str] = None,
42
+ client_secret: Optional[str] = None,
43
+ telemetry: Optional[dict[str, Any]] = None,
44
+ redact_keys: Optional[list[str]] = None,
45
+ reference_keys: Optional[list[str]] = None,
46
+ max_metadata_size: int = 10240,
47
+ max_retries: int = 3,
48
+ on_delivery_failure: Optional[Callable[[dict[str, Any], Exception], None]] = None,
49
+ ):
50
+ self.api_key = api_key
51
+ self.endpoint = endpoint.rstrip("/")
52
+ self.project_id = project_id
53
+ self.client_secret = client_secret
54
+ self.redact_keys = redact_keys or []
55
+ self.reference_keys = reference_keys or []
56
+ self.max_metadata_size = max_metadata_size
57
+ self.max_retries = max_retries
58
+ self.on_delivery_failure = on_delivery_failure
59
+ self._fallback_logical_clock = 0
60
+
61
+ # Set up keyring
62
+ if keyring and active_encryption_key_id:
63
+ self.keyring = keyring
64
+ self.active_key_id = active_encryption_key_id
65
+ elif encryption_key:
66
+ self.active_key_id = "v1"
67
+ self.keyring = {"v1": encryption_key}
68
+ else:
69
+ raise ValueError(
70
+ "Either encryption_key OR (keyring AND active_encryption_key_id) must be provided in VolidatorClient constructor."
71
+ )
72
+
73
+ if len(self.keyring) > 5:
74
+ raise ValueError("Keyring size cannot exceed 5 keys.")
75
+
76
+ if self.active_key_id not in self.keyring:
77
+ raise ValueError(f"Active key ID '{self.active_key_id}' must exist in the keyring.")
78
+
79
+ self._hashed_keyring = {}
80
+
81
+ # Telemetry config resolution
82
+ telemetry = telemetry or {"preset": "standard"}
83
+ self.telemetry_config = self._resolve_telemetry_config(telemetry)
84
+
85
+ # Lazy HTTPX Client for async
86
+ self._httpx_client = None
87
+
88
+ # API Sub-namespaces
89
+ from .compliance import VolidatorCompliance
90
+ from .agent import VolidatorAgent
91
+ self.compliance = VolidatorCompliance(self)
92
+ self.agent = VolidatorAgent(self)
93
+
94
+ async def _get_httpx_client(self):
95
+ if self._httpx_client is None and HAS_HTTPX:
96
+ self._httpx_client = httpx.AsyncClient(timeout=10.0)
97
+ return self._httpx_client
98
+
99
+ async def close(self):
100
+ if self._httpx_client is not None:
101
+ await self._httpx_client.aclose()
102
+ self._httpx_client = None
103
+
104
+ def _resolve_telemetry_config(self, config: dict[str, Any]) -> dict[str, Any]:
105
+ preset = config.get("preset", "standard")
106
+ ip = "anonymize"
107
+ user_agent = "parse"
108
+ location = True
109
+
110
+ if preset == "strict":
111
+ ip = "skip"
112
+ user_agent = "skip"
113
+ location = False
114
+ elif preset == "full":
115
+ ip = "track"
116
+ user_agent = "track"
117
+ location = True
118
+
119
+ if "ip" in config:
120
+ ip = config["ip"]
121
+ if "userAgent" in config:
122
+ user_agent = config["userAgent"]
123
+ if "location" in config:
124
+ location = config["location"]
125
+
126
+ return {"ip": ip, "userAgent": user_agent, "location": location}
127
+
128
+ @contextlib.contextmanager
129
+ def run_in_agent_context(self, context: dict[str, Any]):
130
+ token = agent_context_store.set(context)
131
+ try:
132
+ yield
133
+ finally:
134
+ agent_context_store.reset(token)
135
+
136
+ @contextlib.contextmanager
137
+ def trace_from_langchain(self, config: Optional[dict[str, Any]]):
138
+ run_id = str(config.get("run_id", "")) if config else ""
139
+ configurable = config.get("configurable") or {} if config else {}
140
+ thread_id = str(configurable.get("thread_id", ""))
141
+
142
+ trace_id = run_id or thread_id or None
143
+ ctx = {}
144
+ if trace_id:
145
+ ctx["traceId"] = trace_id
146
+
147
+ token = agent_context_store.set(ctx)
148
+ try:
149
+ yield
150
+ finally:
151
+ agent_context_store.reset(token)
152
+
153
+ @contextlib.contextmanager
154
+ def run_in_clock_context(self, initial_clock: int = 0):
155
+ token = logical_clock_store.set({"clock": initial_clock})
156
+ try:
157
+ yield
158
+ finally:
159
+ logical_clock_store.reset(token)
160
+
161
+ def get_and_increment_clock(self, incoming_clock: Optional[int] = None) -> int:
162
+ store = logical_clock_store.get()
163
+ if store is not None:
164
+ store["clock"] = max(store.get("clock", 0), incoming_clock or 0) + 1
165
+ return store["clock"]
166
+
167
+ self._fallback_logical_clock = max(self._fallback_logical_clock, incoming_clock or 0) + 1
168
+ return self._fallback_logical_clock
169
+
170
+ def _get_hashed_key(self, key_id: str) -> bytes:
171
+ if key_id in self._hashed_keyring:
172
+ return self._hashed_keyring[key_id]
173
+ raw_key = self.keyring[key_id]
174
+ hashed = hashlib.sha256(raw_key.encode("utf-8")).digest()
175
+ self._hashed_keyring[key_id] = hashed
176
+ return hashed
177
+
178
+ def generate_blind_index(self, value: str, key_buffer: bytes) -> str:
179
+ import hmac
180
+ sig = hmac.digest(key_buffer, value.encode("utf-8"), "sha256")
181
+ return sig.hex()
182
+
183
+ def encrypt_payload(self, payload: dict[str, Any]) -> str:
184
+ text = json.dumps(payload, separators=(",", ":"))
185
+ iv = os.urandom(12)
186
+ active_hashed_key = self._get_hashed_key(self.active_key_id)
187
+ aesgcm = AESGCM(active_hashed_key)
188
+ encrypted_data = aesgcm.encrypt(iv, text.encode("utf-8"), None)
189
+ final_buf = iv + encrypted_data
190
+ base64_str = base64.b64encode(final_buf).decode("utf-8")
191
+ return f"{self.active_key_id}:{base64_str}"
192
+
193
+ def decrypt_payload(self, encrypted_str: str) -> dict[str, Any]:
194
+ if ":" not in encrypted_str:
195
+ raise ValueError("Invalid encrypted payload format")
196
+ key_id, base64_data = encrypted_str.split(":", 1)
197
+ if key_id not in self.keyring:
198
+ raise ValueError(f"Decryption key '{key_id}' not found in keyring")
199
+ hashed_key = self._get_hashed_key(key_id)
200
+ final_buf = base64.b64decode(base64_data)
201
+ if len(final_buf) < 28:
202
+ raise ValueError("Encrypted data is too short")
203
+ iv = final_buf[:12]
204
+ encrypted_data = final_buf[12:]
205
+ aesgcm = AESGCM(hashed_key)
206
+ decrypted_bytes = aesgcm.decrypt(iv, encrypted_data, None)
207
+ return json.loads(decrypted_bytes.decode("utf-8"))
208
+
209
+ def _parse_user_agent(self, ua: str) -> dict[str, str]:
210
+ browser = "Unknown Browser"
211
+ os_name = "Unknown OS"
212
+ device_type = "Desktop"
213
+
214
+ if not ua:
215
+ return {"browser": browser, "os": os_name, "type": device_type}
216
+
217
+ if re.search(r"Volidator-Ingest-Worker", ua, re.IGNORECASE):
218
+ return {
219
+ "browser": "Volidator Ingest Worker",
220
+ "os": "Linux Server",
221
+ "type": "Server",
222
+ }
223
+
224
+ if re.search(r"mobile", ua, re.IGNORECASE):
225
+ device_type = "Mobile"
226
+ elif re.search(r"tablet|ipad", ua, re.IGNORECASE):
227
+ device_type = "Tablet"
228
+ elif re.search(r"server|bot", ua, re.IGNORECASE):
229
+ device_type = "Server"
230
+
231
+ if re.search(r"chrome|crios", ua, re.IGNORECASE) and not re.search(r"edge|edg", ua, re.IGNORECASE) and not re.search(r"opr", ua, re.IGNORECASE):
232
+ match = re.search(r"(?:chrome|crios)\/([0-9.]+)", ua, re.IGNORECASE)
233
+ browser_ver = match.group(1).split(".")[0] if match else ""
234
+ browser = f"Chrome {browser_ver}".strip()
235
+ elif re.search(r"safari", ua, re.IGNORECASE) and not re.search(r"chrome|crios", ua, re.IGNORECASE) and not re.search(r"android", ua, re.IGNORECASE):
236
+ browser = "Safari Mobile" if re.search(r"mobile", ua, re.IGNORECASE) else "Safari"
237
+ elif re.search(r"firefox|fxios", ua, re.IGNORECASE):
238
+ browser = "Firefox"
239
+ elif re.search(r"edge|edg", ua, re.IGNORECASE):
240
+ browser = "Edge"
241
+
242
+ if re.search(r"windows", ua, re.IGNORECASE):
243
+ os_name = "Windows"
244
+ elif re.search(r"macintosh|mac os x", ua, re.IGNORECASE):
245
+ os_name = "macOS"
246
+ elif re.search(r"iphone|ipad|ipod", ua, re.IGNORECASE):
247
+ os_name = "iOS"
248
+ elif re.search(r"android", ua, re.IGNORECASE):
249
+ os_name = "Android"
250
+ elif re.search(r"linux", ua, re.IGNORECASE):
251
+ os_name = "Linux"
252
+
253
+ return {"browser": browser, "os": os_name, "type": device_type}
254
+
255
+ def _extract_context(self, req: Any) -> dict[str, Any]:
256
+ if not req:
257
+ return {}
258
+
259
+ headers = {}
260
+ if hasattr(req, "headers"):
261
+ # Check if headers has a get method (FastAPI/Flask-like)
262
+ headers = req.headers
263
+ elif hasattr(req, "META") and isinstance(req.META, dict):
264
+ # Django META
265
+ headers = {
266
+ k.replace("HTTP_", "").replace("_", "-").lower(): v
267
+ for k, v in req.META.items()
268
+ }
269
+ if "CONTENT_TYPE" in req.META:
270
+ headers["content-type"] = req.META["CONTENT_TYPE"]
271
+ if "CONTENT_LENGTH" in req.META:
272
+ headers["content-length"] = req.META["CONTENT_LENGTH"]
273
+ if "REMOTE_ADDR" in req.META:
274
+ headers["remote-addr"] = req.META["REMOTE_ADDR"]
275
+ elif isinstance(req, dict):
276
+ headers = req
277
+
278
+ def get_header(name: str) -> str:
279
+ if hasattr(headers, "get"):
280
+ return headers.get(name) or headers.get(name.lower()) or ""
281
+ try:
282
+ return headers[name] or headers[name.lower()] or ""
283
+ except (KeyError, TypeError):
284
+ return ""
285
+
286
+ cf_ip = get_header("cf-connecting-ip")
287
+ x_real_ip = get_header("x-real-ip")
288
+ x_forwarded = get_header("x-forwarded-for")
289
+
290
+ raw_ip = cf_ip or x_real_ip or x_forwarded
291
+ ip = ""
292
+ if raw_ip:
293
+ ip = raw_ip.split(",")[0].strip()
294
+ else:
295
+ if hasattr(req, "remote_addr"):
296
+ ip = req.remote_addr or ""
297
+ elif "remote-addr" in headers:
298
+ ip = headers["remote-addr"]
299
+
300
+ ua = get_header("user-agent")
301
+
302
+ return {
303
+ "ip": ip,
304
+ "userAgent": ua,
305
+ "location": {
306
+ "country": get_header("cf-ipcountry") or get_header("x-vercel-ip-country") or "",
307
+ "region": get_header("cf-region-code") or get_header("x-vercel-ip-country-region") or "",
308
+ "city": get_header("cf-ipcity") or get_header("x-vercel-ip-city") or "",
309
+ }
310
+ }
311
+
312
+ def _extract_trace_context(self, req: Any) -> dict[str, Any]:
313
+ if not req:
314
+ return {}
315
+
316
+ headers = {}
317
+ if hasattr(req, "headers"):
318
+ headers = req.headers
319
+ elif hasattr(req, "META") and isinstance(req.META, dict):
320
+ headers = {
321
+ k.replace("HTTP_", "").replace("_", "-").lower(): v
322
+ for k, v in req.META.items()
323
+ }
324
+ elif isinstance(req, dict):
325
+ headers = req
326
+
327
+ def get_header(name: str) -> str:
328
+ if hasattr(headers, "get"):
329
+ return headers.get(name) or headers.get(name.lower()) or ""
330
+ try:
331
+ return headers[name] or headers[name.lower()] or ""
332
+ except (KeyError, TypeError):
333
+ return ""
334
+
335
+ result = {}
336
+ clock_val = get_header("x-volidator-clock")
337
+ if clock_val:
338
+ try:
339
+ result["logicalClock"] = int(clock_val)
340
+ except ValueError:
341
+ pass
342
+
343
+ traceparent = get_header("traceparent")
344
+ if traceparent:
345
+ parts = traceparent.split("-")
346
+ if len(parts) == 4:
347
+ result["traceId"] = parts[1]
348
+ result["spanId"] = parts[2]
349
+
350
+ return result
351
+
352
+ def _prepare_log_entry_common(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> tuple[dict[str, Any], dict[str, Any], str, str, str, str, Optional[str]]:
353
+ metadata = payload.get("metadata") or {}
354
+ payload_size = len(json.dumps(metadata, separators=(",", ":")).encode("utf-8"))
355
+
356
+ if payload_size > 5 * 1024 * 1024:
357
+ raise ValueError(
358
+ f"Volidator SDK Error: Audit log payload exceeds the 5MB hard limit ({payload_size} bytes)."
359
+ )
360
+
361
+ actor_raw = payload.get("actor") or payload.get("actorId") or "unknown"
362
+ target_raw = payload.get("target") or payload.get("targetId") or "unknown"
363
+ tenant_raw = payload.get("tenant") or payload.get("tenantId") or ""
364
+ action = payload.get("action", "unknown")
365
+
366
+ def truncate(s: str, max_len: int) -> str:
367
+ return s[:max_len] + "..." if len(s) > max_len else s
368
+
369
+ def extract_pii(v: Any) -> str:
370
+ if isinstance(v, dict) and "pii" in v:
371
+ return v["pii"]
372
+ if hasattr(v, "pii"):
373
+ return getattr(v, "pii")
374
+ return str(v)
375
+
376
+ def extract_id(v: Any) -> str:
377
+ if isinstance(v, dict) and "id" in v:
378
+ return v["id"]
379
+ if hasattr(v, "id"):
380
+ return getattr(v, "id")
381
+ return str(v)
382
+
383
+ actor_plain = truncate(extract_pii(actor_raw), 255)
384
+ target_plain = truncate(extract_pii(target_raw), 255)
385
+ tenant_plain = truncate(extract_pii(tenant_raw), 255) if tenant_raw else ""
386
+ action_plain = truncate(action, 255)
387
+
388
+ log_telemetry = self.telemetry_config
389
+ if "telemetry" in payload:
390
+ log_telemetry = self._resolve_telemetry_config({**self.telemetry_config, **payload["telemetry"]})
391
+
392
+ payload_ctx = payload.get("context") or {}
393
+ if payload.get("req"):
394
+ extracted = self._extract_context(payload["req"])
395
+ payload_ctx = {
396
+ **extracted,
397
+ **payload_ctx,
398
+ "location": {
399
+ **extracted.get("location", {}),
400
+ **payload_ctx.get("location", {}),
401
+ },
402
+ "device": {
403
+ **extracted.get("device", {}),
404
+ **payload_ctx.get("device", {}),
405
+ }
406
+ }
407
+
408
+ context = {}
409
+ raw_ip = payload_ctx.get("ip") or ""
410
+ raw_ua = payload_ctx.get("userAgent") or ""
411
+
412
+ if log_telemetry.get("location"):
413
+ context["location"] = {}
414
+ if payload_ctx.get("location"):
415
+ context["location"]["country"] = payload_ctx["location"].get("country") or ""
416
+ context["location"]["region"] = payload_ctx["location"].get("region") or ""
417
+ is_full = (
418
+ payload.get("telemetry", {}).get("preset") == "full" or
419
+ (not payload.get("telemetry") and self.telemetry_config.get("ip") == "track")
420
+ )
421
+ if log_telemetry.get("ip") == "track" or is_full:
422
+ context["location"]["city"] = payload_ctx["location"].get("city") or ""
423
+
424
+ active_hashed_key = self._get_hashed_key(self.active_key_id)
425
+ if log_telemetry.get("ip") == "anonymize" and raw_ip:
426
+ context["ip"] = self.generate_blind_index(raw_ip, active_hashed_key)
427
+ elif log_telemetry.get("ip") == "track" and raw_ip:
428
+ context["ip"] = raw_ip
429
+
430
+ if log_telemetry.get("userAgent") != "skip":
431
+ if raw_ua:
432
+ context["device"] = self._parse_user_agent(raw_ua)
433
+ if log_telemetry.get("userAgent") == "track":
434
+ context["userAgent"] = truncate(raw_ua, 1000)
435
+ elif payload_ctx.get("device"):
436
+ context["device"] = payload_ctx["device"]
437
+
438
+ def scrub(val: str, key: str) -> str:
439
+ return f"[REDACTED:{key}]" if key in self.redact_keys else val
440
+
441
+ def apply_ref(raw_val: Any, key: str) -> str:
442
+ if key in self.reference_keys:
443
+ ref_id = extract_id(raw_val)
444
+ return f"[REF:{ref_id}]"
445
+ return scrub(extract_pii(raw_val), key)
446
+
447
+ safe_metadata = {}
448
+ for k, v in metadata.items():
449
+ meta_key = f"metadata.{k}"
450
+ if meta_key in self.reference_keys and isinstance(v, dict) and "id" in v:
451
+ safe_metadata[k] = f"[REF:{v['id']}]"
452
+ elif meta_key in self.redact_keys and isinstance(v, str):
453
+ safe_metadata[k] = f"[REDACTED:{k}]"
454
+ else:
455
+ safe_metadata[k] = v
456
+
457
+ did_truncate_depth = False
458
+ did_truncate_string = False
459
+
460
+ def limit_depth(obj, current_depth=1):
461
+ nonlocal did_truncate_depth, did_truncate_string
462
+ if current_depth > 5:
463
+ did_truncate_depth = True
464
+ return "[Truncated - Depth Exceeded]"
465
+ if obj is None:
466
+ return None
467
+ if isinstance(obj, str):
468
+ truncated_val = truncate(obj, 1000)
469
+ if len(truncated_val) != len(obj):
470
+ did_truncate_string = True
471
+ return truncated_val
472
+ if isinstance(obj, (int, float, bool)):
473
+ return obj
474
+ if isinstance(obj, list):
475
+ return [limit_depth(item, current_depth + 1) for item in obj]
476
+ if isinstance(obj, dict):
477
+ return {k: limit_depth(val, current_depth + 1) for k, val in obj.items()}
478
+ return limit_depth(str(obj), current_depth)
479
+
480
+ processed_metadata = limit_depth(safe_metadata)
481
+
482
+ serialized_meta = json.dumps(processed_metadata, separators=(",", ":"))
483
+ max_meta_limit = max_meta_override or self.max_metadata_size
484
+ if len(serialized_meta) > max_meta_limit:
485
+ raise ValueError(f"Metadata size exceeds maximum allowed limit of {max_meta_limit / 1024}KB.")
486
+
487
+ safe_actor = apply_ref(actor_raw, "actor")
488
+ safe_target = apply_ref(target_raw, "target")
489
+ safe_tenant = apply_ref(tenant_raw, "tenant") if tenant_raw else None
490
+
491
+ enriched_payload = {
492
+ "actor": safe_actor,
493
+ "action": action_plain,
494
+ "target": safe_target,
495
+ "metadata": processed_metadata,
496
+ }
497
+ if safe_tenant:
498
+ enriched_payload["tenant"] = safe_tenant
499
+ if context:
500
+ enriched_payload["context"] = context
501
+
502
+ # Extract trace variables
503
+ trace_id = payload.get("traceId")
504
+ span_id = payload.get("spanId")
505
+ parent_span_id = payload.get("parentSpanId")
506
+ logical_clock = payload.get("logicalClock")
507
+
508
+ agent_ctx = agent_context_store.get()
509
+ if agent_ctx:
510
+ if not trace_id and "traceId" in agent_ctx:
511
+ trace_id = agent_ctx["traceId"]
512
+ if not span_id and "spanId" in agent_ctx:
513
+ span_id = agent_ctx["spanId"]
514
+
515
+ if payload.get("req"):
516
+ extracted_trace = self._extract_trace_context(payload["req"])
517
+ if not trace_id and "traceId" in extracted_trace:
518
+ trace_id = extracted_trace["traceId"]
519
+ if not span_id and "spanId" in extracted_trace:
520
+ span_id = extracted_trace["spanId"]
521
+ if logical_clock is None and "logicalClock" in extracted_trace:
522
+ logical_clock = extracted_trace["logicalClock"]
523
+
524
+ resolved_clock = self.get_and_increment_clock(logical_clock)
525
+
526
+ if span_id:
527
+ enriched_payload["spanId"] = span_id
528
+ if parent_span_id:
529
+ enriched_payload["parentSpanId"] = parent_span_id
530
+
531
+ # Return parameters for final cryptographic serialization
532
+ return enriched_payload, active_hashed_key, actor_plain, action_plain, target_plain, tenant_plain, trace_id
533
+
534
+ def prepare_log_entry(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> dict[str, Any]:
535
+ enriched_payload, active_hashed_key, actor, action, target, tenant, trace_id = self._prepare_log_entry_common(
536
+ payload, max_meta_override
537
+ )
538
+
539
+ actor_blind = self.generate_blind_index(actor, active_hashed_key)
540
+ action_blind = self.generate_blind_index(action, active_hashed_key)
541
+ target_blind = self.generate_blind_index(target, active_hashed_key)
542
+ tenant_blind = self.generate_blind_index(tenant, active_hashed_key) if tenant else None
543
+ trace_blind = self.generate_blind_index(trace_id, active_hashed_key) if trace_id else None
544
+
545
+ encrypted_payload = self.encrypt_payload(enriched_payload)
546
+ is_claim_check = False
547
+
548
+ if len(encrypted_payload) > 30720:
549
+ hash_hex = hashlib.sha256(encrypted_payload.encode("utf-8")).hexdigest()
550
+ # Upload encrypted payload to edge worker storage endpoint synchronously
551
+ try:
552
+ headers = {
553
+ "Authorization": f"Bearer {self.api_key}",
554
+ "Content-Type": "application/octet-stream",
555
+ }
556
+ self._fetch_with_retry(
557
+ f"{self.endpoint}/v1/log/upload/{hash_hex}",
558
+ "PUT",
559
+ headers,
560
+ encrypted_payload.encode("utf-8"),
561
+ )
562
+ encrypted_payload = hash_hex
563
+ is_claim_check = True
564
+ except Exception as err:
565
+ # Fall back to original payload
566
+ pass
567
+
568
+ rationale = payload.get("rationale") or (agent_context_store.get() or {}).get("rationale")
569
+ tool_name = payload.get("toolName") or (agent_context_store.get() or {}).get("toolName")
570
+
571
+ agent_context = None
572
+ if rationale or tool_name:
573
+ truncated_rationale = rationale[:1000] if rationale else None
574
+ agent_context = self.encrypt_payload({
575
+ "rationale": truncated_rationale,
576
+ "toolName": tool_name,
577
+ })
578
+
579
+ attestation_proof = json.dumps(payload["attestation"], separators=(",", ":")) if payload.get("attestation") else None
580
+
581
+ return {
582
+ "actorBlindIndex": actor_blind,
583
+ "actionBlindIndex": action_blind,
584
+ "targetBlindIndex": target_blind,
585
+ "tenantBlindIndex": tenant_blind,
586
+ "traceBlindIndex": trace_blind,
587
+ "encryptedPayload": encrypted_payload,
588
+ "logicalClock": enriched_payload.get("logicalClock", self._fallback_logical_clock),
589
+ "isClaimCheck": is_claim_check,
590
+ "agentContext": agent_context,
591
+ "attestationProof": attestation_proof,
592
+ }
593
+
594
+ async def prepare_log_entry_async(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> dict[str, Any]:
595
+ # Perform synchronous core preparation
596
+ enriched_payload, active_hashed_key, actor, action, target, tenant, trace_id = self._prepare_log_entry_common(
597
+ payload, max_meta_override
598
+ )
599
+
600
+ actor_blind = self.generate_blind_index(actor, active_hashed_key)
601
+ action_blind = self.generate_blind_index(action, active_hashed_key)
602
+ target_blind = self.generate_blind_index(target, active_hashed_key)
603
+ tenant_blind = self.generate_blind_index(tenant, active_hashed_key) if tenant else None
604
+ trace_blind = self.generate_blind_index(trace_id, active_hashed_key) if trace_id else None
605
+
606
+ encrypted_payload = self.encrypt_payload(enriched_payload)
607
+ is_claim_check = False
608
+
609
+ if len(encrypted_payload) > 30720:
610
+ hash_hex = hashlib.sha256(encrypted_payload.encode("utf-8")).hexdigest()
611
+ # Upload encrypted payload asynchronously
612
+ try:
613
+ headers = {
614
+ "Authorization": f"Bearer {self.api_key}",
615
+ "Content-Type": "application/octet-stream",
616
+ }
617
+ await self._fetch_with_retry_async(
618
+ f"{self.endpoint}/v1/log/upload/{hash_hex}",
619
+ "PUT",
620
+ headers,
621
+ encrypted_payload.encode("utf-8"),
622
+ )
623
+ encrypted_payload = hash_hex
624
+ is_claim_check = True
625
+ except Exception as err:
626
+ pass
627
+
628
+ rationale = payload.get("rationale") or (agent_context_store.get() or {}).get("rationale")
629
+ tool_name = payload.get("toolName") or (agent_context_store.get() or {}).get("toolName")
630
+
631
+ agent_context = None
632
+ if rationale or tool_name:
633
+ truncated_rationale = rationale[:1000] if rationale else None
634
+ agent_context = self.encrypt_payload({
635
+ "rationale": truncated_rationale,
636
+ "toolName": tool_name,
637
+ })
638
+
639
+ attestation_proof = json.dumps(payload["attestation"], separators=(",", ":")) if payload.get("attestation") else None
640
+
641
+ return {
642
+ "actorBlindIndex": actor_blind,
643
+ "actionBlindIndex": action_blind,
644
+ "targetBlindIndex": target_blind,
645
+ "tenantBlindIndex": tenant_blind,
646
+ "traceBlindIndex": trace_blind,
647
+ "encryptedPayload": encrypted_payload,
648
+ "logicalClock": enriched_payload.get("logicalClock", self._fallback_logical_clock),
649
+ "isClaimCheck": is_claim_check,
650
+ "agentContext": agent_context,
651
+ "attestationProof": attestation_proof,
652
+ }
653
+
654
+ def _fetch_with_retry(self, url: str, method: str, headers: dict[str, str], body: bytes, max_retries: Optional[int] = None) -> bytes:
655
+ if max_retries is None:
656
+ max_retries = self.max_retries
657
+
658
+ attempt = 0
659
+ delay = 0.5
660
+ last_error = Exception("Unknown error")
661
+
662
+ while attempt <= max_retries:
663
+ req = urllib.request.Request(
664
+ url,
665
+ data=body if method in ("POST", "PUT") else None,
666
+ headers=headers,
667
+ method=method,
668
+ )
669
+ try:
670
+ with urllib.request.urlopen(req, timeout=10.0) as response:
671
+ return response.read()
672
+ except urllib.error.HTTPError as e:
673
+ last_error = e
674
+ if 400 <= e.code < 500:
675
+ raise e
676
+ except Exception as e:
677
+ last_error = e
678
+
679
+ attempt += 1
680
+ if attempt <= max_retries:
681
+ time.sleep(delay)
682
+ delay *= 3
683
+
684
+ raise last_error
685
+
686
+ async def _fetch_with_retry_async(self, url: str, method: str, headers: dict[str, str], body: bytes, max_retries: Optional[int] = None) -> bytes:
687
+ if HAS_HTTPX:
688
+ if max_retries is None:
689
+ max_retries = self.max_retries
690
+
691
+ attempt = 0
692
+ delay = 0.5
693
+ last_error = Exception("Unknown error")
694
+ client = await self._get_httpx_client()
695
+
696
+ while attempt <= max_retries:
697
+ try:
698
+ response = await client.request(
699
+ method,
700
+ url,
701
+ headers=headers,
702
+ content=body,
703
+ timeout=10.0,
704
+ )
705
+ if response.is_success:
706
+ return response.content
707
+ response.raise_for_status()
708
+ except httpx.HTTPStatusError as e:
709
+ last_error = e
710
+ if 400 <= e.response.status_code < 500:
711
+ raise e
712
+ except Exception as e:
713
+ last_error = e
714
+
715
+ attempt += 1
716
+ if attempt <= max_retries:
717
+ await asyncio.sleep(delay)
718
+ delay *= 3
719
+
720
+ raise last_error
721
+ else:
722
+ # Fall back to running urllib inside the thread pool executor
723
+ loop = asyncio.get_running_loop()
724
+ return await loop.run_in_executor(
725
+ _executor,
726
+ lambda: self._fetch_with_retry(url, method, headers, body, max_retries)
727
+ )
728
+
729
+ def log(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> bool:
730
+ try:
731
+ entry = self.prepare_log_entry(payload, max_meta_override)
732
+ headers = {
733
+ "Authorization": f"Bearer {self.api_key}",
734
+ "Content-Type": "application/json",
735
+ }
736
+ body = json.dumps(entry, separators=(",", ":")).encode("utf-8")
737
+ self._fetch_with_retry(f"{self.endpoint}/v1/log", "POST", headers, body)
738
+ return True
739
+ except Exception as e:
740
+ import sys
741
+ print(f"[Volidator] Failed to send log: {str(e)}", file=sys.stderr)
742
+ if self.on_delivery_failure:
743
+ try:
744
+ self.on_delivery_failure(payload, e)
745
+ except Exception as cb_err:
746
+ print(f"[Volidator] Error in on_delivery_failure callback: {str(cb_err)}", file=sys.stderr)
747
+ return False
748
+
749
+ async def log_async(self, payload: dict[str, Any], max_meta_override: Optional[int] = None) -> bool:
750
+ try:
751
+ entry = await self.prepare_log_entry_async(payload, max_meta_override)
752
+ headers = {
753
+ "Authorization": f"Bearer {self.api_key}",
754
+ "Content-Type": "application/json",
755
+ }
756
+ body = json.dumps(entry, separators=(",", ":")).encode("utf-8")
757
+ await self._fetch_with_retry_async(f"{self.endpoint}/v1/log", "POST", headers, body)
758
+ return True
759
+ except Exception as e:
760
+ import sys
761
+ print(f"[Volidator] Failed to send log async: {str(e)}", file=sys.stderr)
762
+ if self.on_delivery_failure:
763
+ try:
764
+ self.on_delivery_failure(payload, e)
765
+ except Exception as cb_err:
766
+ print(f"[Volidator] Error in on_delivery_failure callback: {str(cb_err)}", file=sys.stderr)
767
+ return False
768
+
769
+ def log_batch(self, payloads: list[dict[str, Any]], max_meta_override: Optional[int] = None) -> dict[str, int]:
770
+ if not payloads:
771
+ return {"accepted": 0, "rejected": 0}
772
+
773
+ batch = payloads[:100]
774
+ prepared_entries = []
775
+ rejected = len(payloads) - len(batch)
776
+
777
+ for p in batch:
778
+ try:
779
+ prepared_entries.append(self.prepare_log_entry(p, max_meta_override))
780
+ except Exception as e:
781
+ import sys
782
+ print(f"[Volidator] Failed to prepare batch entry: {str(e)}", file=sys.stderr)
783
+ rejected += 1
784
+
785
+ if not prepared_entries:
786
+ return {"accepted": 0, "rejected": rejected}
787
+
788
+ try:
789
+ headers = {
790
+ "Authorization": f"Bearer {self.api_key}",
791
+ "Content-Type": "application/json",
792
+ }
793
+ body = json.dumps({"logs": prepared_entries}, separators=(",", ":")).encode("utf-8")
794
+ self._fetch_with_retry(f"{self.endpoint}/v1/logs/batch", "POST", headers, body)
795
+ return {"accepted": len(prepared_entries), "rejected": rejected}
796
+ except Exception as e:
797
+ import sys
798
+ print(f"[Volidator] Failed to send log batch: {str(e)}", file=sys.stderr)
799
+ if self.on_delivery_failure:
800
+ for payload in batch:
801
+ try:
802
+ self.on_delivery_failure(payload, e)
803
+ except Exception as cb_err:
804
+ pass
805
+ return {"accepted": 0, "rejected": rejected + len(prepared_entries)}
806
+
807
+ async def log_batch_async(self, payloads: list[dict[str, Any]], max_meta_override: Optional[int] = None) -> dict[str, int]:
808
+ if not payloads:
809
+ return {"accepted": 0, "rejected": 0}
810
+
811
+ batch = payloads[:100]
812
+ prepared_entries = []
813
+ rejected = len(payloads) - len(batch)
814
+
815
+ # Crytographic parallelization is handled implicitly in Python async via gather
816
+ tasks = [self.prepare_log_entry_async(p, max_meta_override) for p in batch]
817
+ results = await asyncio.gather(*tasks, return_exceptions=True)
818
+
819
+ for i, res in enumerate(results):
820
+ if isinstance(res, Exception):
821
+ import sys
822
+ print(f"[Volidator] Failed to prepare batch entry async: {str(res)}", file=sys.stderr)
823
+ rejected += 1
824
+ else:
825
+ prepared_entries.append(res)
826
+
827
+ if not prepared_entries:
828
+ return {"accepted": 0, "rejected": rejected}
829
+
830
+ try:
831
+ headers = {
832
+ "Authorization": f"Bearer {self.api_key}",
833
+ "Content-Type": "application/json",
834
+ }
835
+ body = json.dumps({"logs": prepared_entries}, separators=(",", ":")).encode("utf-8")
836
+ await self._fetch_with_retry_async(f"{self.endpoint}/v1/logs/batch", "POST", headers, body)
837
+ return {"accepted": len(prepared_entries), "rejected": rejected}
838
+ except Exception as e:
839
+ import sys
840
+ print(f"[Volidator] Failed to send log batch async: {str(e)}", file=sys.stderr)
841
+ if self.on_delivery_failure:
842
+ for payload in batch:
843
+ try:
844
+ self.on_delivery_failure(payload, e)
845
+ except Exception:
846
+ pass
847
+ return {"accepted": 0, "rejected": rejected + len(prepared_entries)}
848
+
849
+ def batcher(self, options: Optional[dict[str, Any]] = None) -> "VolidatorBatcher":
850
+ options = options or {}
851
+ return VolidatorBatcher(
852
+ self,
853
+ auto_flush_count=options.get("autoFlushCount", 100),
854
+ auto_flush_interval=options.get("autoFlushInterval"),
855
+ )
856
+
857
+ def generate_embed_token(
858
+ self,
859
+ config: dict[str, Any]
860
+ ) -> dict[str, str]:
861
+ project_id = config.get("projectId") or self.project_id
862
+ client_secret = config.get("clientSecret") or self.client_secret
863
+
864
+ if not project_id or not client_secret:
865
+ raise ValueError(
866
+ "generate_embed_token() requires projectId and clientSecret in either config or VolidatorClient constructor."
867
+ )
868
+
869
+ actor_id = config.get("actorId")
870
+ target_id = config.get("targetId")
871
+ tenant_id = config.get("tenantId")
872
+ scope = config.get("scope")
873
+ expires_in = config.get("expiresIn", "2h")
874
+ dashboard_url = config.get("dashboardUrl", "https://dash.volidator.com").rstrip("/")
875
+ host_origin = config.get("hostOrigin")
876
+ view = config.get("view")
877
+
878
+ default_scope = scope or ("tenant" if tenant_id else "actor")
879
+
880
+ if default_scope != "auditor" and not actor_id and not target_id and not tenant_id:
881
+ raise ValueError(
882
+ "At least one of actorId, targetId, or tenantId must be provided to generate_embed_token()."
883
+ )
884
+
885
+ actor_blinds = [
886
+ self.generate_blind_index(actor_id, self._get_hashed_key(kid))
887
+ for kid in self.keyring
888
+ ] if actor_id else None
889
+
890
+ target_blinds = [
891
+ self.generate_blind_index(target_id, self._get_hashed_key(kid))
892
+ for kid in self.keyring
893
+ ] if target_id else None
894
+
895
+ tenant_blinds = [
896
+ self.generate_blind_index(tenant_id, self._get_hashed_key(kid))
897
+ for kid in self.keyring
898
+ ] if tenant_id else None
899
+
900
+ def parse_expiry(exp_str: str) -> int:
901
+ match = re.match(r"^(\d+)(s|m|h|d)$", exp_str)
902
+ if not match:
903
+ return 7200
904
+ num, unit = match.groups()
905
+ n = int(num)
906
+ if unit == "s":
907
+ return n
908
+ if unit == "m":
909
+ return n * 60
910
+ if unit == "h":
911
+ return n * 3600
912
+ if unit == "d":
913
+ return n * 86400
914
+ return 7200
915
+
916
+ parsed_exp = parse_expiry(expires_in)
917
+ max_exp = 604800 if default_scope == "auditor" else 3600
918
+ expires_seconds = min(parsed_exp, max_exp)
919
+ now = int(time.time())
920
+
921
+ payload = {
922
+ "pid": project_id,
923
+ "scope": default_scope,
924
+ "iat": now,
925
+ "exp": now + expires_seconds,
926
+ }
927
+
928
+ if actor_blinds:
929
+ payload["abi"] = actor_blinds
930
+ if target_blinds:
931
+ payload["tgb"] = target_blinds
932
+ if tenant_blinds:
933
+ payload["tbi"] = tenant_blinds
934
+
935
+ if view:
936
+ compressed = {}
937
+ if isinstance(view.get("columns"), list):
938
+ cols = []
939
+ for col in view["columns"]:
940
+ if col == "createdAt":
941
+ cols.append("cat")
942
+ elif col == "actor":
943
+ cols.append("act")
944
+ elif col == "action":
945
+ cols.append("acn")
946
+ elif col == "target":
947
+ cols.append("tgt")
948
+ elif col.startswith("metadata."):
949
+ cols.append(f"m.{col[9:]}")
950
+ else:
951
+ cols.append(col)
952
+ compressed["cols"] = cols
953
+
954
+ if view.get("defaultFilter"):
955
+ flt = {}
956
+ df = view["defaultFilter"]
957
+ if "search" in df:
958
+ flt["q"] = df["search"]
959
+ if "action" in df:
960
+ flt["act"] = df["action"]
961
+ compressed["flt"] = flt
962
+ payload["view"] = compressed
963
+
964
+ client_secret_hash = hashlib.sha256(client_secret.encode("utf-8")).hexdigest()
965
+ token = self._sign_hs256_jwt(payload, client_secret_hash)
966
+
967
+ keyring_str = ",".join(f"{k}:{v}" for k, v in self.keyring.items())
968
+ host_param = f"?host={urllib.parse.quote(host_origin)}" if host_origin else ""
969
+ embed_url = f"{dashboard_url}/embed/{token}{host_param}#{keyring_str}"
970
+
971
+ return {"token": token, "embedUrl": embed_url}
972
+
973
+ async def generate_embed_token_async(self, config: dict[str, Any]) -> dict[str, str]:
974
+ loop = asyncio.get_running_loop()
975
+ return await loop.run_in_executor(_executor, lambda: self.generate_embed_token(config))
976
+
977
+ def _sign_hs256_jwt(self, payload: dict[str, Any], secret: str) -> str:
978
+ import hmac
979
+ header = {"alg": "HS256", "typ": "JWT"}
980
+
981
+ def base64url(data: bytes) -> str:
982
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
983
+
984
+ unsigned = f"{base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))}.{base64url(json.dumps(payload, separators=(',', ':')).encode('utf-8'))}"
985
+ signature = hmac.digest(secret.encode("utf-8"), unsigned.encode("utf-8"), "sha256")
986
+ return f"{unsigned}.{base64url(signature)}"
987
+
988
+ def purge_actor_logs(self, actor_id: str, options: Optional[dict[str, Any]] = None) -> dict[str, int]:
989
+ active_key_buffer = self._get_hashed_key(self.active_key_id)
990
+ actor_blind = self.generate_blind_index(actor_id, active_key_buffer)
991
+
992
+ headers = {
993
+ "Authorization": f"Bearer {self.api_key}",
994
+ }
995
+ body = None
996
+ if options:
997
+ headers["Content-Type"] = "application/json"
998
+ body = json.dumps(options, separators=(",", ":")).encode("utf-8")
999
+
1000
+ res_bytes = self._fetch_with_retry(
1001
+ f"{self.endpoint}/v1/projects/{self.project_id}/actors/{actor_blind}",
1002
+ "DELETE",
1003
+ headers,
1004
+ body or b"",
1005
+ )
1006
+ return json.loads(res_bytes.decode("utf-8"))
1007
+
1008
+ async def purge_actor_logs_async(self, actor_id: str, options: Optional[dict[str, Any]] = None) -> dict[str, int]:
1009
+ active_key_buffer = self._get_hashed_key(self.active_key_id)
1010
+ actor_blind = self.generate_blind_index(actor_id, active_key_buffer)
1011
+
1012
+ headers = {
1013
+ "Authorization": f"Bearer {self.api_key}",
1014
+ }
1015
+ body = None
1016
+ if options:
1017
+ headers["Content-Type"] = "application/json"
1018
+ body = json.dumps(options, separators=(",", ":")).encode("utf-8")
1019
+
1020
+ res_bytes = await self._fetch_with_retry_async(
1021
+ f"{self.endpoint}/v1/projects/{self.project_id}/actors/{actor_blind}",
1022
+ "DELETE",
1023
+ headers,
1024
+ body or b"",
1025
+ )
1026
+ return json.loads(res_bytes.decode("utf-8"))
1027
+
1028
+
1029
+ class VolidatorBatcher:
1030
+ def __init__(self, client: VolidatorClient, auto_flush_count: int = 100, auto_flush_interval: Optional[float] = None):
1031
+ self.client = client
1032
+ self.auto_flush_count = min(auto_flush_count, 100)
1033
+ # auto_flush_interval is in seconds
1034
+ self.auto_flush_interval = auto_flush_interval
1035
+
1036
+ self.buffer = []
1037
+ self.lock = threading.Lock()
1038
+ self.timer = None
1039
+
1040
+ self.start_timer()
1041
+
1042
+ def push(self, payload: dict[str, Any]):
1043
+ with self.lock:
1044
+ self.buffer.append(payload)
1045
+ if len(self.buffer) >= self.auto_flush_count:
1046
+ threading.Thread(target=self.flush).start()
1047
+
1048
+ def flush(self) -> dict[str, int]:
1049
+ with self.lock:
1050
+ self.stop_timer()
1051
+ if not self.buffer:
1052
+ self.start_timer()
1053
+ return {"accepted": 0, "rejected": 0}
1054
+
1055
+ payloads_to_flush = list(self.buffer)
1056
+ self.buffer.clear()
1057
+ self.start_timer()
1058
+
1059
+ return self.client.log_batch(payloads_to_flush)
1060
+
1061
+ async def flush_async(self) -> dict[str, int]:
1062
+ with self.lock:
1063
+ self.stop_timer()
1064
+ if not self.buffer:
1065
+ self.start_timer()
1066
+ return {"accepted": 0, "rejected": 0}
1067
+
1068
+ payloads_to_flush = list(self.buffer)
1069
+ self.buffer.clear()
1070
+ self.start_timer()
1071
+
1072
+ return await self.client.log_batch_async(payloads_to_flush)
1073
+
1074
+ def size(self) -> int:
1075
+ with self.lock:
1076
+ return len(self.buffer)
1077
+
1078
+ def start_timer(self):
1079
+ if self.auto_flush_interval and not self.timer:
1080
+ self.timer = threading.Timer(self.auto_flush_interval, self._timer_flush)
1081
+ self.timer.daemon = True
1082
+ self.timer.start()
1083
+
1084
+ def stop_timer(self):
1085
+ if self.timer:
1086
+ self.timer.cancel()
1087
+ self.timer = None
1088
+
1089
+ def _timer_flush(self):
1090
+ try:
1091
+ self.flush()
1092
+ except Exception:
1093
+ pass
1094
+
1095
+
1096
+ def canonicalize(obj: Any) -> str:
1097
+ if obj is None:
1098
+ return "null"
1099
+ if isinstance(obj, (str, int, float, bool)):
1100
+ return json.dumps(obj, separators=(",", ":"))
1101
+ import datetime
1102
+ if isinstance(obj, (datetime.datetime, datetime.date)):
1103
+ if isinstance(obj, datetime.datetime):
1104
+ if obj.tzinfo is not None:
1105
+ obj = obj.astimezone(datetime.timezone.utc)
1106
+ else:
1107
+ obj = obj.replace(tzinfo=datetime.timezone.utc)
1108
+ val = obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
1109
+ else:
1110
+ val = obj.isoformat()
1111
+ return json.dumps(val)
1112
+ if isinstance(obj, list):
1113
+ items = []
1114
+ for item in obj:
1115
+ val = canonicalize(item)
1116
+ items.append("null" if val == "" else val)
1117
+ return "[" + ",".join(items) + "]"
1118
+ if isinstance(obj, dict):
1119
+ keys = sorted(obj.keys())
1120
+ pairs = []
1121
+ for k in keys:
1122
+ val = obj[k]
1123
+ if val is None:
1124
+ pairs.append(json.dumps(k, separators=(",", ":")) + ":null")
1125
+ else:
1126
+ pairs.append(json.dumps(k, separators=(",", ":")) + ":" + canonicalize(val))
1127
+ return "{" + ",".join(pairs) + "}"
1128
+ return json.dumps(str(obj))
1129
+