veritrail 0.3.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.
veritrail/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """
2
+ Veritrail — verifiable provenance and forensics for autonomous AI agents.
3
+
4
+ Veritrail is the "flight recorder and chain-of-custody" for agentic actions.
5
+ For any action an agent (or a sub-agent it spawned, N hops deep) takes, it can
6
+ answer the three questions every CISO, auditor, and court will ask:
7
+
8
+ 1. Who authorized this? -> cryptographically reconstruct the chain to the
9
+ originating human.
10
+ 2. Was the chain hijacked? -> detect goal-hijack, tool poisoning, intent
11
+ drift, expired authority, identity abuse, and consent fatigue.
12
+ 3. Can you prove it later? -> a tamper-evident, hash-chained ledger.
13
+
14
+ Quick start::
15
+
16
+ from veritrail import Engine, Scope, crypto
17
+
18
+ eng = Engine()
19
+ h_priv, h_pub = crypto.generate_keypair()
20
+ a_priv, a_pub = crypto.generate_keypair()
21
+ human = eng.register_human("Alice (CFO)", h_pub)
22
+ agent = eng.register_agent("FinanceBot", a_pub)
23
+
24
+ grant = eng.issue_root_delegation(
25
+ human_private_key=h_priv, human_id=human.id, agent_id=agent.id,
26
+ scope=Scope.make(tools={"payments.read"}, actions={"read"}, max_risk=20),
27
+ purpose="reconcile invoices", ttl_seconds=3600,
28
+ )
29
+ rec, verdict = eng.record_action(
30
+ actor_private_key=a_priv, actor_id=agent.id, delegation_id=grant.id,
31
+ tool="payments.read", action="read", risk=10, description="read invoice 42",
32
+ )
33
+ assert verdict.authorized
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from . import crypto
39
+ from .action import ActionRecord, build_signed_action
40
+ from .audit import AuditSink, JsonlSink, NullSink, make_event
41
+ from .delegation import Delegation, build_signed_delegation
42
+ from .detection import DetectionEngine, Finding, Severity
43
+ from .engine import ChainResult, Engine, VerdictResult
44
+ from .errors import (
45
+ ChainBroken,
46
+ ExpiredGrant,
47
+ ScopeViolation,
48
+ SignatureError,
49
+ TamperDetected,
50
+ UnknownPrincipal,
51
+ ValidationError,
52
+ VeritrailError,
53
+ )
54
+ from .ledger import Ledger, LedgerEntry
55
+ from .persistence import PostgresStore, SqliteStore, open_store
56
+ from .principals import Principal, PrincipalKind, PrincipalRegistry
57
+ from .revocation import Revocation, RevocationRegistry
58
+ from .scope import Scope
59
+
60
+ __version__ = "0.3.0"
61
+
62
+ __all__ = [
63
+ "crypto",
64
+ "Engine",
65
+ "Scope",
66
+ "Delegation",
67
+ "build_signed_delegation",
68
+ "ActionRecord",
69
+ "build_signed_action",
70
+ "DetectionEngine",
71
+ "Finding",
72
+ "Severity",
73
+ "ChainResult",
74
+ "VerdictResult",
75
+ "Ledger",
76
+ "LedgerEntry",
77
+ "Principal",
78
+ "PrincipalKind",
79
+ "PrincipalRegistry",
80
+ "Revocation",
81
+ "RevocationRegistry",
82
+ "SqliteStore",
83
+ "PostgresStore",
84
+ "open_store",
85
+ "AuditSink",
86
+ "JsonlSink",
87
+ "NullSink",
88
+ "make_event",
89
+ "VeritrailError",
90
+ "ChainBroken",
91
+ "ExpiredGrant",
92
+ "ScopeViolation",
93
+ "SignatureError",
94
+ "TamperDetected",
95
+ "UnknownPrincipal",
96
+ "ValidationError",
97
+ "__version__",
98
+ ]
veritrail/action.py ADDED
@@ -0,0 +1,126 @@
1
+ """
2
+ veritrail.action
3
+ ================
4
+ An :class:`ActionRecord` is the signed statement "principal X performed this
5
+ action under delegation D". It is the leaf of the provenance tree — the thing
6
+ a forensic investigator starts from when asking "who authorized this, and was
7
+ the chain hijacked?".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from . import crypto
17
+ from .errors import ValidationError
18
+ from .principals import new_id
19
+
20
+ _TOOL_MAX = 256
21
+ _DESC_MAX = 2048
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ActionRecord:
26
+ id: str
27
+ actor_id: str # the principal taking the action
28
+ delegation_id: str # the delegation authorizing it
29
+ tool: str # tool/capability invoked (e.g. "payments.transfer")
30
+ action: str # action type (e.g. "write", "read", "execute")
31
+ risk: int # 0-100 risk band the caller assigns this action
32
+ description: str # what the agent believes it is doing / params digest
33
+ params_digest: str # sha256 of the concrete parameters (no raw secrets)
34
+ occurred_at: float
35
+ signature: str = ""
36
+
37
+ def __post_init__(self) -> None:
38
+ if not isinstance(self.tool, str) or not (0 < len(self.tool) <= _TOOL_MAX):
39
+ raise ValidationError("tool invalid")
40
+ if not isinstance(self.action, str) or not (0 < len(self.action) <= _TOOL_MAX):
41
+ raise ValidationError("action invalid")
42
+ if not isinstance(self.risk, int) or not (0 <= self.risk <= 100):
43
+ raise ValidationError("risk must be int in [0,100]")
44
+ if not isinstance(self.description, str) or len(self.description) > _DESC_MAX:
45
+ raise ValidationError("description too long")
46
+
47
+ def _signing_payload(self) -> dict[str, Any]:
48
+ return {
49
+ "id": self.id,
50
+ "actor_id": self.actor_id,
51
+ "delegation_id": self.delegation_id,
52
+ "tool": self.tool,
53
+ "action": self.action,
54
+ "risk": self.risk,
55
+ "description": self.description,
56
+ "params_digest": self.params_digest,
57
+ "occurred_at": self.occurred_at,
58
+ }
59
+
60
+ def signing_bytes(self) -> bytes:
61
+ return crypto.canonical_bytes(self._signing_payload())
62
+
63
+ def verify_signature(self, actor_public_key) -> bool:
64
+ return crypto.verify(actor_public_key, self.signing_bytes(), self.signature)
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ d = self._signing_payload()
68
+ d["signature"] = self.signature
69
+ return d
70
+
71
+ @classmethod
72
+ def from_dict(cls, d: dict[str, Any]) -> "ActionRecord":
73
+ if not isinstance(d, dict):
74
+ raise ValidationError("action payload must be an object")
75
+ try:
76
+ return cls(
77
+ id=d["id"],
78
+ actor_id=d["actor_id"],
79
+ delegation_id=d["delegation_id"],
80
+ tool=d["tool"],
81
+ action=d["action"],
82
+ risk=int(d["risk"]),
83
+ description=d["description"],
84
+ params_digest=d["params_digest"],
85
+ occurred_at=float(d["occurred_at"]),
86
+ signature=d.get("signature", ""),
87
+ )
88
+ except (KeyError, TypeError, ValueError) as exc:
89
+ raise ValidationError(f"malformed action payload: {exc}") from None
90
+
91
+
92
+ def build_signed_action(
93
+ *,
94
+ actor_private_key,
95
+ actor_id: str,
96
+ delegation_id: str,
97
+ tool: str,
98
+ action: str,
99
+ risk: int,
100
+ description: str,
101
+ params: dict[str, Any] | None = None,
102
+ now: float | None = None,
103
+ ) -> ActionRecord:
104
+ """Construct and sign an action record.
105
+
106
+ ``params`` are hashed, never stored raw, so the ledger carries proof-of-
107
+ parameters without becoming a secrets repository (OWASP A02/A09 hygiene).
108
+ """
109
+ params_digest = crypto.sha256_hex(crypto.canonical_bytes(params or {}))
110
+ rec = ActionRecord(
111
+ id=new_id("act"),
112
+ actor_id=actor_id,
113
+ delegation_id=delegation_id,
114
+ tool=tool,
115
+ action=action,
116
+ risk=risk,
117
+ description=description,
118
+ params_digest=params_digest,
119
+ occurred_at=now if now is not None else time.time(),
120
+ )
121
+ sig = crypto.sign(actor_private_key, rec.signing_bytes())
122
+ return ActionRecord(
123
+ id=rec.id, actor_id=rec.actor_id, delegation_id=rec.delegation_id,
124
+ tool=rec.tool, action=rec.action, risk=rec.risk, description=rec.description,
125
+ params_digest=rec.params_digest, occurred_at=rec.occurred_at, signature=sig,
126
+ )
File without changes
@@ -0,0 +1,320 @@
1
+ """
2
+ veritrail.api.server
3
+ ====================
4
+ The Veritrail REST service.
5
+
6
+ Security & robustness posture:
7
+ * Clients sign delegations/actions locally with the SDK; the service only ever
8
+ receives public keys and signatures. No private key material is accepted,
9
+ stored, or logged (OWASP A02 Cryptographic Failures, A09 Logging).
10
+ * Domain errors are raised as typed ``VeritrailError`` subclasses at the source
11
+ and mapped to HTTP status codes by global exception handlers. A catch-all
12
+ handler guarantees no unhandled exception ever leaks a stack trace to a
13
+ client (OWASP A04/A05). Stack traces are logged server-side only.
14
+ * Strict security headers on every response, including error responses.
15
+ * Optional bearer-token auth on writes; per-client rate limiting with bounded
16
+ memory; optional trusted-host enforcement.
17
+
18
+ Run: uvicorn veritrail.api.server:app --host 0.0.0.0 --port 8080
19
+ Docs: http://localhost:8080/docs (or the machine-readable /openapi.json)
20
+
21
+ Environment variables:
22
+ VERITRAIL_DB path to a SQLite file to enable durable persistence
23
+ VERITRAIL_API_KEY if set, writes require "Authorization: Bearer <key>"
24
+ VERITRAIL_RATE_LIMIT max requests per client IP per 60s window (default 240)
25
+ VERITRAIL_ALLOWED_HOSTS comma-separated Host allowlist (TrustedHostMiddleware)
26
+ VERITRAIL_DISABLE_DOCS set to "1" to disable the interactive docs in prod
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import os
33
+ import time
34
+ from collections import defaultdict, deque
35
+ from typing import Any
36
+
37
+ from fastapi import FastAPI, Header, HTTPException, Request
38
+ from fastapi.exceptions import RequestValidationError
39
+ from fastapi.responses import HTMLResponse, JSONResponse
40
+ from pydantic import BaseModel, Field
41
+
42
+ from veritrail import __version__
43
+ from veritrail.action import ActionRecord
44
+ from veritrail.delegation import Delegation
45
+ from veritrail.engine import Engine
46
+ from veritrail.errors import (
47
+ ChainBroken,
48
+ ExpiredGrant,
49
+ ScopeViolation,
50
+ SignatureError,
51
+ UnknownPrincipal,
52
+ ValidationError,
53
+ VeritrailError,
54
+ )
55
+ from veritrail.forensics import build_report
56
+ from veritrail.persistence import open_store
57
+ from veritrail.principals import Principal, PrincipalKind, new_id
58
+
59
+ logger = logging.getLogger("veritrail")
60
+
61
+ # --- configuration ---------------------------------------------------------
62
+ _DISABLE_DOCS = os.environ.get("VERITRAIL_DISABLE_DOCS") == "1"
63
+
64
+ app = FastAPI(
65
+ title="Veritrail",
66
+ version=__version__,
67
+ description="Verifiable provenance and forensics for autonomous AI agents.",
68
+ docs_url=None if _DISABLE_DOCS else "/docs",
69
+ redoc_url=None if _DISABLE_DOCS else "/redoc",
70
+ )
71
+
72
+ # Optional durable persistence.
73
+ _db_path = os.environ.get("VERITRAIL_DB")
74
+ _store = open_store(_db_path) if _db_path else None
75
+ engine = Engine(store=_store)
76
+
77
+ # Optional API-key auth for write endpoints.
78
+ _API_KEY = os.environ.get("VERITRAIL_API_KEY")
79
+
80
+ # Optional trusted-host enforcement.
81
+ _allowed_hosts = os.environ.get("VERITRAIL_ALLOWED_HOSTS")
82
+ if _allowed_hosts:
83
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
84
+ app.add_middleware(
85
+ TrustedHostMiddleware,
86
+ allowed_hosts=[h.strip() for h in _allowed_hosts.split(",") if h.strip()],
87
+ )
88
+
89
+ # Rate limiter (bounded-memory, per client IP).
90
+ _RATE_LIMIT = int(os.environ.get("VERITRAIL_RATE_LIMIT", "240"))
91
+ _RATE_WINDOW = 60.0
92
+ _MAX_TRACKED_IPS = 100_000
93
+ _hits: dict[str, deque] = defaultdict(deque)
94
+ _last_prune = 0.0
95
+
96
+ # Security headers applied to *every* response, including errors.
97
+ _BASE_SECURITY_HEADERS = {
98
+ "X-Content-Type-Options": "nosniff",
99
+ "X-Frame-Options": "DENY",
100
+ "Referrer-Policy": "no-referrer",
101
+ "Cache-Control": "no-store",
102
+ }
103
+ _STRICT_CSP = "default-src 'none'; frame-ancestors 'none'"
104
+ # The interactive docs load Swagger UI assets from a CDN, so they need a
105
+ # relaxed policy. This applies ONLY to the docs routes; the API stays strict.
106
+ _DOCS_CSP = (
107
+ "default-src 'none'; "
108
+ "script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; "
109
+ "style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; "
110
+ "img-src 'self' https://fastapi.tiangolo.com data:; "
111
+ "connect-src 'self'"
112
+ )
113
+ _DOCS_PATHS = ("/docs", "/redoc", "/openapi.json")
114
+
115
+
116
+ def _security_headers_for(path: str) -> dict[str, str]:
117
+ headers = dict(_BASE_SECURITY_HEADERS)
118
+ headers["Content-Security-Policy"] = _DOCS_CSP if path in _DOCS_PATHS else _STRICT_CSP
119
+ return headers
120
+
121
+
122
+ def _check_auth(authorization: str | None) -> None:
123
+ if _API_KEY is None:
124
+ return
125
+ import hmac
126
+ expected = f"Bearer {_API_KEY}"
127
+ if authorization is None or not hmac.compare_digest(authorization, expected):
128
+ raise HTTPException(status_code=401, detail="missing or invalid API key")
129
+
130
+
131
+ def _prune_rate_limiter(now: float) -> None:
132
+ """Drop stale per-IP buckets so memory cannot grow without bound."""
133
+ global _last_prune
134
+ if now - _last_prune < _RATE_WINDOW:
135
+ return
136
+ _last_prune = now
137
+ stale = [ip for ip, dq in _hits.items() if not dq or dq[-1] < now - _RATE_WINDOW]
138
+ for ip in stale:
139
+ _hits.pop(ip, None)
140
+ # Hard cap as a final backstop against a flood of unique IPs.
141
+ if len(_hits) > _MAX_TRACKED_IPS:
142
+ _hits.clear()
143
+
144
+
145
+ @app.middleware("http")
146
+ async def security_and_rate_limit(request: Request, call_next):
147
+ path = request.url.path
148
+ if path != "/healthz":
149
+ client = request.client.host if request.client else "unknown"
150
+ now = time.time()
151
+ _prune_rate_limiter(now)
152
+ dq = _hits[client]
153
+ while dq and dq[0] < now - _RATE_WINDOW:
154
+ dq.popleft()
155
+ if len(dq) >= _RATE_LIMIT:
156
+ return JSONResponse(
157
+ status_code=429,
158
+ content={"detail": "rate limit exceeded"},
159
+ headers=_security_headers_for(path),
160
+ )
161
+ dq.append(now)
162
+
163
+ response = await call_next(request)
164
+ for k, v in _security_headers_for(path).items():
165
+ response.headers[k] = v
166
+ return response
167
+
168
+
169
+ # --- global exception handlers --------------------------------------------
170
+ def _status_and_detail(exc: VeritrailError) -> tuple[int, str]:
171
+ if isinstance(exc, (ScopeViolation, ExpiredGrant, SignatureError, ChainBroken, ValidationError)):
172
+ return 422, f"{type(exc).__name__}: {exc}"
173
+ if isinstance(exc, UnknownPrincipal):
174
+ return 404, str(exc)
175
+ return 400, str(exc)
176
+
177
+
178
+ @app.exception_handler(VeritrailError)
179
+ async def veritrail_error_handler(request: Request, exc: VeritrailError) -> JSONResponse:
180
+ status, detail = _status_and_detail(exc)
181
+ return JSONResponse(
182
+ status_code=status, content={"detail": detail},
183
+ headers=_security_headers_for(request.url.path),
184
+ )
185
+
186
+
187
+ @app.exception_handler(RequestValidationError)
188
+ async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
189
+ return JSONResponse(
190
+ status_code=422, content={"detail": "invalid request body", "errors": exc.errors()},
191
+ headers=_security_headers_for(request.url.path),
192
+ )
193
+
194
+
195
+ @app.exception_handler(Exception)
196
+ async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
197
+ # Log the full context server-side; return a sanitized message to the client.
198
+ logger.exception("unhandled error on %s %s", request.method, request.url.path)
199
+ return JSONResponse(
200
+ status_code=500, content={"detail": "internal server error"},
201
+ headers=_security_headers_for(request.url.path),
202
+ )
203
+
204
+
205
+ # --- request models --------------------------------------------------------
206
+ class RegisterPrincipal(BaseModel):
207
+ id: str | None = Field(default=None, max_length=128)
208
+ kind: str = Field(pattern="^(human|agent)$")
209
+ name: str = Field(min_length=1, max_length=256)
210
+ public_key_b64: str = Field(min_length=1, max_length=128)
211
+
212
+
213
+ class DelegationIn(BaseModel):
214
+ delegation: dict[str, Any]
215
+
216
+
217
+ class ActionIn(BaseModel):
218
+ action: dict[str, Any]
219
+
220
+
221
+ class RevokeIn(BaseModel):
222
+ target_id: str = Field(min_length=1, max_length=256)
223
+ target_kind: str = Field(pattern="^(delegation|principal)$")
224
+ reason: str = Field(min_length=1, max_length=512)
225
+ revoked_by: str | None = Field(default=None, max_length=256)
226
+
227
+
228
+ # --- endpoints -------------------------------------------------------------
229
+ @app.get("/healthz")
230
+ def healthz() -> dict[str, str]:
231
+ return {"status": "ok"}
232
+
233
+
234
+ @app.get("/v1/stats")
235
+ def stats() -> dict[str, Any]:
236
+ return engine.stats()
237
+
238
+
239
+ @app.post("/v1/principals", status_code=201)
240
+ def register_principal(body: RegisterPrincipal, authorization: str | None = Header(default=None)) -> dict[str, Any]:
241
+ _check_auth(authorization)
242
+ # Principal construction validates the key and raises ValidationError on
243
+ # malformed input, which the global handler maps to 422.
244
+ p = Principal(
245
+ id=body.id or new_id(body.kind),
246
+ kind=PrincipalKind(body.kind),
247
+ name=body.name,
248
+ public_key_b64=body.public_key_b64,
249
+ )
250
+ engine.registry.register(p)
251
+ if engine.store is not None:
252
+ engine.store.save_principal(p)
253
+ return p.to_dict()
254
+
255
+
256
+ @app.get("/v1/principals")
257
+ def list_principals() -> list[dict[str, Any]]:
258
+ return [p.to_dict() for p in engine.registry.all()]
259
+
260
+
261
+ @app.post("/v1/delegations", status_code=201)
262
+ def ingest_delegation(body: DelegationIn, authorization: str | None = Header(default=None)) -> dict[str, Any]:
263
+ _check_auth(authorization)
264
+ d = Delegation.from_dict(body.delegation) # raises ValidationError if malformed
265
+ engine.ingest_delegation(d) # raises typed errors on failure
266
+ return {"id": d.id, "status": "accepted"}
267
+
268
+
269
+ @app.post("/v1/actions", status_code=201)
270
+ def ingest_action(body: ActionIn, authorization: str | None = Header(default=None)) -> dict[str, Any]:
271
+ _check_auth(authorization)
272
+ a = ActionRecord.from_dict(body.action)
273
+ _, verdict = engine.ingest_action(a)
274
+ return verdict.to_dict()
275
+
276
+
277
+ @app.post("/v1/revocations", status_code=201)
278
+ def revoke(body: RevokeIn, authorization: str | None = Header(default=None)) -> dict[str, Any]:
279
+ _check_auth(authorization)
280
+ if body.target_kind == "delegation":
281
+ r = engine.revoke_delegation(body.target_id, body.reason, revoked_by=body.revoked_by)
282
+ else:
283
+ r = engine.revoke_principal(body.target_id, body.reason, revoked_by=body.revoked_by)
284
+ return r.to_dict()
285
+
286
+
287
+ @app.get("/v1/revocations")
288
+ def list_revocations() -> list[dict[str, Any]]:
289
+ return [r.to_dict() for r in engine.revocations.all()]
290
+
291
+
292
+ @app.get("/v1/actions/{action_id}/verdict")
293
+ def get_verdict(action_id: str) -> dict[str, Any]:
294
+ if not engine.has_action(action_id):
295
+ raise HTTPException(status_code=404, detail="unknown action")
296
+ return engine.verify_action(action_id).to_dict()
297
+
298
+
299
+ @app.get("/v1/actions/{action_id}/chain")
300
+ def get_chain(action_id: str) -> dict[str, Any]:
301
+ if not engine.has_action(action_id):
302
+ raise HTTPException(status_code=404, detail="unknown action")
303
+ return engine.reconstruct_chain(action_id).to_dict()
304
+
305
+
306
+ @app.get("/v1/actions/{action_id}/report", response_class=HTMLResponse)
307
+ def get_report(action_id: str) -> HTMLResponse:
308
+ if not engine.has_action(action_id):
309
+ raise HTTPException(status_code=404, detail="unknown action")
310
+ verdict = engine.verify_action(action_id)
311
+ return HTMLResponse(content=build_report(engine, verdict))
312
+
313
+
314
+ @app.get("/v1/ledger/verify")
315
+ def verify_ledger() -> dict[str, Any]:
316
+ try:
317
+ ok = engine.verify_ledger()
318
+ return {"intact": ok, "entries": len(engine.ledger), "head": engine.ledger.head_hash}
319
+ except VeritrailError as exc:
320
+ return {"intact": False, "error": str(exc)}
veritrail/audit.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ veritrail.audit
3
+ ===============
4
+ Structured audit events for SIEM / observability pipelines.
5
+
6
+ Veritrail emits a structured event for every consequential operation
7
+ (delegation issued, action verified, revocation, integrity check). Field names
8
+ follow the OpenTelemetry GenAI semantic conventions where one exists
9
+ (``gen_ai.agent.id``, ``gen_ai.operation.name``, ``gen_ai.tool.name``) so the
10
+ events drop straight into an OTel collector, Datadog, Splunk, or Elastic
11
+ without remapping. Veritrail-specific fields use the ``veritrail.*`` namespace.
12
+
13
+ By default no event sink is attached (``NullSink``). Attach :class:`JsonlSink`
14
+ to write newline-delimited JSON, or implement the one-method protocol to bridge
15
+ to your tracer. Sinks must never raise into the caller — an observability
16
+ failure must not break an authorization decision.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ import threading
24
+ import time
25
+ from typing import Any, Protocol, TextIO
26
+
27
+
28
+ class AuditSink(Protocol):
29
+ def emit(self, event: dict[str, Any]) -> None: ...
30
+
31
+
32
+ class NullSink:
33
+ """Discards events. The safe default."""
34
+
35
+ def emit(self, event: dict[str, Any]) -> None: # noqa: D401
36
+ return None
37
+
38
+
39
+ class JsonlSink:
40
+ """Writes newline-delimited JSON. Thread-safe; never raises into callers."""
41
+
42
+ def __init__(self, stream: TextIO | None = None) -> None:
43
+ self._stream = stream or sys.stdout
44
+ self._lock = threading.Lock()
45
+
46
+ def emit(self, event: dict[str, Any]) -> None:
47
+ try:
48
+ line = json.dumps(event, separators=(",", ":"), default=str)
49
+ with self._lock:
50
+ self._stream.write(line + "\n")
51
+ self._stream.flush()
52
+ except Exception:
53
+ # Observability must not break authorization.
54
+ pass
55
+
56
+
57
+ def make_event(operation: str, **fields: Any) -> dict[str, Any]:
58
+ """Build an OTel-GenAI-flavored event envelope."""
59
+ event = {
60
+ "timestamp": time.time(),
61
+ "gen_ai.operation.name": operation,
62
+ "veritrail.event": operation,
63
+ }
64
+ event.update(fields)
65
+ return event
veritrail/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ """
2
+ veritrail.cli
3
+ =============
4
+ A small operator CLI. Deliberately minimal — the heavy lifting is the SDK and
5
+ the REST service; this just covers the things an operator does at a terminal.
6
+
7
+ veritrail keygen generate an Ed25519 keypair (prints public, and
8
+ writes the private key to a 0600 file)
9
+ veritrail serve run the REST API (uvicorn)
10
+ veritrail demo run the end-to-end demo
11
+ veritrail version print the version
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+
20
+ from . import __version__, crypto
21
+
22
+
23
+ def _keygen(args: argparse.Namespace) -> int:
24
+ priv, pub = crypto.generate_keypair()
25
+ pub_b64 = crypto.public_key_to_b64(pub)
26
+ print(f"public_key_b64: {pub_b64}")
27
+ if args.out:
28
+ # Write private key with restrictive permissions; never print it.
29
+ fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
30
+ with os.fdopen(fd, "w") as f:
31
+ f.write(crypto.private_key_to_b64(priv))
32
+ print(f"private key written to {args.out} (mode 0600) — keep it secret")
33
+ else:
34
+ print("(re-run with --out PATH to persist the private key to a 0600 file)")
35
+ return 0
36
+
37
+
38
+ def _serve(args: argparse.Namespace) -> int:
39
+ try:
40
+ import uvicorn
41
+ except ImportError:
42
+ print("uvicorn is required to serve; pip install 'uvicorn[standard]'", file=sys.stderr)
43
+ return 1
44
+ uvicorn.run("veritrail.api.server:app", host=args.host, port=args.port)
45
+ return 0
46
+
47
+
48
+ def _demo(_args: argparse.Namespace) -> int:
49
+ from examples.demo import main as demo_main
50
+ demo_main()
51
+ return 0
52
+
53
+
54
+ def _version(_args: argparse.Namespace) -> int:
55
+ print(f"veritrail {__version__}")
56
+ return 0
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ parser = argparse.ArgumentParser(prog="veritrail", description="Veritrail operator CLI")
61
+ sub = parser.add_subparsers(dest="command", required=True)
62
+
63
+ p_keygen = sub.add_parser("keygen", help="generate an Ed25519 keypair")
64
+ p_keygen.add_argument("--out", help="path to write the private key (0600)")
65
+ p_keygen.set_defaults(func=_keygen)
66
+
67
+ p_serve = sub.add_parser("serve", help="run the REST API")
68
+ p_serve.add_argument("--host", default="0.0.0.0")
69
+ p_serve.add_argument("--port", type=int, default=8080)
70
+ p_serve.set_defaults(func=_serve)
71
+
72
+ p_demo = sub.add_parser("demo", help="run the end-to-end demo")
73
+ p_demo.set_defaults(func=_demo)
74
+
75
+ p_version = sub.add_parser("version", help="print version")
76
+ p_version.set_defaults(func=_version)
77
+
78
+ args = parser.parse_args(argv)
79
+ return args.func(args)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ raise SystemExit(main())