From 0c0dd4e3a69862436db90cd94283f66d9f6df270 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 27 Apr 2026 23:27:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ZeroClaw=20compliance=20agent=20?= =?UTF-8?q?=E2=80=94=20document=20analysis=20+=20role=20assignment=20+=20e?= =?UTF-8?q?mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add autonomous compliance agent that fetches web documents (cookie banners, privacy policies), classifies them via Qwen/Ollama, assesses DSGVO compliance, assigns to the responsible role, and sends notification emails. Components: - ZeroClaw SOP (6-step workflow: fetch, classify, assess, summarize, assign, notify) - Backend: /api/compliance/agent/analyze (combined endpoint) - Backend: /api/compliance/agent/notify (standalone email) - Frontend: /sdk/agent page (Manager UI with URL input + results) - Helper scripts + E2E test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/api/sdk/v1/agent/analyze/route.ts | 42 +++++ .../sdk/agent/_components/AnalysisHistory.tsx | 57 ++++++ .../sdk/agent/_components/AnalysisResult.tsx | 109 +++++++++++ .../app/sdk/agent/_hooks/useAgentAnalysis.ts | 80 ++++++++ admin-compliance/app/sdk/agent/page.tsx | 83 +++++++++ .../compliance/api/agent_analyze_routes.py | 173 ++++++++++++++++++ .../api/agent_notification_routes.py | 77 ++++++++ .../compliance/services/smtp_sender.py | 49 +++++ backend-compliance/main.py | 8 + .../tests/test_agent_notification_routes.py | 83 +++++++++ zeroclaw/README.md | 56 ++++++ zeroclaw/scripts/fetch-and-analyze.sh | 34 ++++ zeroclaw/scripts/send-notification.sh | 35 ++++ zeroclaw/sops/compliance-analyst/SOP.md | 98 ++++++++++ zeroclaw/sops/compliance-analyst/SOP.toml | 15 ++ zeroclaw/tests/test_sop_workflow.sh | 96 ++++++++++ 16 files changed, 1095 insertions(+) create mode 100644 admin-compliance/app/api/sdk/v1/agent/analyze/route.ts create mode 100644 admin-compliance/app/sdk/agent/_components/AnalysisHistory.tsx create mode 100644 admin-compliance/app/sdk/agent/_components/AnalysisResult.tsx create mode 100644 admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts create mode 100644 admin-compliance/app/sdk/agent/page.tsx create mode 100644 backend-compliance/compliance/api/agent_analyze_routes.py create mode 100644 backend-compliance/compliance/api/agent_notification_routes.py create mode 100644 backend-compliance/compliance/services/smtp_sender.py create mode 100644 backend-compliance/tests/test_agent_notification_routes.py create mode 100644 zeroclaw/README.md create mode 100755 zeroclaw/scripts/fetch-and-analyze.sh create mode 100755 zeroclaw/scripts/send-notification.sh create mode 100644 zeroclaw/sops/compliance-analyst/SOP.md create mode 100644 zeroclaw/sops/compliance-analyst/SOP.toml create mode 100755 zeroclaw/tests/test_sop_workflow.sh diff --git a/admin-compliance/app/api/sdk/v1/agent/analyze/route.ts b/admin-compliance/app/api/sdk/v1/agent/analyze/route.ts new file mode 100644 index 0000000..df93aac --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/analyze/route.ts @@ -0,0 +1,42 @@ +/** + * Agent Analyze API Proxy + * POST /api/sdk/v1/agent/analyze → backend-compliance /api/compliance/agent/analyze + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002' + +export async function POST(request: NextRequest) { + try { + const body = await request.text() + + const response = await fetch(`${BACKEND_URL}/api/compliance/agent/analyze`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', + 'X-User-Id': '00000000-0000-0000-0000-000000000001', + }, + body, + signal: AbortSignal.timeout(120000), // 2 min — LLM can be slow + }) + + if (!response.ok) { + const errorText = await response.text() + return NextResponse.json( + { error: `Backend: ${response.status}`, detail: errorText }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Agent analyze proxy error:', error) + return NextResponse.json( + { error: 'Verbindung zum Backend fehlgeschlagen' }, + { status: 503 } + ) + } +} diff --git a/admin-compliance/app/sdk/agent/_components/AnalysisHistory.tsx b/admin-compliance/app/sdk/agent/_components/AnalysisHistory.tsx new file mode 100644 index 0000000..b945126 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/AnalysisHistory.tsx @@ -0,0 +1,57 @@ +'use client' + +import React from 'react' +import type { AnalysisResult } from '../_hooks/useAgentAnalysis' + +const DOC_TYPE_LABELS: Record = { + privacy_policy: 'DSE', + cookie_banner: 'Cookie', + terms_of_service: 'AGB', + imprint: 'Impressum', + dpa: 'AVV', + other: 'Sonstig', +} + +const RISK_DOT: Record = { + low: 'bg-green-500', + medium: 'bg-yellow-500', + high: 'bg-orange-500', + critical: 'bg-red-500', +} + +interface Props { + history: AnalysisResult[] + onSelect: (result: AnalysisResult) => void +} + +export function AnalysisHistory({ history, onSelect }: Props) { + if (history.length === 0) return null + + return ( +
+

Letzte Analysen

+
+ {history.map((item, i) => ( + + ))} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/AnalysisResult.tsx b/admin-compliance/app/sdk/agent/_components/AnalysisResult.tsx new file mode 100644 index 0000000..73257ed --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/AnalysisResult.tsx @@ -0,0 +1,109 @@ +'use client' + +import React from 'react' +import type { AnalysisResult as AnalysisResultType } from '../_hooks/useAgentAnalysis' + +const RISK_COLORS: Record = { + low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Niedrig' }, + medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Mittel' }, + high: { bg: 'bg-orange-100', text: 'text-orange-800', label: 'Hoch' }, + critical: { bg: 'bg-red-100', text: 'text-red-800', label: 'Kritisch' }, + unknown: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unbekannt' }, +} + +const DOC_TYPE_LABELS: Record = { + privacy_policy: 'Datenschutzerklaerung', + cookie_banner: 'Cookie-Banner', + terms_of_service: 'AGB', + imprint: 'Impressum', + dpa: 'Auftragsverarbeitung (AVV)', + other: 'Sonstiges', +} + +interface Props { + result: AnalysisResultType +} + +export function AnalysisResult({ result }: Props) { + const risk = RISK_COLORS[result.risk_level] || RISK_COLORS.unknown + + return ( +
+ {/* Header */} +
+
+

