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 +98 -0
- veritrail/action.py +126 -0
- veritrail/api/__init__.py +0 -0
- veritrail/api/server.py +320 -0
- veritrail/audit.py +65 -0
- veritrail/cli.py +83 -0
- veritrail/crypto.py +132 -0
- veritrail/delegation.py +141 -0
- veritrail/detection.py +225 -0
- veritrail/engine.py +654 -0
- veritrail/errors.py +35 -0
- veritrail/forensics.py +232 -0
- veritrail/ledger.py +191 -0
- veritrail/persistence.py +339 -0
- veritrail/principals.py +107 -0
- veritrail/revocation.py +91 -0
- veritrail/scope.py +163 -0
- veritrail-0.3.0.dist-info/METADATA +276 -0
- veritrail-0.3.0.dist-info/RECORD +23 -0
- veritrail-0.3.0.dist-info/WHEEL +5 -0
- veritrail-0.3.0.dist-info/entry_points.txt +2 -0
- veritrail-0.3.0.dist-info/licenses/LICENSE +201 -0
- veritrail-0.3.0.dist-info/top_level.txt +1 -0
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
|
veritrail/api/server.py
ADDED
|
@@ -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())
|