#!/usr/bin/env python3 """ Ledger API - FastAPI Service ============================ RESTful API for agent governance ledger access. Endpoints: - /health - Health check - /agents - Agent metrics CRUD - /actions - Agent action logs - /violations - Violation records - /promotions - Promotion history - /orchestration - Orchestration logs Authentication: Vault token in X-Vault-Token header """ import json import os import sqlite3 import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Optional, List, Any from contextlib import contextmanager from fastapi import FastAPI, HTTPException, Header, Query, Depends from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field import uvicorn # ============================================================================= # Configuration # ============================================================================= DB_PATH = Path("/opt/agent-governance/ledger/governance.db") VAULT_ADDR = os.environ.get("VAULT_ADDR", "https://127.0.0.1:8200") API_PORT = int(os.environ.get("LEDGER_API_PORT", "8080")) REQUIRE_AUTH = os.environ.get("LEDGER_API_AUTH", "true").lower() == "true" # ============================================================================= # Database # ============================================================================= @contextmanager def get_db(): """Get database connection with row factory""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() def row_to_dict(row: sqlite3.Row) -> dict: """Convert sqlite Row to dictionary""" return dict(row) def rows_to_list(rows: list) -> list: """Convert list of sqlite Rows to list of dicts""" return [row_to_dict(row) for row in rows] # ============================================================================= # Authentication # ============================================================================= async def verify_vault_token(x_vault_token: Optional[str] = Header(None)) -> dict: """Verify Vault token and return token info""" if not REQUIRE_AUTH: return {"authenticated": False, "policies": ["*"]} if not x_vault_token: raise HTTPException(status_code=401, detail="Missing X-Vault-Token header") try: result = subprocess.run([ "curl", "-sk", "-H", f"X-Vault-Token: {x_vault_token}", f"{VAULT_ADDR}/v1/auth/token/lookup-self" ], capture_output=True, text=True, timeout=10) data = json.loads(result.stdout) if "errors" in data: raise HTTPException(status_code=401, detail="Invalid Vault token") return { "authenticated": True, "policies": data.get("data", {}).get("policies", []), "accessor": data.get("data", {}).get("accessor"), "display_name": data.get("data", {}).get("display_name") } except subprocess.TimeoutExpired: raise HTTPException(status_code=503, detail="Vault timeout") except json.JSONDecodeError: raise HTTPException(status_code=401, detail="Invalid Vault response") except Exception as e: raise HTTPException(status_code=500, detail=f"Auth error: {str(e)}") # ============================================================================= # Models # ============================================================================= class AgentMetrics(BaseModel): agent_id: str current_tier: int = 0 compliant_runs: int = 0 consecutive_compliant: int = 0 total_runs: int = 0 last_violation_at: Optional[str] = None last_active_at: Optional[str] = None promotion_eligible: int = 0 class AgentMetricsUpdate(BaseModel): current_tier: Optional[int] = None compliant_runs: Optional[int] = None consecutive_compliant: Optional[int] = None total_runs: Optional[int] = None promotion_eligible: Optional[int] = None class AgentAction(BaseModel): agent_id: str agent_version: str = "1.0" tier: int action: str decision: str confidence: float target: Optional[str] = None side_effects: Optional[str] = None success: int error_type: Optional[str] = None error_message: Optional[str] = None vault_token_accessor: Optional[str] = None session_id: Optional[str] = None class Violation(BaseModel): agent_id: str violation_type: str severity: str # low, medium, high, critical description: str triggering_action: Optional[str] = None evidence: Optional[str] = None remediation: Optional[str] = None class ViolationAcknowledge(BaseModel): acknowledged_by: str class Promotion(BaseModel): agent_id: str from_tier: int to_tier: int approved_by: str rationale: Optional[str] = None evidence: Optional[str] = None class OrchestrationLog(BaseModel): session_id: Optional[str] = None agent_id: Optional[str] = None orchestration_mode: str model_id: Optional[str] = None command_type: str command: str response: Optional[str] = None tokens_used: Optional[int] = None success: int # ============================================================================= # FastAPI App # ============================================================================= app = FastAPI( title="Agent Governance Ledger API", description="RESTful API for agent governance ledger access", version="1.0.0", docs_url="/docs", redoc_url="/redoc" ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ============================================================================= # Health Endpoints # ============================================================================= @app.get("/health") async def health_check(): """Health check endpoint""" db_ok = DB_PATH.exists() vault_ok = False try: result = subprocess.run( ["docker", "exec", "vault", "vault", "status", "-format=json"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: vault_ok = True except: pass return { "status": "healthy" if db_ok else "degraded", "database": "ok" if db_ok else "missing", "vault": "ok" if vault_ok else "unavailable", "timestamp": datetime.now(timezone.utc).isoformat() } @app.get("/") async def root(): """API root""" return { "service": "Agent Governance Ledger API", "version": "1.0.0", "endpoints": { "health": "/health", "agents": "/agents", "actions": "/actions", "violations": "/violations", "promotions": "/promotions", "orchestration": "/orchestration" }, "docs": "/docs" } # ============================================================================= # Agent Metrics Endpoints # ============================================================================= @app.get("/agents") async def list_agents( limit: int = Query(100, le=1000), offset: int = Query(0, ge=0), tier: Optional[int] = None, auth: dict = Depends(verify_vault_token) ): """List all agents with metrics""" with get_db() as conn: query = "SELECT * FROM agent_metrics" params = [] if tier is not None: query += " WHERE current_tier = ?" params.append(tier) query += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) agents = rows_to_list(cursor.fetchall()) return {"agents": agents, "count": len(agents)} @app.get("/agents/{agent_id}") async def get_agent(agent_id: str, auth: dict = Depends(verify_vault_token)): """Get agent metrics by ID""" with get_db() as conn: cursor = conn.execute( "SELECT * FROM agent_metrics WHERE agent_id = ?", (agent_id,) ) row = cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Agent not found") return row_to_dict(row) @app.post("/agents") async def create_agent(agent: AgentMetrics, auth: dict = Depends(verify_vault_token)): """Create or update agent metrics""" with get_db() as conn: conn.execute(""" INSERT OR REPLACE INTO agent_metrics (agent_id, current_tier, compliant_runs, consecutive_compliant, total_runs, last_violation_at, last_active_at, promotion_eligible, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) """, ( agent.agent_id, agent.current_tier, agent.compliant_runs, agent.consecutive_compliant, agent.total_runs, agent.last_violation_at, agent.last_active_at, agent.promotion_eligible )) conn.commit() return {"status": "created", "agent_id": agent.agent_id} @app.patch("/agents/{agent_id}") async def update_agent( agent_id: str, update: AgentMetricsUpdate, auth: dict = Depends(verify_vault_token) ): """Partially update agent metrics""" updates = [] params = [] if update.current_tier is not None: updates.append("current_tier = ?") params.append(update.current_tier) if update.compliant_runs is not None: updates.append("compliant_runs = ?") params.append(update.compliant_runs) if update.consecutive_compliant is not None: updates.append("consecutive_compliant = ?") params.append(update.consecutive_compliant) if update.total_runs is not None: updates.append("total_runs = ?") params.append(update.total_runs) if update.promotion_eligible is not None: updates.append("promotion_eligible = ?") params.append(update.promotion_eligible) if not updates: raise HTTPException(status_code=400, detail="No fields to update") updates.append("updated_at = datetime('now')") params.append(agent_id) with get_db() as conn: cursor = conn.execute( f"UPDATE agent_metrics SET {', '.join(updates)} WHERE agent_id = ?", params ) conn.commit() if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Agent not found") return {"status": "updated", "agent_id": agent_id} # ============================================================================= # Actions Endpoints # ============================================================================= @app.get("/actions") async def list_actions( limit: int = Query(100, le=1000), offset: int = Query(0, ge=0), agent_id: Optional[str] = None, success: Optional[int] = None, auth: dict = Depends(verify_vault_token) ): """List agent actions""" with get_db() as conn: query = "SELECT * FROM agent_actions WHERE 1=1" params = [] if agent_id: query += " AND agent_id = ?" params.append(agent_id) if success is not None: query += " AND success = ?" params.append(success) query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) actions = rows_to_list(cursor.fetchall()) return {"actions": actions, "count": len(actions)} @app.post("/actions") async def create_action(action: AgentAction, auth: dict = Depends(verify_vault_token)): """Log an agent action""" timestamp = datetime.now(timezone.utc).isoformat() with get_db() as conn: cursor = conn.execute(""" INSERT INTO agent_actions (timestamp, agent_id, agent_version, tier, action, decision, confidence, target, side_effects, success, error_type, error_message, vault_token_accessor, session_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( timestamp, action.agent_id, action.agent_version, action.tier, action.action, action.decision, action.confidence, action.target, action.side_effects, action.success, action.error_type, action.error_message, action.vault_token_accessor, action.session_id )) conn.commit() action_id = cursor.lastrowid return {"status": "created", "id": action_id, "timestamp": timestamp} # ============================================================================= # Violations Endpoints # ============================================================================= @app.get("/violations") async def list_violations( limit: int = Query(100, le=1000), offset: int = Query(0, ge=0), agent_id: Optional[str] = None, severity: Optional[str] = None, acknowledged: Optional[int] = None, auth: dict = Depends(verify_vault_token) ): """List violations""" with get_db() as conn: query = "SELECT * FROM violations WHERE 1=1" params = [] if agent_id: query += " AND agent_id = ?" params.append(agent_id) if severity: query += " AND severity = ?" params.append(severity) if acknowledged is not None: query += " AND acknowledged = ?" params.append(acknowledged) query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) violations = rows_to_list(cursor.fetchall()) return {"violations": violations, "count": len(violations)} @app.get("/violations/{violation_id}") async def get_violation(violation_id: int, auth: dict = Depends(verify_vault_token)): """Get violation by ID""" with get_db() as conn: cursor = conn.execute( "SELECT * FROM violations WHERE id = ?", (violation_id,) ) row = cursor.fetchone() if not row: raise HTTPException(status_code=404, detail="Violation not found") return row_to_dict(row) @app.post("/violations") async def create_violation( violation: Violation, auth: dict = Depends(verify_vault_token) ): """Report a violation""" timestamp = datetime.now(timezone.utc).isoformat() with get_db() as conn: cursor = conn.execute(""" INSERT INTO violations (timestamp, agent_id, violation_type, severity, description, triggering_action, evidence, remediation) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( timestamp, violation.agent_id, violation.violation_type, violation.severity, violation.description, violation.triggering_action, violation.evidence, violation.remediation )) conn.commit() violation_id = cursor.lastrowid return {"status": "created", "id": violation_id, "timestamp": timestamp} @app.post("/violations/{violation_id}/acknowledge") async def acknowledge_violation( violation_id: int, ack: ViolationAcknowledge, auth: dict = Depends(verify_vault_token) ): """Acknowledge a violation""" with get_db() as conn: cursor = conn.execute(""" UPDATE violations SET acknowledged = 1, acknowledged_by = ? WHERE id = ? """, (ack.acknowledged_by, violation_id)) conn.commit() if cursor.rowcount == 0: raise HTTPException(status_code=404, detail="Violation not found") return {"status": "acknowledged", "id": violation_id} # ============================================================================= # Promotions Endpoints # ============================================================================= @app.get("/promotions") async def list_promotions( limit: int = Query(100, le=1000), offset: int = Query(0, ge=0), agent_id: Optional[str] = None, auth: dict = Depends(verify_vault_token) ): """List promotions""" with get_db() as conn: query = "SELECT * FROM promotions" params = [] if agent_id: query += " WHERE agent_id = ?" params.append(agent_id) query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) promotions = rows_to_list(cursor.fetchall()) return {"promotions": promotions, "count": len(promotions)} @app.post("/promotions") async def create_promotion( promotion: Promotion, auth: dict = Depends(verify_vault_token) ): """Record a promotion""" timestamp = datetime.now(timezone.utc).isoformat() with get_db() as conn: cursor = conn.execute(""" INSERT INTO promotions (timestamp, agent_id, from_tier, to_tier, approved_by, rationale, evidence) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( timestamp, promotion.agent_id, promotion.from_tier, promotion.to_tier, promotion.approved_by, promotion.rationale, promotion.evidence )) conn.commit() promotion_id = cursor.lastrowid # Update agent metrics conn.execute(""" UPDATE agent_metrics SET current_tier = ?, updated_at = datetime('now') WHERE agent_id = ? """, (promotion.to_tier, promotion.agent_id)) conn.commit() return {"status": "created", "id": promotion_id, "timestamp": timestamp} # ============================================================================= # Orchestration Endpoints # ============================================================================= @app.get("/orchestration") async def list_orchestration_logs( limit: int = Query(100, le=1000), offset: int = Query(0, ge=0), mode: Optional[str] = None, success: Optional[int] = None, auth: dict = Depends(verify_vault_token) ): """List orchestration logs""" with get_db() as conn: query = "SELECT * FROM orchestration_log WHERE 1=1" params = [] if mode: query += " AND orchestration_mode = ?" params.append(mode) if success is not None: query += " AND success = ?" params.append(success) query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.execute(query, params) logs = rows_to_list(cursor.fetchall()) return {"logs": logs, "count": len(logs)} @app.get("/orchestration/summary") async def get_orchestration_summary(auth: dict = Depends(verify_vault_token)): """Get orchestration summary by mode and model""" with get_db() as conn: cursor = conn.execute("SELECT * FROM orchestration_summary") summary = rows_to_list(cursor.fetchall()) return {"summary": summary} @app.post("/orchestration") async def create_orchestration_log( log: OrchestrationLog, auth: dict = Depends(verify_vault_token) ): """Log an orchestration event""" timestamp = datetime.now(timezone.utc).isoformat() with get_db() as conn: cursor = conn.execute(""" INSERT INTO orchestration_log (timestamp, session_id, agent_id, orchestration_mode, model_id, command_type, command, response, tokens_used, success) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( timestamp, log.session_id, log.agent_id, log.orchestration_mode, log.model_id, log.command_type, log.command, log.response, log.tokens_used, log.success )) conn.commit() log_id = cursor.lastrowid return {"status": "created", "id": log_id, "timestamp": timestamp} # ============================================================================= # Stats Endpoint # ============================================================================= @app.get("/stats") async def get_stats(auth: dict = Depends(verify_vault_token)): """Get overall ledger statistics""" with get_db() as conn: stats = {} # Agent counts by tier cursor = conn.execute(""" SELECT current_tier, COUNT(*) as count FROM agent_metrics GROUP BY current_tier """) stats["agents_by_tier"] = {row["current_tier"]: row["count"] for row in cursor.fetchall()} # Total agents cursor = conn.execute("SELECT COUNT(*) as count FROM agent_metrics") stats["total_agents"] = cursor.fetchone()["count"] # Violation counts by severity cursor = conn.execute(""" SELECT severity, COUNT(*) as count FROM violations GROUP BY severity """) stats["violations_by_severity"] = {row["severity"]: row["count"] for row in cursor.fetchall()} # Unacknowledged violations cursor = conn.execute(""" SELECT COUNT(*) as count FROM violations WHERE acknowledged = 0 """) stats["unacknowledged_violations"] = cursor.fetchone()["count"] # Total promotions cursor = conn.execute("SELECT COUNT(*) as count FROM promotions") stats["total_promotions"] = cursor.fetchone()["count"] # Recent activity (last 24h) cursor = conn.execute(""" SELECT COUNT(*) as count FROM agent_actions WHERE timestamp > datetime('now', '-1 day') """) stats["actions_last_24h"] = cursor.fetchone()["count"] # Orchestration token usage cursor = conn.execute(""" SELECT SUM(tokens_used) as total FROM orchestration_log """) row = cursor.fetchone() stats["total_tokens_used"] = row["total"] or 0 return stats # ============================================================================= # Main # ============================================================================= if __name__ == "__main__": print(f""" ╔══════════════════════════════════════════════════════════╗ ║ AGENT GOVERNANCE LEDGER API ║ ╚══════════════════════════════════════════════════════════╝ Database: {DB_PATH} Auth Required: {REQUIRE_AUTH} Port: {API_PORT} Endpoints: GET /health Health check GET / API info GET /docs Swagger UI GET /agents List agents GET /agents/:id Get agent POST /agents Create/update agent PATCH /agents/:id Update agent GET /actions List actions POST /actions Log action GET /violations List violations GET /violations/:id Get violation POST /violations Report violation POST /violations/:id/ack Acknowledge GET /promotions List promotions POST /promotions Record promotion GET /orchestration List orchestration logs GET /orchestration/summary Summary by mode POST /orchestration Log orchestration GET /stats Overall statistics """) uvicorn.run(app, host="0.0.0.0", port=API_PORT)