+ {DOC_TYPE_LABELS[result.classification] || result.classification} +

+

{result.url}

+
+ + {risk.label} ({result.risk_score}/100) + +
+ + {/* Role Assignment */} +
+
+ + + + + Zugewiesen an: {result.responsible_role} + + + Eskalationsstufe {result.escalation_level} + +
+
+ + {/* Summary */} + {result.summary && ( +
+

Zusammenfassung

+

{result.summary}

+
+ )} + + {/* Findings */} + {result.findings.length > 0 && ( +
+

Findings ({result.findings.length})

+
    + {result.findings.map((f, i) => ( +
  • + ! + {f} +
  • + ))} +
+
+ )} + + {/* Required Controls */} + {result.required_controls.length > 0 && ( +
+

Erforderliche Massnahmen

+
    + {result.required_controls.map((c, i) => ( +
  • + + {c} +
  • + ))} +
+
+ )} + + {/* Email Status */} +
+ + {result.email_status === 'sent' ? '✉ Email gesendet' : '✉ Email ausstehend'} + + + {new Date(result.analyzed_at).toLocaleString('de-DE')} + +
+
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts b/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts new file mode 100644 index 0000000..a9a3a19 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_hooks/useAgentAnalysis.ts @@ -0,0 +1,80 @@ +'use client' + +import { useState } from 'react' + +export interface AnalysisResult { + url: string + classification: string + risk_level: string + risk_score: number + escalation_level: string + responsible_role: string + findings: string[] + required_controls: string[] + summary: string + email_status: string + analyzed_at: string +} + +const ESCALATION_ROLES: Record = { + E0: 'Kein Handlungsbedarf', + E1: 'Teamleitung Datenschutz', + E2: 'Datenschutzbeauftragter (DSB)', + E3: 'DSB + Rechtsabteilung', +} + +const SDK_HEADERS = { + 'Content-Type': 'application/json', + 'X-Tenant-ID': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', + 'X-User-ID': '00000000-0000-0000-0000-000000000001', +} + +export function useAgentAnalysis() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const [history, setHistory] = useState([]) + + async function analyze(url: string) { + setLoading(true) + setError(null) + setResult(null) + + try { + // Step 1: Fetch and classify + const fetchRes = await fetch('/api/sdk/v1/agent/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }) + + if (!fetchRes.ok) { + throw new Error(`Analyse fehlgeschlagen: ${fetchRes.status}`) + } + + const data = await fetchRes.json() + const analysisResult: AnalysisResult = { + url, + classification: data.classification || 'unknown', + risk_level: data.risk_level || 'unknown', + risk_score: data.risk_score || 0, + escalation_level: data.escalation_level || 'E0', + responsible_role: ESCALATION_ROLES[data.escalation_level] || ESCALATION_ROLES.E0, + findings: data.findings || [], + required_controls: data.required_controls || [], + summary: data.summary || '', + email_status: data.email_status || 'pending', + analyzed_at: new Date().toISOString(), + } + + setResult(analysisResult) + setHistory(prev => [analysisResult, ...prev].slice(0, 20)) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unbekannter Fehler') + } finally { + setLoading(false) + } + } + + return { analyze, loading, error, result, history } +} diff --git a/admin-compliance/app/sdk/agent/page.tsx b/admin-compliance/app/sdk/agent/page.tsx new file mode 100644 index 0000000..c29fc6c --- /dev/null +++ b/admin-compliance/app/sdk/agent/page.tsx @@ -0,0 +1,83 @@ +'use client' + +import React, { useState } from 'react' +import { useAgentAnalysis } from './_hooks/useAgentAnalysis' +import { AnalysisResult } from './_components/AnalysisResult' +import { AnalysisHistory } from './_components/AnalysisHistory' + +export default function AgentPage() { + const [url, setUrl] = useState('') + const { analyze, loading, error, result, history } = useAgentAnalysis() + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!url.trim()) return + analyze(url.trim()) + } + + return ( +
+ {/* Header */} +
+

