feat: ZeroClaw compliance agent — document analysis + role assignment + email

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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 23:27:25 +02:00
parent f528b8e7a9
commit 0c0dd4e3a6
16 changed files with 1095 additions and 0 deletions

View File

@@ -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 }
)
}
}

View File

@@ -0,0 +1,57 @@
'use client'
import React from 'react'
import type { AnalysisResult } from '../_hooks/useAgentAnalysis'
const DOC_TYPE_LABELS: Record<string, string> = {
privacy_policy: 'DSE',
cookie_banner: 'Cookie',
terms_of_service: 'AGB',
imprint: 'Impressum',
dpa: 'AVV',
other: 'Sonstig',
}
const RISK_DOT: Record<string, string> = {
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 (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Analysen</h3>
<div className="space-y-2">
{history.map((item, i) => (
<button
key={i}
onClick={() => onSelect(item)}
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${RISK_DOT[item.risk_level] || 'bg-gray-400'}`} />
<span className="text-xs font-medium text-gray-500 w-16">
{DOC_TYPE_LABELS[item.classification] || item.classification}
</span>
<span className="text-sm text-gray-700 truncate flex-1">
{new URL(item.url).hostname}
</span>
<span className="text-xs text-gray-400">
{new Date(item.analyzed_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import React from 'react'
import type { AnalysisResult as AnalysisResultType } from '../_hooks/useAgentAnalysis'
const RISK_COLORS: Record<string, { bg: string; text: string; label: string }> = {
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<string, string> = {
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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{DOC_TYPE_LABELS[result.classification] || result.classification}
</h3>
<p className="text-sm text-gray-500 truncate max-w-md">{result.url}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${risk.bg} ${risk.text}`}>
{risk.label} ({result.risk_score}/100)
</span>
</div>
{/* Role Assignment */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="text-sm font-medium text-purple-900">
Zugewiesen an: <strong>{result.responsible_role}</strong>
</span>
<span className="text-xs text-purple-600 ml-auto">
Eskalationsstufe {result.escalation_level}
</span>
</div>
</div>
{/* Summary */}
{result.summary && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Zusammenfassung</h4>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{result.summary}</p>
</div>
)}
{/* Findings */}
{result.findings.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Findings ({result.findings.length})</h4>
<ul className="space-y-1">
{result.findings.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
<span className="text-orange-500 mt-0.5">!</span>
{f}
</li>
))}
</ul>
</div>
)}
{/* Required Controls */}
{result.required_controls.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Erforderliche Massnahmen</h4>
<ul className="space-y-1">
{result.required_controls.map((c, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
<span className="text-blue-500 mt-0.5">&#10003;</span>
{c}
</li>
))}
</ul>
</div>
)}
{/* Email Status */}
<div className="flex items-center gap-2 text-sm text-gray-500 pt-2 border-t">
<span className={result.email_status === 'sent' ? 'text-green-600' : 'text-yellow-600'}>
{result.email_status === 'sent' ? '&#9993; Email gesendet' : '&#9993; Email ausstehend'}
</span>
<span className="ml-auto text-xs">
{new Date(result.analyzed_at).toLocaleString('de-DE')}
</span>
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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<string | null>(null)
const [result, setResult] = useState<AnalysisResult | null>(null)
const [history, setHistory] = useState<AnalysisResult[]>([])
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 }
}

View File

@@ -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 (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
<p className="text-gray-500 mt-1">
Analysiere Webseiten auf DSGVO-Konformitaet. Der Agent holt das Dokument,
klassifiziert es, bewertet das Risiko und weist die Aufgabe der zustaendigen Rolle zu.
</p>
</div>
{/* URL Input */}
<form onSubmit={handleSubmit} className="flex gap-3">
<input
type="url"
value={url}
onChange={e => 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
/>
<button
type="submit"
disabled={loading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2 text-sm font-medium"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Analysiere...
</>
) : (
'Analysieren'
)}
</button>
</form>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
{error}
</div>
)}
{/* Result */}
{result && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<AnalysisResult result={result} />
</div>
)}
{/* History */}
<AnalysisHistory
history={history}
onSelect={r => {
setUrl(r.url)
analyze(r.url)
}}
/>
</div>
)
}

View File

@@ -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"<div>{summary}</div>",
)
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)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
clean = re.sub(r"<[^>]+>", " ", clean)
clean = re.sub(r"&nbsp;", " ", 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}"
)

View File

@@ -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"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #1a1a2e; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">BreakPilot Compliance Agent</h2>
</div>
<div style="padding: 24px; border: 1px solid #e2e8f0; border-top: none;">
<p style="color: #64748b; font-size: 13px; margin-top: 0;">
Zugewiesen an: <strong>{req.role}</strong>
{f' | Eskalation: {req.escalation_id}' if req.escalation_id else ''}
</p>
{req.body_html}
</div>
<div style="background: #f8fafc; padding: 12px 24px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<p style="color: #94a3b8; font-size: 11px; margin: 0;">
Automatisch generiert vom BreakPilot Compliance Agent (ZeroClaw + Qwen)
</p>
</div>
</div>
"""

View File

@@ -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)}

View File

@@ -41,6 +41,10 @@ from compliance.api.screening_routes import router as screening_router
# Company Profile # Company Profile
from compliance.api.company_profile_routes import router as company_profile_router 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 # Middleware
from middleware import ( from middleware import (
RequestIDMiddleware, RequestIDMiddleware,
@@ -135,6 +139,10 @@ app.include_router(screening_router, prefix="/api")
# Company Profile (CRUD with audit logging) # Company Profile (CRUD with audit logging)
app.include_router(company_profile_router, prefix="/api") 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__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -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": "<p>Test body</p>",
"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": "<h2>Urgent</h2>",
"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": "<p>Test</p>",
"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": "<p>Test</p>",
"role": "DSB",
})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "failed"
assert "SMTP down" in data["error"]

56
zeroclaw/README.md Normal file
View File

@@ -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

View File

@@ -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 <url> [max_chars]
#
# Outputs clean text to stdout, truncated to max_chars (default: 4000).
set -euo pipefail
URL="${1:?Usage: fetch-and-analyze.sh <url> [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[^>]*>[^<]*<\/style>//gi' \
| sed 's/<script[^>]*>[^<]*<\/script>//gi' \
| sed 's/<[^>]*>//g' \
| sed 's/&nbsp;/ /g; s/&amp;/\&/g; s/&lt;/</g; s/&gt;/>/g; s/&quot;/"/g' \
| tr -s '[:space:]' ' ' \
| sed 's/^ //; s/ $//')
# Truncate to max chars
echo "$CLEAN" | head -c "$MAX_CHARS"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# send-notification.sh — Send a notification email via Mailpit SMTP.
#
# Usage: bash send-notification.sh <recipient> <subject> <body_text>
#
# Uses Mailpit's SMTP on localhost:1025 via Python smtplib (one-liner).
set -euo pipefail
RECIPIENT="${1:?Usage: send-notification.sh <recipient> <subject> <body_text>}"
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}')
"

View File

@@ -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[^>]*>.*<\/style>//g; s/<script[^>]*>.*<\/script>//g; s/<[^>]*>//g; s/&nbsp;/ /g; s/&amp;/\&/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

View File

@@ -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"

View File

@@ -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": "<h2>Test-Benachrichtigung</h2><p>Automatischer E2E-Test des Compliance-Agent SOP.</p>",
"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 ═══"