diff --git a/agentwatch/api/server.py b/agentwatch/api/server.py index cb729010..d2ec52ba 100644 --- a/agentwatch/api/server.py +++ b/agentwatch/api/server.py @@ -12,6 +12,7 @@ from collections import defaultdict from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import Any from fastapi import ( @@ -249,7 +250,8 @@ async def _pg_write_event(event: AgentEvent, tenant_id: str | None = None) -> No _confidence_scorer = ConfidenceScorer() _cost_tracker = CostTracker() _reasoning_auditor = ReasoningAuditor() -_governance = GovernanceEngine() +_audit_log_path = Path(os.getenv("AGENTWATCH_AUDIT_LOG_PATH", "data/audit-log.jsonl")) +_governance = GovernanceEngine(audit_log_path=_audit_log_path) _compliance_reporter = ComplianceReporter(_governance, _collector) _alerting = AlertingEngine( AlertingConfig( @@ -1265,7 +1267,16 @@ async def dashboard_top(_auth: None = Depends(_require_api_key)) -> dict[str, An @app.get("/api/v1/governance/compliance-report") -async def compliance_report(_auth: None = Depends(_require_api_key)) -> dict[str, Any]: +async def compliance_report( + _auth: None = Depends(_require_api_key), + format: str = Query("json", alias="format"), + include_allowed: bool = Query(False), +): + if format == "csv": + from fastapi.responses import PlainTextResponse + + csv_content = _compliance_reporter.generate_csv(include_allowed=include_allowed) + return PlainTextResponse(csv_content, media_type="text/csv") return _compliance_reporter.generate().to_dict() @@ -1305,10 +1316,7 @@ async def eu_ai_act_report(_auth: None = Depends(_require_api_key)) -> dict[str, TechnicalDocumentation, ) - # Derive the report from live telemetry and the active safety policy rather - # than static literals, so the conformity evidence reflects the running - # system (Article 15 record-keeping / accuracy-robustness requirements). - safety = _safety_engine.stats() # {"checked", "blocked", "approved"} + safety = _safety_engine.stats() policy = _safety_engine.policy sessions = _collector.list_sessions(limit=200) checked = safety["checked"] @@ -1350,7 +1358,6 @@ async def eu_ai_act_report(_auth: None = Depends(_require_api_key)) -> dict[str, pkg = EUAIActPackage() pkg.set_documentation(doc) - # Record-keeping evidence: derive decision-log entries from real sessions. sessions_used = sessions[:50] for session in sessions_used: pkg.log_decision( @@ -1374,6 +1381,18 @@ async def eu_ai_act_report(_auth: None = Depends(_require_api_key)) -> dict[str, } +@app.get("/api/v1/compliance/audit-log") +async def compliance_audit_log_csv( + _auth: None = Depends(_require_api_key), + format: str = Query("csv", alias="format"), + include_allowed: bool = Query(False), +): + from fastapi.responses import PlainTextResponse + + csv_content = _compliance_reporter.generate_csv(include_allowed=include_allowed) + return PlainTextResponse(csv_content, media_type="text/csv") + + @app.post("/api/v1/demo/seed") async def seed_demo(_auth: None = Depends(_require_api_key)) -> dict[str, Any]: _seed_demo_data() diff --git a/agentwatch/cli/main.py b/agentwatch/cli/main.py index 99eff12f..88dcca4f 100644 --- a/agentwatch/cli/main.py +++ b/agentwatch/cli/main.py @@ -7,12 +7,13 @@ import asyncio import json +import os import platform import sys import time from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn import typer from rich import box @@ -108,7 +109,6 @@ def main_callback( def _start_repl_session(): """Run an interactive REPL shell for the CLI.""" - import os import shlex from rich.panel import Panel @@ -143,9 +143,7 @@ def _start_repl_session(): break if cmd_lower in ("clear", "cls"): - os.system( # noqa: S605, S607 - "cls" if os.name == "nt" else "clear" - ) # nosec + console.clear() continue args = shlex.split(cmd_line) @@ -876,7 +874,6 @@ def top( async def _run() -> None: try: import asyncio - from typing import Any import httpx from rich.live import Live @@ -1862,6 +1859,108 @@ async def _run() -> None: asyncio.run(_run()) +# ───────────────────────────────────────────── +# compliance commands +# --------------------------------------------- + + +compliance_app = typer.Typer( + name="compliance", + help="Compliance audit log export and reporting.", + no_args_is_help=True, +) +app.add_typer(compliance_app) + + +@compliance_app.command(name="export-csv") +def compliance_export_csv( + output: Path = typer.Option( + Path("compliance-audit.csv"), "--output", "-o", help="Output CSV file path" + ), + include_allowed: bool = typer.Option( + False, + "--include-allowed", + help="Include allowed actions (denials only by default)", + ), + api_url: str = typer.Option("http://localhost:8000", "--api"), + api_key: str | None = API_KEY_OPTION, +) -> None: + """Export compliance audit log as CSV from the running API server.""" + + async def _run() -> None: + try: + import httpx + except ImportError: + console.print("[red]httpx not installed. Run: pip install httpx[/red]") + raise typer.Exit(1) + + async with httpx.AsyncClient() as client: + try: + params: dict[str, Any] = {"format": "csv"} + if include_allowed: + params["include_allowed"] = "true" + resp = await client.get( + f"{api_url}/api/v1/governance/compliance-report", + headers=_api_headers(api_key), + params=params, + timeout=10.0, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + _handle_http_status_error(exc, api_url) + except httpx.HTTPError as exc: + console.print(f"[red]Failed to connect to API at {api_url}: {exc}[/red]") + raise typer.Exit(1) + + with open(output, "w", encoding="utf-8", newline="") as f: + f.write(resp.text) + console.print(f"[green]{output} created successfully[/green]") + + asyncio.run(_run()) + + +_DEFAULT_AUDIT_LOG_PATH = Path(os.getenv("AGENTWATCH_AUDIT_LOG_PATH", "data/audit-log.jsonl")) + + +@compliance_app.command(name="export-local") +def compliance_export_local( + output: Path = typer.Option( + Path("compliance-audit.csv"), "--output", "-o", help="Output CSV file path" + ), + include_allowed: bool = typer.Option( + False, + "--include-allowed", + help="Include allowed actions (denials only by default)", + ), + audit_log: Path = typer.Option( + _DEFAULT_AUDIT_LOG_PATH, + "--audit-log", + help="Path to the persisted audit log JSONL file", + ), +) -> None: + """Export compliance audit log from a local JSONL file as CSV.""" + from agentwatch.governance.compliance_reporter import ComplianceReporter + from agentwatch.governance.engine import GovernanceEngine + + engine = GovernanceEngine(audit_log_path=audit_log) + loaded = len(engine._audit_log) + if loaded == 0: + console.print( + f"[yellow]No audit entries found in {audit_log}. " + "The server persists audit data when AGENTWATCH_AUDIT_LOG_PATH is set.[/yellow]" + ) + return + + reporter = ComplianceReporter(engine) + csv_content = reporter.generate_csv(include_allowed=include_allowed) + + with open(output, "w", encoding="utf-8", newline="") as f: + f.write(csv_content) + # Count actual exported rows (subtract 1 for header) + exported = max(0, csv_content.count("\n") - 1) + console.print(f"[green]{output} created successfully ({exported} entries exported)[/green]") + + # ───────────────────────────────────────────── # Version # --------------------------------------------- diff --git a/agentwatch/governance/compliance_reporter.py b/agentwatch/governance/compliance_reporter.py index 111aeda0..67b0d9ee 100644 --- a/agentwatch/governance/compliance_reporter.py +++ b/agentwatch/governance/compliance_reporter.py @@ -2,6 +2,8 @@ from __future__ import annotations +import csv +import io from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -28,6 +30,38 @@ def to_dict(self) -> dict[str, Any]: "findings": self.findings, } + def to_csv(self) -> str: + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow( + [ + "audit_id", + "timestamp", + "principal_id", + "event_type", + "resource", + "action", + "allowed", + "session_id", + "details", + ] + ) + for entry in self.findings.get("sample_denials", []): + writer.writerow( + [ + entry.get("audit_id", ""), + entry.get("timestamp", ""), + entry.get("principal_id", ""), + entry.get("event_type", ""), + entry.get("resource", ""), + entry.get("action", ""), + entry.get("allowed", ""), + entry.get("session_id", ""), + entry.get("details", ""), + ] + ) + return buf.getvalue() + class ComplianceReporter: def __init__(self, governance: GovernanceEngine, collector: TraceCollector | None = None): @@ -56,3 +90,40 @@ def generate(self) -> ComplianceReport: summary=summary, findings=findings, ) + + def generate_csv(self, *, include_allowed: bool = False) -> str: + total = len(self._governance._audit_log) + audit_log = self._governance.get_audit_log(limit=total) + if not include_allowed: + audit_log = [entry for entry in audit_log if not entry.allowed] + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow( + [ + "audit_id", + "timestamp", + "principal_id", + "event_type", + "resource", + "action", + "allowed", + "session_id", + "details", + ] + ) + for entry in audit_log: + writer.writerow( + [ + entry.audit_id, + entry.timestamp.isoformat(), + entry.principal_id or "", + entry.event_type.value, + entry.resource, + entry.action, + entry.allowed, + entry.session_id or "", + entry.details, + ] + ) + return buf.getvalue() diff --git a/agentwatch/governance/engine.py b/agentwatch/governance/engine.py index 90ad8911..5fd71e82 100644 --- a/agentwatch/governance/engine.py +++ b/agentwatch/governance/engine.py @@ -178,6 +178,47 @@ def __init__( self._audit_path = audit_log_path self._entry_counter = 0 + if self._audit_path: + self.load_from_disk() + + def load_from_disk(self) -> int: + """Load persisted audit entries from the JSONL file into memory. + + Returns the number of entries loaded. + """ + if not self._audit_path or not self._audit_path.exists(): + return 0 + + count = 0 + try: + with open(self._audit_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + entry = AuditEntry( + audit_id=data["audit_id"], + timestamp=datetime.fromisoformat(data["timestamp"]), + principal_id=data.get("principal_id"), + event_type=AuditEventType(data["event_type"]), + resource=data["resource"], + action=data["action"], + allowed=data["allowed"], + session_id=data.get("session_id"), + details=data.get("details", {}), + ) + self._audit_log.append(entry) + count += 1 + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: + logger.warning("Skipping corrupt audit entry: %s", exc) + except OSError as exc: + logger.warning("Failed to read audit log from %s: %s", self._audit_path, exc) + + logger.info("Loaded %d audit entries from %s", count, self._audit_path) + return count + def register_principal(self, principal: Principal) -> None: self._principals[principal.principal_id] = principal logger.debug("Registered principal: %s (%s)", principal.name, principal.roles) diff --git a/tests/test_compliance.py b/tests/test_compliance.py index 5c142821..5341a11e 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -2,9 +2,18 @@ from __future__ import annotations +import csv +import io from datetime import UTC, datetime from agentwatch.governance.causal import AdverseOutcome, attribute +from agentwatch.governance.compliance_reporter import ComplianceReporter +from agentwatch.governance.engine import ( + AuditEventType, + GovernanceEngine, + Permission, + Principal, +) from agentwatch.governance.eu_ai_act import ( EUAIActPackage, TechnicalDocumentation, @@ -229,3 +238,78 @@ def test_iso42001_report_counts(): rep = ams.report() assert rep.metrics["open_incidents"] == 1 assert rep.metrics["high_risks"] == 1 + + +# ───────────────────────────────────────────── +# CMP-010 — CSV Export (Issue #148) +# ───────────────────────────────────────────── + + +def _make_engine_with_entries() -> GovernanceEngine: + engine = GovernanceEngine() + viewer = Principal(principal_id="u1", name="Viewer", roles=["viewer"]) + admin = Principal(principal_id="u2", name="Admin", roles=["admin"]) + engine.register_principal(viewer) + engine.register_principal(admin) + engine.check_permission("u1", Permission.SAFETY_OVERRIDE, "policy") + engine.check_permission("u1", Permission.ADMIN_ALL, "config") + engine.record_action( + "u2", AuditEventType.CONFIG_CHANGE, "config", "updated", allowed=True + ) + return engine + + +def test_compliance_report_to_csv_has_header(): + engine = _make_engine_with_entries() + reporter = ComplianceReporter(engine) + report = reporter.generate() + csv_output = report.to_csv() + reader = csv.reader(io.StringIO(csv_output)) + header = next(reader) + assert "audit_id" in header + assert "event_type" in header + assert "allowed" in header + + +def test_compliance_report_to_csv_contains_denials(): + engine = _make_engine_with_entries() + reporter = ComplianceReporter(engine) + report = reporter.generate() + csv_output = report.to_csv() + assert "permission_denied" in csv_output + + +def test_compliance_report_to_csv_empty_when_no_denials(): + engine = GovernanceEngine() + engine.register_principal(Principal(principal_id="a", name="A", roles=["admin"])) + engine.record_action( + "a", AuditEventType.CONFIG_CHANGE, "c", "u", allowed=True + ) + reporter = ComplianceReporter(engine) + report = reporter.generate() + csv_output = report.to_csv() + reader = csv.reader(io.StringIO(csv_output)) + rows = list(reader) + assert len(rows) == 1 + + +def test_compliance_reporter_generate_csv_denials_only(): + engine = _make_engine_with_entries() + reporter = ComplianceReporter(engine) + csv_output = reporter.generate_csv(include_allowed=False) + reader = csv.reader(io.StringIO(csv_output)) + rows = list(reader) + assert len(rows) >= 2 + for row in rows[1:]: + assert "permission_denied" in row[3] or "safety_override" in row[3] + + +def test_compliance_reporter_generate_csv_includes_allowed(): + engine = _make_engine_with_entries() + reporter = ComplianceReporter(engine) + csv_output = reporter.generate_csv(include_allowed=True) + reader = csv.reader(io.StringIO(csv_output)) + rows = list(reader) + assert len(rows) >= 3 + actions = [row[5] for row in rows[1:]] + assert "updated" in actions