Compliance Agent

+

+ Analysiere Webseiten auf DSGVO-Konformitaet. Der Agent holt das Dokument, + klassifiziert es, bewertet das Risiko und weist die Aufgabe der zustaendigen Rolle zu. +

+
+ + {/* URL Input */} +
+ setUrl(e.target.value)} + placeholder="https://example.com/datenschutz" + className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" + disabled={loading} + required + /> + +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Result */} + {result && ( +
+ +
+ )} + + {/* History */} + { + setUrl(r.url) + analyze(r.url) + }} + /> +
+ ) +} diff --git a/backend-compliance/compliance/api/agent_analyze_routes.py b/backend-compliance/compliance/api/agent_analyze_routes.py new file mode 100644 index 0000000..b0f20c7 --- /dev/null +++ b/backend-compliance/compliance/api/agent_analyze_routes.py @@ -0,0 +1,173 @@ +""" +Agent Analyze Routes — combined endpoint that fetches a URL, classifies it, +assesses DSGVO compliance, and sends a notification email. + +POST /api/compliance/agent/analyze +""" + +import logging +import re +import os +from datetime import datetime, timezone + +import httpx +from fastapi import APIRouter +from pydantic import BaseModel + +from compliance.services.smtp_sender import send_email + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/compliance/agent", tags=["agent"]) + +SDK_URL = os.environ.get("AI_SDK_URL", "http://ai-compliance-sdk:8093") +TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" +USER_ID = "00000000-0000-0000-0000-000000000001" + +ESCALATION_ROLES = { + "E0": "Kein Handlungsbedarf", + "E1": "Teamleitung Datenschutz", + "E2": "Datenschutzbeauftragter (DSB)", + "E3": "DSB + Rechtsabteilung", +} + +SDK_HEADERS = { + "Content-Type": "application/json", + "X-Tenant-ID": TENANT_ID, + "X-User-ID": USER_ID, +} + + +class AnalyzeRequest(BaseModel): + url: str + recipient: str = "dsb@breakpilot.local" + + +class AnalyzeResponse(BaseModel): + url: str + classification: str + risk_level: str + risk_score: float + escalation_level: str + responsible_role: str + findings: list[str] + required_controls: list[str] + summary: str + email_status: str + analyzed_at: str + + +@router.post("/analyze", response_model=AnalyzeResponse) +async def analyze_url(req: AnalyzeRequest): + """Fetch URL, classify, assess compliance, and notify responsible role.""" + async with httpx.AsyncClient(timeout=60.0) as client: + # Step 1: Fetch and clean + text = await _fetch_and_clean(client, req.url) + + # Step 2: Classify via SDK LLM + classification = await _classify(client, text) + + # Step 3: Assess via UCCA + assessment = await _assess(client, text, classification) + + # Step 4: Determine role + esc_level = assessment.get("escalation_level", "E0") + role = ESCALATION_ROLES.get(esc_level, ESCALATION_ROLES["E0"]) + + # Step 5: Build summary + findings = assessment.get("triggered_rules", []) + controls = assessment.get("required_controls", []) + summary = _build_summary(req.url, classification, assessment, role) + + # Step 6: Send notification + email_result = send_email( + recipient=req.recipient, + subject=f"Compliance-Finding: {classification} — {req.url[:60]}", + body_html=f"
{summary}
", + ) + + return AnalyzeResponse( + url=req.url, + classification=classification, + risk_level=assessment.get("risk_level", "unknown"), + risk_score=assessment.get("risk_score", 0), + escalation_level=esc_level, + responsible_role=role, + findings=findings if isinstance(findings, list) else [str(findings)], + required_controls=controls if isinstance(controls, list) else [str(controls)], + summary=summary, + email_status=email_result.get("status", "failed"), + analyzed_at=datetime.now(timezone.utc).isoformat(), + ) + + +async def _fetch_and_clean(client: httpx.AsyncClient, url: str) -> str: + """Fetch URL and strip HTML to plain text.""" + resp = await client.get(url, follow_redirects=True, headers={ + "User-Agent": "BreakPilot-Compliance-Agent/1.0", + }) + html = resp.text + # Strip script/style blocks, then all tags + clean = re.sub(r"<(script|style)[^>]*>.*?", "", html, flags=re.DOTALL | re.IGNORECASE) + clean = re.sub(r"<[^>]+>", " ", clean) + clean = re.sub(r" ", " ", clean) + clean = re.sub(r"\s+", " ", clean).strip() + return clean[:4000] + + +async def _classify(client: httpx.AsyncClient, text: str) -> str: + """Classify document type via SDK LLM chat.""" + try: + resp = await client.post(f"{SDK_URL}/sdk/v1/llm/chat", headers=SDK_HEADERS, json={ + "messages": [ + {"role": "system", "content": ( + "Klassifiziere das Dokument in GENAU EINE Kategorie: " + "privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. " + "Antworte NUR mit dem Kategorienamen, nichts anderes." + )}, + {"role": "user", "content": text[:2000]}, + ], + }) + data = resp.json() + raw = data.get("response", data.get("content", "other")).strip().lower() + for cat in ["privacy_policy", "cookie_banner", "terms_of_service", "imprint", "dpa"]: + if cat in raw: + return cat + return "other" + except Exception as e: + logger.warning("Classification failed: %s", e) + return "other" + + +async def _assess(client: httpx.AsyncClient, text: str, classification: str) -> dict: + """Run UCCA assessment via SDK.""" + try: + resp = await client.post(f"{SDK_URL}/sdk/v1/ucca/assess", headers=SDK_HEADERS, json={ + "use_case_text": text[:3000], + "domain": classification, + "data_categories": ["personal_data", "tracking", "cookies", "third_party_sharing"], + }) + return resp.json() + except Exception as e: + logger.warning("Assessment failed: %s", e) + return {"risk_level": "unknown", "risk_score": 0, "escalation_level": "E0"} + + +def _build_summary(url: str, classification: str, assessment: dict, role: str) -> str: + """Build a German manager summary.""" + risk = assessment.get("risk_level", "unbekannt") + score = assessment.get("risk_score", 0) + findings = assessment.get("triggered_rules", []) + controls = assessment.get("required_controls", []) + + findings_text = "\n".join(f"- {f}" for f in findings[:5]) if findings else "Keine" + controls_text = "\n".join(f"- {c}" for c in controls[:5]) if controls else "Keine" + + return ( + f"Dokumenttyp: {classification}\n" + f"Quelle: {url}\n" + f"Risikobewertung: {risk} ({score}/100)\n" + f"Zustaendig: {role}\n\n" + f"Findings:\n{findings_text}\n\n" + f"Erforderliche Massnahmen:\n{controls_text}" + ) diff --git a/backend-compliance/compliance/api/agent_notification_routes.py b/backend-compliance/compliance/api/agent_notification_routes.py new file mode 100644 index 0000000..9a1d0c2 --- /dev/null +++ b/backend-compliance/compliance/api/agent_notification_routes.py @@ -0,0 +1,77 @@ +""" +Agent Notification Routes — endpoint for the ZeroClaw compliance agent +to send notification emails via SMTP. + +POST /api/compliance/agent/notify +""" + +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter +from pydantic import BaseModel, EmailStr + +from compliance.services.smtp_sender import send_email + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/compliance/agent", tags=["agent"]) + + +class NotifyRequest(BaseModel): + recipient: EmailStr + subject: str + body_html: str + role: str + escalation_id: str | None = None + + +class NotifyResponse(BaseModel): + status: str + recipient: str + subject: str + role: str + sent_at: str + error: str | None = None + + +@router.post("/notify", response_model=NotifyResponse) +async def send_agent_notification(req: NotifyRequest): + """Send a compliance notification email on behalf of the agent.""" + result = send_email( + recipient=req.recipient, + subject=req.subject, + body_html=_build_email_body(req), + ) + + return NotifyResponse( + status=result["status"], + recipient=req.recipient, + subject=req.subject, + role=req.role, + sent_at=datetime.now(timezone.utc).isoformat(), + error=result.get("error"), + ) + + +def _build_email_body(req: NotifyRequest) -> str: + """Wrap the agent's HTML body with a standard email frame.""" + return f""" +
+
+

