agentrust-py 0.0.3__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.
@@ -0,0 +1,428 @@
1
+ """
2
+ AgentTrust EmbeddedGateway — zero-dependency local governance.
3
+
4
+ Ships as part of agentrust[embedded]. Starts a minimal FastAPI app in a
5
+ background thread backed by SQLite — no PostgreSQL, no Redis, no Docker.
6
+
7
+ Usage::
8
+
9
+ from agentrust import embed_gateway, harness
10
+
11
+ embed_gateway() # starts in-process gateway on :8765
12
+
13
+ @harness
14
+ def my_agent(user: str, input: str) -> dict:
15
+ return {"answer": "42"}
16
+
17
+ The gateway runs only the fast deterministic checks (schema + basic policy).
18
+ For full risk scoring, LLM judge, and analytics, deploy the full gateway.
19
+
20
+ Stop it::
21
+
22
+ gw = embed_gateway() # returns EmbeddedGateway instance
23
+ gw.stop()
24
+
25
+ Or use as context manager::
26
+
27
+ with EmbeddedGateway() as gw:
28
+ ...
29
+
30
+ Env vars respected
31
+ ------------------
32
+ AGENTRUST_EMBED_PORT Port (default 8765)
33
+ AGENTRUST_EMBED_DB SQLite db path (default ~/.agentrust/embedded.db)
34
+ Use ":memory:" for ephemeral (lost on stop)
35
+ AGENTRUST_EMBED_TOKEN Bearer token required on all non-health endpoints.
36
+ When unset, a random token is generated at startup and
37
+ logged at INFO level. Set explicitly for reproducible auth.
38
+ AGENTRUST_ENABLED When false, embed_gateway() is a no-op
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ import os
44
+ import secrets
45
+ import sqlite3
46
+ import threading
47
+ import time
48
+ from pathlib import Path
49
+ from typing import Any
50
+ from uuid import uuid4
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ _DEFAULT_PORT = int(os.environ.get("AGENTRUST_EMBED_PORT", "8765"))
55
+ _DEFAULT_DB = os.environ.get(
56
+ "AGENTRUST_EMBED_DB",
57
+ str(Path.home() / ".agentrust" / "embedded.db"),
58
+ )
59
+ _MAX_LIST_LIMIT = 200 # server-side cap on /v1/audit/executions?limit=
60
+
61
+ # Module-level singleton so embed_gateway() is idempotent
62
+ _INSTANCE: "EmbeddedGateway | None" = None
63
+
64
+
65
+ class EmbeddedGateway:
66
+ """
67
+ Minimal in-process governance gateway backed by SQLite.
68
+
69
+ Supports:
70
+ - Schema validation (output must be non-empty JSON object)
71
+ - Basic policy checks (output keys, type enforcement)
72
+ - Append-only SQLite audit log
73
+ - governance_disclosure + confidence_rationale fields
74
+ - All 5 decision outcomes: approve/retry/request_evidence/escalate/block
75
+
76
+ Does NOT support (requires full gateway):
77
+ - LLM judge slow-path
78
+ - Risk engine (scores all LOW)
79
+ - Human review queue
80
+ - Webhook notifications
81
+ - Multi-tenant isolation
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ port: int = _DEFAULT_PORT,
87
+ db_path: str = _DEFAULT_DB,
88
+ token: str | None = None,
89
+ ) -> None:
90
+ self._port = port
91
+ self._db_path = db_path
92
+ self._server: Any = None
93
+ self._thread: threading.Thread | None = None
94
+ self._ready = threading.Event()
95
+ # A-3: bearer token for all non-health endpoints.
96
+ # Caller may pass an explicit token; otherwise a random one is generated
97
+ # and emitted to logs so the SDK client can pick it up.
98
+ env_token = os.environ.get("AGENTRUST_EMBED_TOKEN", "").strip()
99
+ self._token: str = token or env_token or secrets.token_hex(32)
100
+ if not (token or env_token):
101
+ logger.info(
102
+ "[AgentTrust] EmbeddedGateway auto-generated bearer token: %s "
103
+ "(set AGENTRUST_EMBED_TOKEN env var to pin this across restarts)",
104
+ self._token,
105
+ )
106
+ self._app = self._build_app()
107
+
108
+ # ------------------------------------------------------------------
109
+ # Lifecycle
110
+ # ------------------------------------------------------------------
111
+
112
+ def start(self) -> "EmbeddedGateway":
113
+ if self._thread and self._thread.is_alive():
114
+ return self
115
+ self._init_db()
116
+ self._thread = threading.Thread(
117
+ target=self._run_server, daemon=True, name="agentrust-embedded"
118
+ )
119
+ self._thread.start()
120
+ if not self._ready.wait(timeout=10):
121
+ raise RuntimeError(
122
+ f"EmbeddedGateway did not start within 10s on port {self._port}"
123
+ )
124
+ logger.info(
125
+ "[AgentTrust] EmbeddedGateway started on http://127.0.0.1:%d "
126
+ "(SQLite: %s)", self._port, self._db_path
127
+ )
128
+ return self
129
+
130
+ def stop(self) -> None:
131
+ if self._server:
132
+ self._server.should_exit = True
133
+ if self._thread:
134
+ self._thread.join(timeout=5)
135
+ logger.info("[AgentTrust] EmbeddedGateway stopped.")
136
+
137
+ def __enter__(self) -> "EmbeddedGateway":
138
+ return self.start()
139
+
140
+ def __exit__(self, *args: Any) -> None:
141
+ self.stop()
142
+
143
+ # ------------------------------------------------------------------
144
+ # Internal: SQLite
145
+ # ------------------------------------------------------------------
146
+
147
+ def _init_db(self) -> None:
148
+ if self._db_path == ":memory:":
149
+ # Will be created per-connection; not shareable across threads,
150
+ # but fine for unit tests. Use file path for persistence.
151
+ return
152
+ Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
153
+ con = sqlite3.connect(self._db_path)
154
+ con.execute("PRAGMA journal_mode=WAL")
155
+ con.execute("PRAGMA synchronous=NORMAL")
156
+ con.execute(
157
+ """
158
+ CREATE TABLE IF NOT EXISTS executions (
159
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
160
+ envelope_id TEXT NOT NULL,
161
+ agent_id TEXT NOT NULL,
162
+ decision TEXT NOT NULL,
163
+ risk_tier TEXT NOT NULL DEFAULT 'low',
164
+ final_confidence REAL NOT NULL DEFAULT 100.0,
165
+ request_json TEXT,
166
+ output_json TEXT,
167
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
168
+ )
169
+ """
170
+ )
171
+ con.commit()
172
+ con.close()
173
+
174
+ def _save_execution(self, envelope_id: str, agent_id: str,
175
+ decision: str, payload: dict[str, Any]) -> None:
176
+ import json
177
+ if self._db_path == ":memory:":
178
+ return
179
+ try:
180
+ con = sqlite3.connect(self._db_path)
181
+ con.execute(
182
+ "INSERT INTO executions "
183
+ "(envelope_id, agent_id, decision, request_json, output_json) "
184
+ "VALUES (?, ?, ?, ?, ?)",
185
+ (
186
+ envelope_id, agent_id, decision,
187
+ json.dumps(payload.get("request", {})),
188
+ json.dumps(payload.get("output", {})),
189
+ ),
190
+ )
191
+ con.commit()
192
+ con.close()
193
+ except Exception as exc:
194
+ logger.warning("[AgentTrust] Embedded audit write failed: %s", exc)
195
+
196
+ # ------------------------------------------------------------------
197
+ # Internal: FastAPI app
198
+ # ------------------------------------------------------------------
199
+
200
+ def _build_app(self) -> Any:
201
+ try:
202
+ from fastapi import FastAPI, Request
203
+ from fastapi.responses import JSONResponse
204
+ from starlette.middleware.base import BaseHTTPMiddleware
205
+ except ImportError:
206
+ raise ImportError(
207
+ "FastAPI is required for EmbeddedGateway. "
208
+ "Install it: pip install 'agentrust-sdk[embedded]'"
209
+ )
210
+
211
+ app = FastAPI(
212
+ title="AgentTrust EmbeddedGateway",
213
+ version="embedded",
214
+ docs_url=None,
215
+ redoc_url=None,
216
+ openapi_url=None,
217
+ )
218
+ _gw = self # closure reference
219
+
220
+ # Reject oversized request bodies (default 1 MB) to prevent memory exhaustion
221
+ _MAX_BODY_BYTES = 1 * 1024 * 1024
222
+
223
+ class _BodySizeLimiter(BaseHTTPMiddleware):
224
+ async def dispatch(self, request: Request, call_next):
225
+ content_length = request.headers.get("content-length")
226
+ if content_length and int(content_length) > _MAX_BODY_BYTES:
227
+ return JSONResponse(
228
+ {"detail": f"Request body too large (max {_MAX_BODY_BYTES} bytes)"},
229
+ status_code=413,
230
+ )
231
+ return await call_next(request)
232
+
233
+ # A-3: bearer token enforcement on all non-health endpoints
234
+ _expected_token = _gw._token
235
+
236
+ class _TokenAuth(BaseHTTPMiddleware):
237
+ async def dispatch(self, request: Request, call_next):
238
+ if request.url.path == "/v1/health":
239
+ return await call_next(request)
240
+ auth = request.headers.get("authorization", "")
241
+ provided = auth.removeprefix("Bearer ").strip()
242
+ if not secrets.compare_digest(provided, _expected_token):
243
+ return JSONResponse(
244
+ {"detail": "Invalid or missing Bearer token"},
245
+ status_code=401,
246
+ )
247
+ return await call_next(request)
248
+
249
+ app.add_middleware(_BodySizeLimiter)
250
+ app.add_middleware(_TokenAuth)
251
+
252
+ @app.get("/v1/health")
253
+ async def health() -> dict:
254
+ return {"status": "ok", "mode": "embedded", "db": _gw._db_path}
255
+
256
+ @app.post("/v1/runtime/validate")
257
+ async def validate(body: dict) -> dict:
258
+ return await _gw._handle_validate(body)
259
+
260
+ @app.get("/v1/audit/executions")
261
+ async def list_executions(limit: int = 20) -> dict:
262
+ # Server-side cap to prevent runaway memory reads
263
+ return _gw._list_executions(min(limit, _MAX_LIST_LIMIT))
264
+
265
+ return app
266
+
267
+ async def _handle_validate(self, body: dict[str, Any]) -> dict[str, Any]:
268
+ envelope_id = str(uuid4())
269
+ agent_id = body.get("agent_id", "unknown")
270
+ output = body.get("output", {})
271
+ request = body.get("request", {})
272
+
273
+ failures: list[str] = []
274
+ schema_score = 100.0
275
+
276
+ # Schema check
277
+ if not isinstance(output, dict):
278
+ failures.append("schema: output must be a JSON object")
279
+ schema_score = 0.0
280
+ elif not output:
281
+ failures.append("schema: output is empty — agent produced no result")
282
+ schema_score = 40.0
283
+
284
+ # Basic policy check: no error keys in output
285
+ if isinstance(output, dict) and output.get("error"):
286
+ failures.append("policy: output contains error field")
287
+ schema_score = min(schema_score, 50.0)
288
+
289
+ final_confidence = schema_score
290
+ decision = "approve" if not failures else ("block" if schema_score == 0 else "retry")
291
+
292
+ governance_disclosure = (
293
+ f"Evaluated by AgentTrust EmbeddedGateway "
294
+ f"(envelope_id={envelope_id}, agent_id={agent_id}). "
295
+ f"Decision: {decision}. Confidence: {final_confidence:.1f}%. "
296
+ f"Mode: embedded (schema + basic policy checks only)."
297
+ )
298
+
299
+ self._save_execution(envelope_id, agent_id, decision, body)
300
+
301
+ return {
302
+ "envelope_id": envelope_id,
303
+ "validation": {
304
+ "schema_score": schema_score,
305
+ "evidence_score": 0.0,
306
+ "tool_trust_score": 0.0,
307
+ "consistency_score": 0.0,
308
+ "policy_score": schema_score,
309
+ "final_confidence": final_confidence,
310
+ "failures": failures,
311
+ },
312
+ "risk": {
313
+ "tier": "low",
314
+ "score": 0.0,
315
+ "reason": "Embedded mode: risk engine not available",
316
+ },
317
+ "decision": {
318
+ "outcome": decision,
319
+ "reason": failures[0] if failures else "All embedded checks passed",
320
+ "policy_version": "embedded-v1",
321
+ },
322
+ "latency_ms": 0.0,
323
+ "governance_disclosure": governance_disclosure,
324
+ "confidence_rationale": (
325
+ f"Schema check: {schema_score:.0f}/100. "
326
+ "Full confidence engine requires full gateway deployment."
327
+ ),
328
+ "trust_chain": None,
329
+ }
330
+
331
+ def _list_executions(self, limit: int) -> dict[str, Any]:
332
+ if self._db_path == ":memory:":
333
+ return {"items": [], "note": "In-memory mode: no persisted records"}
334
+ try:
335
+ con = sqlite3.connect(self._db_path)
336
+ con.row_factory = sqlite3.Row
337
+ rows = con.execute(
338
+ "SELECT * FROM executions ORDER BY timestamp DESC LIMIT ?", (limit,)
339
+ ).fetchall()
340
+ con.close()
341
+ return {"items": [dict(r) for r in rows], "total": len(rows)}
342
+ except Exception as exc:
343
+ return {"items": [], "error": str(exc)}
344
+
345
+ # ------------------------------------------------------------------
346
+ # Internal: server runner
347
+ # ------------------------------------------------------------------
348
+
349
+ def _run_server(self) -> None:
350
+ try:
351
+ import uvicorn
352
+ except ImportError:
353
+ raise ImportError(
354
+ "uvicorn is required for EmbeddedGateway. "
355
+ "Install it: pip install 'agentrust-sdk[embedded]'"
356
+ )
357
+
358
+ config = uvicorn.Config(
359
+ app=self._app,
360
+ host="127.0.0.1",
361
+ port=self._port,
362
+ log_level="error",
363
+ access_log=False,
364
+ )
365
+ self._server = uvicorn.Server(config)
366
+
367
+ # Signal ready once the server loop is about to start
368
+ original_startup = self._server.startup
369
+
370
+ async def _startup_with_signal(*args: Any, **kw: Any) -> None:
371
+ await original_startup(*args, **kw)
372
+ self._ready.set()
373
+
374
+ self._server.startup = _startup_with_signal
375
+ self._server.run()
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Public helper
380
+ # ---------------------------------------------------------------------------
381
+
382
+ def embed_gateway(
383
+ port: int = _DEFAULT_PORT,
384
+ db_path: str = _DEFAULT_DB,
385
+ set_env: bool = True,
386
+ ) -> "EmbeddedGateway":
387
+ """
388
+ Start an in-process EmbeddedGateway and (optionally) set AGENTRUST_GATEWAY_URL
389
+ so all SDK clients automatically route to it.
390
+
391
+ Idempotent — calling twice returns the same instance.
392
+
393
+ Parameters
394
+ ----------
395
+ port: Port to listen on (default 8765, configurable via AGENTRUST_EMBED_PORT)
396
+ db_path: SQLite database file path (default ~/.agentrust/embedded.db)
397
+ set_env: When True, sets AGENTRUST_GATEWAY_URL=http://127.0.0.1:{port}
398
+ so @harness picks it up automatically.
399
+
400
+ Returns the running EmbeddedGateway instance.
401
+ """
402
+ from .config import SDK_CONFIG
403
+
404
+ if not SDK_CONFIG.enabled:
405
+ logger.info("[AgentTrust] AGENTRUST_ENABLED=false — embed_gateway() is a no-op")
406
+
407
+ class _NoOp:
408
+ def stop(self) -> None: ...
409
+ def __enter__(self): return self
410
+ def __exit__(self, *a: Any): ...
411
+
412
+ return _NoOp() # type: ignore[return-value]
413
+
414
+ global _INSTANCE
415
+ if _INSTANCE is not None:
416
+ return _INSTANCE
417
+
418
+ gw = EmbeddedGateway(port=port, db_path=db_path)
419
+ gw.start()
420
+
421
+ if set_env:
422
+ # SDK_CONFIG.gateway_url and api_key are properties that read from os.environ,
423
+ # so setting env vars here is sufficient for all clients created afterwards.
424
+ os.environ["AGENTRUST_GATEWAY_URL"] = f"http://127.0.0.1:{port}"
425
+ os.environ["AGENTRUST_KEY"] = gw._token
426
+
427
+ _INSTANCE = gw
428
+ return gw