BreakPilot Compliance Agent

+
+
+

+ Zugewiesen an: {req.role} + {f' | Eskalation: {req.escalation_id}' if req.escalation_id else ''} +

+ {req.body_html} +
+
+

+ Automatisch generiert vom BreakPilot Compliance Agent (ZeroClaw + Qwen) +

+
+
+ """ diff --git a/backend-compliance/compliance/services/smtp_sender.py b/backend-compliance/compliance/services/smtp_sender.py new file mode 100644 index 0000000..973c096 --- /dev/null +++ b/backend-compliance/compliance/services/smtp_sender.py @@ -0,0 +1,49 @@ +""" +SMTP Sender — sends real emails via SMTP (e.g., to Mailpit for dev). + +Uses standard smtplib. Configuration via environment variables: + SMTP_HOST (default: localhost) + SMTP_PORT (default: 1025) + SMTP_FROM_NAME (default: BreakPilot Compliance) + SMTP_FROM_ADDR (default: compliance@breakpilot.local) +""" + +import logging +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +logger = logging.getLogger(__name__) + +SMTP_HOST = os.environ.get("SMTP_HOST", "localhost") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "1025")) +SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "BreakPilot Compliance") +SMTP_FROM_ADDR = os.environ.get("SMTP_FROM_ADDR", "compliance@breakpilot.local") + + +def send_email( + recipient: str, + subject: str, + body_html: str, + from_addr: str | None = None, + from_name: str | None = None, +) -> dict: + """Send an email via SMTP. Returns dict with status and message_id.""" + sender_addr = from_addr or SMTP_FROM_ADDR + sender_name = from_name or SMTP_FROM_NAME + + msg = MIMEMultipart("alternative") + msg["From"] = f"{sender_name} <{sender_addr}>" + msg["To"] = recipient + msg["Subject"] = subject + msg.attach(MIMEText(body_html, "html", "utf-8")) + + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server: + server.sendmail(sender_addr, [recipient], msg.as_string()) + logger.info("Email sent to %s: %s", recipient, subject) + return {"status": "sent", "recipient": recipient, "subject": subject} + except Exception as e: + logger.error("Failed to send email to %s: %s", recipient, e) + return {"status": "failed", "recipient": recipient, "error": str(e)} diff --git a/backend-compliance/main.py b/backend-compliance/main.py index 816c4e2..792c78a 100644 --- a/backend-compliance/main.py +++ b/backend-compliance/main.py @@ -41,6 +41,10 @@ from compliance.api.screening_routes import router as screening_router # Company Profile from compliance.api.company_profile_routes import router as company_profile_router +# Agent (ZeroClaw compliance agent) +from compliance.api.agent_notification_routes import router as agent_notify_router +from compliance.api.agent_analyze_routes import router as agent_analyze_router + # Middleware from middleware import ( RequestIDMiddleware, @@ -135,6 +139,10 @@ app.include_router(screening_router, prefix="/api") # Company Profile (CRUD with audit logging) app.include_router(company_profile_router, prefix="/api") +# Agent (ZeroClaw compliance agent → analyze + email via SMTP) +app.include_router(agent_notify_router, prefix="/api") +app.include_router(agent_analyze_router, prefix="/api") + if __name__ == "__main__": import uvicorn diff --git a/backend-compliance/tests/test_agent_notification_routes.py b/backend-compliance/tests/test_agent_notification_routes.py new file mode 100644 index 0000000..2f2226d --- /dev/null +++ b/backend-compliance/tests/test_agent_notification_routes.py @@ -0,0 +1,83 @@ +"""Tests for agent notification endpoint.""" + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + from main import app + return TestClient(app) + + +class TestAgentNotify: + """Tests for POST /api/compliance/agent/notify.""" + + @patch("compliance.services.smtp_sender.smtplib.SMTP") + def test_send_notification_success(self, mock_smtp, client): + mock_instance = mock_smtp.return_value.__enter__.return_value + + resp = client.post("/api/compliance/agent/notify", json={ + "recipient": "dsb@firma.de", + "subject": "Test Finding", + "body_html": "

Test body

", + "role": "Datenschutzbeauftragter", + }) + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "sent" + assert data["recipient"] == "dsb@firma.de" + assert data["role"] == "Datenschutzbeauftragter" + assert data["sent_at"] is not None + mock_instance.sendmail.assert_called_once() + + @patch("compliance.services.smtp_sender.smtplib.SMTP") + def test_send_notification_with_escalation(self, mock_smtp, client): + mock_smtp.return_value.__enter__.return_value + + resp = client.post("/api/compliance/agent/notify", json={ + "recipient": "legal@firma.de", + "subject": "Escalation E3", + "body_html": "

Urgent

", + "role": "DSB + Rechtsabteilung", + "escalation_id": "esc-123", + }) + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "sent" + assert data["role"] == "DSB + Rechtsabteilung" + + def test_send_notification_invalid_email(self, client): + resp = client.post("/api/compliance/agent/notify", json={ + "recipient": "not-an-email", + "subject": "Test", + "body_html": "

Test

", + "role": "DSB", + }) + assert resp.status_code == 422 + + def test_send_notification_missing_fields(self, client): + resp = client.post("/api/compliance/agent/notify", json={ + "recipient": "dsb@firma.de", + }) + assert resp.status_code == 422 + + @patch("compliance.services.smtp_sender.smtplib.SMTP") + def test_send_notification_smtp_failure(self, mock_smtp, client): + mock_smtp.return_value.__enter__.side_effect = ConnectionRefusedError("SMTP down") + + resp = client.post("/api/compliance/agent/notify", json={ + "recipient": "dsb@firma.de", + "subject": "Test", + "body_html": "

Test

", + "role": "DSB", + }) + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "failed" + assert "SMTP down" in data["error"] diff --git a/zeroclaw/README.md b/zeroclaw/README.md new file mode 100644 index 0000000..d552560 --- /dev/null +++ b/zeroclaw/README.md @@ -0,0 +1,56 @@ +# ZeroClaw Compliance Agent Demo + +Autonomer Compliance-Agent der Web-Dokumente (Cookie-Banner, Datenschutzerklaerungen) analysiert und die Ergebnisse an die zustaendige Rolle weiterleitet. + +## Architektur + +``` +ZeroClaw Agent (Rust, Mac Mini) + │ + ├── LLM: Qwen 3.5:35b-a3b (Ollama, localhost:11434) + │ + ├── Compliance SDK (Go/Gin, localhost:8093) + │ ├── /sdk/v1/llm/chat → Dokumentklassifizierung + │ ├── /sdk/v1/ucca/assess → Risikobewertung + │ └── /sdk/v1/ucca/escalations → Eskalation + Rollenzuweisung + │ + ├── Backend (Python/FastAPI, localhost:8002) + │ └── /api/compliance/agent/notify → Email-Benachrichtigung + │ + └── Mailpit (SMTP localhost:1025, Web localhost:8025) + └── Fiktive Email-Zustellung +``` + +## Voraussetzungen + +- ZeroClaw v0.7.3+ (`brew install zeroclaw`) +- Ollama mit `qwen3.5:35b-a3b` Modell +- Alle Compliance-Services laufen (SDK, Backend, Mailpit) + +## Demo ausfuehren + +```bash +# 1. ZeroClaw mit Ollama verbinden (einmalig) +zeroclaw onboard --quick --provider ollama --model qwen3.5:35b-a3b + +# 2. SOP ausfuehren +zeroclaw agent -m "Analysiere die Datenschutzerklaerung von https://www.google.com/intl/de/policies/privacy/" + +# 3. Ergebnis pruefen +open http://localhost:8025 # Mailpit Web-UI +``` + +## E2E Test + +```bash +bash zeroclaw/tests/test_sop_workflow.sh +``` + +## SOP-Workflow (6 Schritte) + +1. **Fetch** — URL holen, HTML strippen +2. **Classify** — Dokumenttyp bestimmen (privacy_policy, cookie_banner, etc.) +3. **Assess** — DSGVO-Risikobewertung via UCCA +4. **Summarize** — Manager-Report auf Deutsch +5. **Assign** — Zustaendige Rolle bestimmen (E0-E3 Mapping) +6. **Notify** — Email an DSB/Teamleitung senden diff --git a/zeroclaw/scripts/fetch-and-analyze.sh b/zeroclaw/scripts/fetch-and-analyze.sh new file mode 100755 index 0000000..b0ddd07 --- /dev/null +++ b/zeroclaw/scripts/fetch-and-analyze.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# fetch-and-analyze.sh — Fetch a URL and extract clean text for compliance analysis. +# +# Usage: bash fetch-and-analyze.sh [max_chars] +# +# Outputs clean text to stdout, truncated to max_chars (default: 4000). + +set -euo pipefail + +URL="${1:?Usage: fetch-and-analyze.sh [max_chars]}" +MAX_CHARS="${2:-4000}" + +# Fetch page with reasonable timeout and user agent +HTML=$(curl -sL --max-time 30 \ + -H "User-Agent: Mozilla/5.0 (compatible; BreakPilot-Compliance-Agent/1.0)" \ + "$URL" 2>/dev/null || echo "") + +if [ -z "$HTML" ]; then + echo "ERROR: Could not fetch $URL" >&2 + exit 1 +fi + +# Strip HTML: remove style/script blocks, then all tags, normalize whitespace +CLEAN=$(echo "$HTML" \ + | sed 's/]*>[^<]*<\/style>//gi' \ + | sed 's/]*>[^<]*<\/script>//gi' \ + | sed 's/<[^>]*>//g' \ + | sed 's/ / /g; s/&/\&/g; s/<//g; s/"/"/g' \ + | tr -s '[:space:]' ' ' \ + | sed 's/^ //; s/ $//') + +# Truncate to max chars +echo "$CLEAN" | head -c "$MAX_CHARS" diff --git a/zeroclaw/scripts/send-notification.sh b/zeroclaw/scripts/send-notification.sh new file mode 100755 index 0000000..a96147d --- /dev/null +++ b/zeroclaw/scripts/send-notification.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# send-notification.sh — Send a notification email via Mailpit SMTP. +# +# Usage: bash send-notification.sh +# +# Uses Mailpit's SMTP on localhost:1025 via Python smtplib (one-liner). + +set -euo pipefail + +RECIPIENT="${1:?Usage: send-notification.sh }" +SUBJECT="${2:?Missing subject}" +BODY="${3:?Missing body text}" + +SMTP_HOST="${SMTP_HOST:-localhost}" +SMTP_PORT="${SMTP_PORT:-1025}" +FROM_ADDR="${SMTP_FROM_ADDR:-compliance-agent@breakpilot.local}" +FROM_NAME="${SMTP_FROM_NAME:-BreakPilot Compliance Agent}" + +python3 -c " +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +msg = MIMEMultipart('alternative') +msg['From'] = '${FROM_NAME} <${FROM_ADDR}>' +msg['To'] = '${RECIPIENT}' +msg['Subject'] = '${SUBJECT}' +msg.attach(MIMEText('''${BODY}''', 'html', 'utf-8')) + +with smtplib.SMTP('${SMTP_HOST}', ${SMTP_PORT}) as server: + server.sendmail('${FROM_ADDR}', '${RECIPIENT}', msg.as_string()) + +print('Email sent to ${RECIPIENT}') +" diff --git a/zeroclaw/sops/compliance-analyst/SOP.md b/zeroclaw/sops/compliance-analyst/SOP.md new file mode 100644 index 0000000..a322fcd --- /dev/null +++ b/zeroclaw/sops/compliance-analyst/SOP.md @@ -0,0 +1,98 @@ +## Context + +Du bist ein Compliance-Analyst-Agent. Du analysierst Web-Dokumente (Cookie-Banner, Datenschutzerklaerungen) auf DSGVO-Konformitaet mithilfe des BreakPilot Compliance SDK. + +### Endpunkte + +- **Compliance SDK:** http://localhost:8093 +- **Backend:** http://localhost:8002 +- **Mailpit SMTP:** localhost:1025 +- **Mailpit Web:** http://localhost:8025 + +### Authentifizierung + +Alle SDK-Anfragen benoetigen diese Header: +- `X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e` +- `X-User-ID: 00000000-0000-0000-0000-000000000001` + +## Steps + +### 1. Fetch Document + +Hole die Ziel-URL und extrahiere den Text: + +```bash +curl -sL "$URL" | sed 's/]*>.*<\/style>//g; s/]*>.*<\/script>//g; s/<[^>]*>//g; s/ / /g; s/&/\&/g; s/ */ /g' | head -c 4000 +``` + +Speichere das Ergebnis als `$DOCUMENT_TEXT`. + +### 2. Classify Document + +Sende den Text an das SDK zur Klassifizierung: + +```bash +curl -s -X POST http://localhost:8093/sdk/v1/llm/chat \ + -H "Content-Type: application/json" \ + -H "X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" \ + -H "X-User-ID: 00000000-0000-0000-0000-000000000001" \ + -d '{ + "messages": [ + {"role": "system", "content": "Klassifiziere das folgende Dokument in GENAU EINE Kategorie: privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. Antworte NUR mit dem Kategorienamen."}, + {"role": "user", "content": "'"$DOCUMENT_TEXT"'"} + ] + }' +``` + +### 3. Analyze Compliance + +Fuehre eine UCCA-Bewertung durch: + +```bash +curl -s -X POST http://localhost:8093/sdk/v1/ucca/assess \ + -H "Content-Type: application/json" \ + -H "X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" \ + -H "X-User-ID: 00000000-0000-0000-0000-000000000001" \ + -d '{ + "use_case_text": "'"$DOCUMENT_TEXT"'", + "domain": "'"$CLASSIFICATION"'", + "data_categories": ["personal_data", "tracking", "cookies", "third_party_sharing"] + }' +``` + +Notiere: `risk_score`, `risk_level`, `escalation_level`, `triggered_rules`, `required_controls`. + +### 4. Prepare Summary + +Erstelle einen Manager-Report auf Deutsch mit: +- **Dokumenttyp:** (aus Schritt 2) +- **Quelle:** (URL) +- **Risikobewertung:** (risk_level + risk_score aus Schritt 3) +- **Wesentliche Findings:** (triggered_rules zusammengefasst) +- **Erforderliche Massnahmen:** (required_controls zusammengefasst) +- **Empfehlung:** (Handlungsempfehlung basierend auf escalation_level) + +### 5. Determine Responsible Role + +Basierend auf dem `escalation_level` aus Schritt 3: +- **E0** → Kein Handlungsbedarf, automatische Compliance +- **E1** → Teamleitung Datenschutz +- **E2** → Datenschutzbeauftragter (DSB) +- **E3** → DSB + Rechtsabteilung (gemeinsame Entscheidung) + +### 6. Send Notification Email + +Sende eine Benachrichtigung an die zustaendige Rolle: + +```bash +curl -s -X POST http://localhost:8002/api/compliance/agent/notify \ + -H "Content-Type: application/json" \ + -d '{ + "recipient": "dsb@breakpilot.local", + "subject": "Compliance-Finding: '"$CLASSIFICATION"' — '"$URL"'", + "body_html": "'"$MANAGER_SUMMARY_HTML"'", + "role": "'"$RESPONSIBLE_ROLE"'" + }' +``` + +Pruefe das Ergebnis in Mailpit: http://localhost:8025 diff --git a/zeroclaw/sops/compliance-analyst/SOP.toml b/zeroclaw/sops/compliance-analyst/SOP.toml new file mode 100644 index 0000000..7d83784 --- /dev/null +++ b/zeroclaw/sops/compliance-analyst/SOP.toml @@ -0,0 +1,15 @@ +[sop] +name = "compliance-analyst" +description = "Fetch a web document (cookie banner, privacy policy), analyze for DSGVO compliance via BreakPilot SDK, assign to responsible role, notify via email" +version = "1.0.0" +priority = "normal" +execution_mode = "supervised" +max_concurrent = 1 +cooldown_secs = 60 + +[[triggers]] +type = "manual" + +[[triggers]] +type = "webhook" +path = "/sop/compliance-analyst" diff --git a/zeroclaw/tests/test_sop_workflow.sh b/zeroclaw/tests/test_sop_workflow.sh new file mode 100755 index 0000000..f6c797d --- /dev/null +++ b/zeroclaw/tests/test_sop_workflow.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# test_sop_workflow.sh — End-to-end test for the compliance-analyst SOP. +# +# Prerequisites: +# - Compliance SDK running on localhost:8093 +# - Backend running on localhost:8002 +# - Ollama running on localhost:11434 with qwen model +# - Mailpit running (SMTP on 1025, Web on 8025) +# - ZeroClaw installed + +set -euo pipefail + +SDK="http://localhost:8093" +BACKEND="http://localhost:8002" +OLLAMA="http://localhost:11434" +MAILPIT="http://localhost:8025" +TENANT="9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" +USER_ID="00000000-0000-0000-0000-000000000001" + +red() { printf '\033[31m✗ %s\033[0m\n' "$*"; } +green() { printf '\033[32m✓ %s\033[0m\n' "$*"; } + +echo "═══ Compliance Agent SOP — E2E Test ═══" +echo "" + +# Step 1: Health checks +echo "── Step 1: Service Health ──" +curl -sf "$SDK/health" >/dev/null && green "SDK healthy" || red "SDK unreachable" +curl -sf "$BACKEND/health" >/dev/null && green "Backend healthy" || red "Backend unreachable" +curl -sf "$OLLAMA/api/tags" >/dev/null && green "Ollama running" || red "Ollama unreachable" + +# Step 2: Test document fetch +echo "" +echo "── Step 2: Document Fetch ──" +TEXT=$(bash "$(dirname "$0")/../scripts/fetch-and-analyze.sh" "https://www.google.com/intl/de/policies/privacy/" 2000) +CHARS=${#TEXT} +if [ "$CHARS" -gt 100 ]; then + green "Fetched $CHARS chars from Google Privacy Policy" +else + red "Fetch returned too little text ($CHARS chars)" + exit 1 +fi + +# Step 3: Test LLM classification +echo "" +echo "── Step 3: LLM Classification ──" +CLASSIFY_RESULT=$(curl -sf -X POST "$SDK/sdk/v1/llm/chat" \ + -H "Content-Type: application/json" \ + -H "X-Tenant-ID: $TENANT" \ + -H "X-User-ID: $USER_ID" \ + -d "{ + \"messages\": [ + {\"role\": \"system\", \"content\": \"Klassifiziere: privacy_policy, cookie_banner, terms_of_service, imprint, dpa, other. Antworte NUR mit dem Kategorienamen.\"}, + {\"role\": \"user\", \"content\": $(echo "$TEXT" | head -c 1000 | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')} + ] + }" 2>&1) || true + +if echo "$CLASSIFY_RESULT" | grep -qi "privacy_policy\|cookie\|terms\|imprint\|dpa"; then + green "Classification: $(echo "$CLASSIFY_RESULT" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("response","").strip()[:50])' 2>/dev/null || echo "$CLASSIFY_RESULT" | head -c 50)" +else + echo " Classification result: $(echo "$CLASSIFY_RESULT" | head -c 100)" + red "Classification did not return expected category (may still be valid)" +fi + +# Step 4: Test notification endpoint +echo "" +echo "── Step 4: Agent Notification ──" +NOTIFY_RESULT=$(curl -sf -X POST "$BACKEND/api/compliance/agent/notify" \ + -H "Content-Type: application/json" \ + -d '{ + "recipient": "dsb@breakpilot.local", + "subject": "E2E Test: Compliance-Finding", + "body_html": "

Test-Benachrichtigung

Automatischer E2E-Test des Compliance-Agent SOP.

", + "role": "Datenschutzbeauftragter" + }' 2>&1) || true + +if echo "$NOTIFY_RESULT" | grep -qi "sent\|success\|ok"; then + green "Notification sent" +else + echo " Notify result: $(echo "$NOTIFY_RESULT" | head -c 100)" + red "Notification endpoint returned unexpected result" +fi + +# Step 5: Check Mailpit +echo "" +echo "── Step 5: Mailpit Check ──" +MAIL_COUNT=$(curl -sf "$MAILPIT/api/v1/messages" 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("total",0))' 2>/dev/null || echo "0") +if [ "$MAIL_COUNT" -gt 0 ]; then + green "Mailpit has $MAIL_COUNT message(s)" +else + red "No messages in Mailpit (check SMTP connectivity)" +fi + +echo "" +echo "═══ E2E Test Complete ═══"