diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 27e5f9f..5ac7d42 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.api_v1.endpoints import ( + ai, api_tokens, audit, auth, @@ -10,6 +11,7 @@ from app.api.api_v1.endpoints import ( imap, integrations, mail_sources, + mcp, onboarding, operator, public, @@ -26,6 +28,7 @@ api_router = APIRouter() # Include all endpoint routers api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(ai.router, prefix="/ai", tags=["ai"]) api_router.include_router(api_tokens.router, prefix="/api-tokens", tags=["api-tokens"]) api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) api_router.include_router(health.router, tags=["health"]) @@ -38,6 +41,7 @@ api_router.include_router(imap.router, prefix="/imap", tags=["imap"]) api_router.include_router(integrations.router, prefix="/integrations", tags=["integrations"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"]) +api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"]) api_router.include_router(onboarding.router, prefix="/onboarding", tags=["onboarding"]) api_router.include_router(operator.router, prefix="/operator", tags=["operator"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) diff --git a/backend/app/api/api_v1/endpoints/ai.py b/backend/app/api/api_v1/endpoints/ai.py new file mode 100644 index 0000000..9b8850c --- /dev/null +++ b/backend/app/api/api_v1/endpoints/ai.py @@ -0,0 +1,194 @@ +"""Optional AI and automation endpoints.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.security import require_admin_auth +from app.services.ai_assistance import ( + build_action_proposals, + build_evidence_summary, + build_safe_context, + get_assistance_config, +) +from app.services.workspace_audit import record_workspace_audit_log +from app.services.workspaces import assign_default_workspace_to_unscoped_rows + +router = APIRouter() + + +class SafeContextResponse(BaseModel): + """Redacted model/agent context response.""" + + context: Dict[str, Any] + + +class EvidenceSummaryResponse(BaseModel): + """Evidence-first summary response.""" + + summary: Dict[str, Any] + + +class ActionProposalResponse(BaseModel): + """Reviewable action proposals response.""" + + domain: str + action_tools_enabled: bool + proposals: list[Dict[str, Any]] + + +class ProposalConfirmation(BaseModel): + """Human confirmation payload for a proposal.""" + + proposal_id: str + confirmation_text: str + note: Optional[str] = None + + +def _require_ai_enabled(db: Session) -> None: + if not get_assistance_config(db).ai_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="AI assistance is disabled. Enable ai.enabled before using this endpoint.", + ) + + +@router.get("/config") +async def get_ai_config( + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> Dict[str, Any]: + """Return safe AI/MCP configuration without secrets.""" + return {"config": get_assistance_config(db).to_dict()} + + +@router.get("/domains/{domain}/context", response_model=SafeContextResponse) +async def get_domain_safe_context( + domain: str, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> SafeContextResponse: + """Return a redacted, evidence-linked context payload for one domain.""" + _require_ai_enabled(db) + try: + return {"context": build_safe_context(db, domain)} + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/domains/{domain}/summary", response_model=EvidenceSummaryResponse) +async def get_domain_evidence_summary( + domain: str, + request: Request, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> EvidenceSummaryResponse: + """Return deterministic evidence-first assistance for one domain.""" + _require_ai_enabled(db) + try: + summary = build_evidence_summary(db, domain) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + workspace = assign_default_workspace_to_unscoped_rows(db, commit=False) + record_workspace_audit_log( + db, + workspace=workspace, + action="ai.summary_generated", + entity_type="domain", + entity_id=domain, + entity_name=domain, + details={ + "provider": summary["provider"]["provider"], + "recommendations": len(summary["recommendations"]), + }, + auth_context=_auth, + request=request, + ) + db.commit() + return {"summary": summary} + + +@router.get("/domains/{domain}/action-proposals", response_model=ActionProposalResponse) +async def get_domain_action_proposals( + domain: str, + request: Request, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> ActionProposalResponse: + """Return reviewable proposals; this endpoint never applies changes.""" + _require_ai_enabled(db) + try: + payload = build_action_proposals(db, domain) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + workspace = assign_default_workspace_to_unscoped_rows(db, commit=False) + record_workspace_audit_log( + db, + workspace=workspace, + action="ai.action_proposals_generated", + entity_type="domain", + entity_id=domain, + entity_name=domain, + details={"proposal_count": len(payload["proposals"]), "mutates_state": False}, + auth_context=_auth, + request=request, + ) + db.commit() + return payload + + +@router.post("/domains/{domain}/action-proposals/confirm") +async def confirm_action_proposal( + domain: str, + payload: ProposalConfirmation, + request: Request, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> Dict[str, Any]: + """Audit human confirmation for a proposal without applying external changes.""" + _require_ai_enabled(db) + config = get_assistance_config(db) + if not config.action_tools_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Action tools are disabled. Enable ai.action_tools_enabled to confirm " + "proposals." + ), + ) + proposals = build_action_proposals(db, domain)["proposals"] + proposal = next( + (item for item in proposals if item["proposal_id"] == payload.proposal_id), + None, + ) + if proposal is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found") + if payload.confirmation_text != payload.proposal_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="confirmation_text must match proposal_id", + ) + workspace = assign_default_workspace_to_unscoped_rows(db, commit=False) + record_workspace_audit_log( + db, + workspace=workspace, + action="ai.action_proposal_confirmed", + entity_type="action_proposal", + entity_id=payload.proposal_id, + entity_name=proposal["title"], + details={ + "domain": domain, + "proposal_id": payload.proposal_id, + "mutates_state": False, + "note": payload.note, + }, + auth_context=_auth, + request=request, + ) + db.commit() + return {"status": "confirmed", "applied": False, "proposal": proposal} diff --git a/backend/app/api/api_v1/endpoints/mcp.py b/backend/app/api/api_v1/endpoints/mcp.py new file mode 100644 index 0000000..a5e3cdc --- /dev/null +++ b/backend/app/api/api_v1/endpoints/mcp.py @@ -0,0 +1,163 @@ +"""Read-only MCP-style JSON-RPC endpoint for agent integrations.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.security import require_api_token_scope +from app.services.ai_assistance import ( + build_action_proposals, + build_evidence_summary, + get_assistance_config, +) +from app.services.api_tokens import MCP_READ_SCOPE +from app.services.report_persistence import hydrate_report_store_from_db +from app.services.report_store import ReportStore +from app.services.workspace_audit import record_workspace_audit_log +from app.services.workspaces import assign_default_workspace_to_unscoped_rows + +router = APIRouter() + + +class MCPRequest(BaseModel): + """Minimal JSON-RPC request for MCP HTTP integrations.""" + + jsonrpc: str = "2.0" + id: Optional[Any] = None + method: str + params: Dict[str, Any] = {} + + +READ_ONLY_TOOLS = [ + { + "name": "list_domains", + "description": "List monitored domains and aggregate counts.", + "inputSchema": {"type": "object", "properties": {}}, + "readOnlyHint": True, + }, + { + "name": "domain_summary", + "description": "Return an evidence-first summary for one domain.", + "inputSchema": { + "type": "object", + "properties": {"domain": {"type": "string"}}, + "required": ["domain"], + }, + "readOnlyHint": True, + }, + { + "name": "action_proposals", + "description": "Return reviewable remediation proposals without applying changes.", + "inputSchema": { + "type": "object", + "properties": {"domain": {"type": "string"}}, + "required": ["domain"], + }, + "readOnlyHint": True, + }, +] + + +def _jsonrpc_response(request_id: Any, result: Any = None, error: Optional[Dict[str, Any]] = None): + payload = {"jsonrpc": "2.0", "id": request_id} + if error is not None: + payload["error"] = error + else: + payload["result"] = result + return payload + + +def _require_mcp_enabled(db: Session) -> None: + if not get_assistance_config(db).mcp_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="MCP access is disabled. Enable mcp.enabled before using this endpoint.", + ) + + +def _list_domains(db: Session) -> Dict[str, Any]: + store = ReportStore.get_instance() + hydrate_report_store_from_db(db, store) + summaries = store.get_all_domain_summaries() + return { + "domains": [ + { + "domain": domain, + "total_messages": int(summary.get("total_count", 0) or 0), + "failed_messages": int(summary.get("failed_count", 0) or 0), + "compliance_rate": float(summary.get("compliance_rate", 0.0) or 0.0), + "reports_processed": int(summary.get("reports_processed", 0) or 0), + } + for domain, summary in sorted(summaries.items()) + ] + } + + +@router.post("") +async def mcp_jsonrpc( + payload: MCPRequest, + request: Request, + db: Session = Depends(get_db), + _auth: dict = Depends(require_api_token_scope(MCP_READ_SCOPE)), +) -> Dict[str, Any]: + """Handle a small read-only MCP tool surface over JSON-RPC.""" + _require_mcp_enabled(db) + if payload.method == "initialize": + return _jsonrpc_response( + payload.id, + { + "protocolVersion": "2024-11-05", + "serverInfo": {"name": "dmarq", "version": "1"}, + "capabilities": {"tools": {}}, + }, + ) + if payload.method == "tools/list": + return _jsonrpc_response(payload.id, {"tools": READ_ONLY_TOOLS}) + if payload.method != "tools/call": + return _jsonrpc_response( + payload.id, + error={"code": -32601, "message": f"Unsupported method: {payload.method}"}, + ) + + name = payload.params.get("name") + arguments = payload.params.get("arguments") or {} + try: + if name == "list_domains": + result = _list_domains(db) + elif name == "domain_summary": + result = build_evidence_summary(db, str(arguments.get("domain", ""))) + elif name == "action_proposals": + result = build_action_proposals(db, str(arguments.get("domain", ""))) + else: + return _jsonrpc_response( + payload.id, + error={"code": -32602, "message": f"Unsupported tool: {name}"}, + ) + except ValueError as exc: + return _jsonrpc_response(payload.id, error={"code": -32004, "message": str(exc)}) + + workspace = assign_default_workspace_to_unscoped_rows(db, commit=False) + record_workspace_audit_log( + db, + workspace=workspace, + action="mcp.tool_called", + entity_type="mcp_tool", + entity_id=name, + entity_name=name, + details={"tool": name, "read_only": True}, + auth_context=_auth, + request=request, + ) + db.commit() + return _jsonrpc_response( + payload.id, + { + "content": [{"type": "json", "json": result}], + "isError": False, + }, + ) diff --git a/backend/app/api/api_v1/endpoints/settings.py b/backend/app/api/api_v1/endpoints/settings.py index 0523119..082d742 100644 --- a/backend/app/api/api_v1/endpoints/settings.py +++ b/backend/app/api/api_v1/endpoints/settings.py @@ -270,6 +270,56 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [ "value_type": "string", "category": "notifications", }, + # ── Optional AI / MCP ─────────────────────────────────────────────────── + { + "key": "ai.enabled", + "value": "false", + "description": "Enable optional AI assistance endpoints", + "value_type": "boolean", + "category": "ai", + }, + { + "key": "ai.provider", + "value": "template", + "description": "AI provider: template, local, or remote", + "value_type": "string", + "category": "ai", + }, + { + "key": "ai.model", + "value": "", + "description": "Optional model name for local or remote providers", + "value_type": "string", + "category": "ai", + }, + { + "key": "ai.remote_base_url", + "value": "", + "description": "Optional remote provider base URL; credentials should be injected by environment", + "value_type": "string", + "category": "ai", + }, + { + "key": "ai.redaction_mode", + "value": "strict", + "description": "Redaction mode for AI-safe context: strict or balanced", + "value_type": "string", + "category": "ai", + }, + { + "key": "ai.action_tools_enabled", + "value": "false", + "description": "Allow human-confirmed action proposals to be recorded", + "value_type": "boolean", + "category": "ai", + }, + { + "key": "mcp.enabled", + "value": "false", + "description": "Enable the scoped read-only MCP endpoint", + "value_type": "boolean", + "category": "mcp", + }, ] # Keys whose values should be redacted in GET responses (treated as secrets) @@ -334,7 +384,7 @@ def _audit_value_for_setting(key: str, value: Optional[str]) -> Optional[str]: def _should_audit_setting(key: str) -> bool: - return key.startswith(("notifications.", "forensics.")) + return key.startswith(("notifications.", "forensics.", "ai.", "mcp.")) def _audit_setting_change( diff --git a/backend/app/services/ai_assistance.py b/backend/app/services/ai_assistance.py new file mode 100644 index 0000000..cf707b8 --- /dev/null +++ b/backend/app/services/ai_assistance.py @@ -0,0 +1,317 @@ +"""Privacy-preserving AI and agent assistance helpers.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List + +from sqlalchemy.orm import Session + +from app.core.redaction import SENSITIVE_VALUE, redact_sensitive_text +from app.models.domain import Domain +from app.models.setting import Setting +from app.services.report_persistence import hydrate_report_store_from_db +from app.services.report_store import ReportStore + +AI_DEFAULTS = { + "ai.enabled": "false", + "ai.provider": "template", + "ai.model": "", + "ai.remote_base_url": "", + "ai.redaction_mode": "strict", + "ai.action_tools_enabled": "false", + "mcp.enabled": "false", +} + +EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b", re.IGNORECASE) +LONG_TOKEN_PATTERN = re.compile(r"\b[A-Za-z0-9._~+/=-]{24,}\b") + + +@dataclass(frozen=True) +class AssistanceConfig: + """Operator-controlled automation settings.""" + + ai_enabled: bool + provider: str + model: str + remote_base_url: str + redaction_mode: str + action_tools_enabled: bool + mcp_enabled: bool + + def to_dict(self) -> Dict[str, Any]: + """Return a UI/API-safe provider configuration.""" + return { + "ai_enabled": self.ai_enabled, + "provider": self.provider, + "model": self.model, + "remote_base_url_configured": bool(self.remote_base_url), + "redaction_mode": self.redaction_mode, + "action_tools_enabled": self.action_tools_enabled, + "mcp_enabled": self.mcp_enabled, + "data_handling": { + "default_provider": "template", + "secrets_in_prompts": "never", + "raw_message_content": "never", + "remote_provider_requires_opt_in": True, + }, + } + + +def _setting_value(db: Session, key: str) -> str: + row = db.query(Setting).filter(Setting.key == key).first() + if row is None: + return AI_DEFAULTS.get(key, "") + return row.value or "" + + +def _setting_bool(db: Session, key: str) -> bool: + return _setting_value(db, key).strip().lower() in {"1", "true", "yes", "on"} + + +def get_assistance_config(db: Session) -> AssistanceConfig: + """Load AI/MCP settings with safe defaults.""" + provider = (_setting_value(db, "ai.provider") or "template").strip().lower() + if provider not in {"template", "local", "remote"}: + provider = "template" + redaction_mode = (_setting_value(db, "ai.redaction_mode") or "strict").strip().lower() + if redaction_mode not in {"strict", "balanced"}: + redaction_mode = "strict" + return AssistanceConfig( + ai_enabled=_setting_bool(db, "ai.enabled"), + provider=provider, + model=_setting_value(db, "ai.model").strip(), + remote_base_url=_setting_value(db, "ai.remote_base_url").strip(), + redaction_mode=redaction_mode, + action_tools_enabled=_setting_bool(db, "ai.action_tools_enabled"), + mcp_enabled=_setting_bool(db, "mcp.enabled"), + ) + + +def redact_safe_value(value: Any, *, mode: str = "strict") -> Any: + """Redact values before they can be shared with model or agent surfaces.""" + if isinstance(value, dict): + return {str(key): redact_safe_value(item, mode=mode) for key, item in value.items()} + if isinstance(value, list): + return [redact_safe_value(item, mode=mode) for item in value] + if not isinstance(value, str): + return value + redacted = redact_sensitive_text(value) + redacted = LONG_TOKEN_PATTERN.sub(SENSITIVE_VALUE, redacted) + if mode == "strict": + redacted = EMAIL_PATTERN.sub(r"*@\1", redacted) + return redacted + + +def _domain_exists(db: Session, store: ReportStore, domain: str) -> bool: + if domain in store.get_domains(): + return True + return ( + db.query(Domain.id).filter(Domain.name == domain, Domain.active.is_(True)).first() + is not None + ) + + +def _evidence(label: str, value: Any, href: str) -> Dict[str, str]: + return { + "label": label, + "value": str(value), + "href": href, + } + + +def build_safe_context(db: Session, domain: str) -> Dict[str, Any]: + """Build a redacted, evidence-linked context bundle for one domain.""" + store = ReportStore.get_instance() + hydrate_report_store_from_db(db, store) + if not _domain_exists(db, store, domain): + raise ValueError("Domain not found") + + config = get_assistance_config(db) + summary = store.get_domain_summary(domain) + total = int(summary.get("total_count", 0) or 0) + passed = int(summary.get("passed_count", 0) or 0) + failed = int(summary.get("failed_count", max(0, total - passed)) or 0) + compliance = float(summary.get("compliance_rate", 0.0) or 0.0) + reports_processed = int(summary.get("reports_processed", 0) or 0) + sources = store.get_domain_sources(domain, days=30)[:5] + reports = store.get_domain_reports(domain, limit=5) + + context = { + "domain": domain, + "generated_at": datetime.utcnow().isoformat() + "Z", + "config": config.to_dict(), + "summary": { + "total_messages": total, + "passed_messages": passed, + "failed_messages": failed, + "compliance_rate": compliance, + "reports_processed": reports_processed, + "policy": summary.get("policy", "unknown"), + }, + "top_sources": [ + { + "source_ip": source.get("source_ip", "unknown"), + "count": int(source.get("count", 0) or 0), + "spf": source.get("spf_result", "unknown"), + "dkim": source.get("dkim_result", "unknown"), + "dmarc": source.get("dmarc_result", "unknown"), + "disposition": source.get("disposition", "none"), + } + for source in sources + ], + "recent_reports": [ + { + "report_id": report.get("report_id", "unknown"), + "org_name": report.get("org_name", "Unknown Organization"), + "total_messages": int(report.get("summary", {}).get("total_count", 0) or 0), + "pass_rate": report.get("pass_rate", 0.0), + } + for report in reports + ], + "evidence": [ + _evidence("Domain summary", domain, f"/domain/{domain}"), + _evidence("Total messages", total, f"/domain/{domain}#compliance-chart"), + _evidence("Compliance rate", f"{compliance}%", f"/domain/{domain}#compliance-chart"), + _evidence("Failed messages", failed, f"/domain/{domain}#sending-sources"), + ], + "redaction": { + "mode": config.redaction_mode, + "applied": True, + "rules": [ + "secret-like key/value fragments", + "bearer tokens", + "long opaque tokens", + "email local-parts in strict mode", + ], + }, + } + return redact_safe_value(context, mode=config.redaction_mode) + + +def _headline_for_context(context: Dict[str, Any]) -> str: + summary = context["summary"] + total = int(summary["total_messages"]) + failed = int(summary["failed_messages"]) + compliance = float(summary["compliance_rate"]) + if total == 0: + return "No DMARC aggregate volume has been observed yet." + if failed == 0 and compliance >= 99: + return "Observed DMARC traffic is passing cleanly." + if compliance >= 90: + return "DMARC posture is mostly healthy, with a small failure set to review." + return "DMARC posture needs remediation before policy enforcement." + + +def build_evidence_summary(db: Session, domain: str) -> Dict[str, Any]: + """Return an evidence-first operator summary and remediation plan.""" + context = build_safe_context(db, domain) + summary = context["summary"] + failed = int(summary["failed_messages"]) + total = int(summary["total_messages"]) + compliance = float(summary["compliance_rate"]) + recommendations: List[Dict[str, Any]] = [] + + if total == 0: + recommendations.append( + { + "priority": "medium", + "title": "Confirm report ingestion", + "detail": "No aggregate reports are available for this domain.", + "action": "Check mailbox sources and senders for rua delivery.", + "evidence": [context["evidence"][0]], + } + ) + elif failed > 0: + recommendations.append( + { + "priority": "high" if compliance < 90 else "medium", + "title": "Review failing sending sources", + "detail": f"{failed} of {total} observed messages failed DMARC alignment.", + "action": ( + "Open the sending-source evidence and confirm whether each failing " + "source is legitimate." + ), + "evidence": [context["evidence"][2], context["evidence"][3]], + } + ) + + unknown_or_failing_sources = [ + source + for source in context["top_sources"] + if source.get("dmarc") in {"fail", "mixed", "unknown", "none"} + ] + if unknown_or_failing_sources: + recommendations.append( + { + "priority": "medium", + "title": "Triage top unauthenticated sources", + "detail": "At least one high-volume source is not consistently passing DMARC.", + "action": "Verify ownership before adding SPF mechanisms or enabling DKIM signing.", + "evidence": [ + _evidence( + "Top source", + f"{source['source_ip']} ({source['count']} messages)", + f"/domain/{context['domain']}#sending-sources", + ) + for source in unknown_or_failing_sources[:3] + ], + } + ) + + return { + "enabled": get_assistance_config(db).ai_enabled, + "provider": context["config"], + "summary": { + "domain": context["domain"], + "headline": _headline_for_context(context), + "total_messages": total, + "failed_messages": failed, + "compliance_rate": compliance, + }, + "recommendations": recommendations, + "safe_context": context, + } + + +def _proposal_id(payload: Dict[str, Any]) -> str: + serialized = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16] + + +def build_action_proposals(db: Session, domain: str) -> Dict[str, Any]: + """Generate reviewable, reproducible action proposals without mutating state.""" + summary = build_evidence_summary(db, domain) + proposals = [] + for index, recommendation in enumerate(summary["recommendations"], start=1): + payload = { + "domain": domain, + "index": index, + "title": recommendation["title"], + "action": recommendation["action"], + "evidence": recommendation.get("evidence", []), + } + proposal_id = _proposal_id(payload) + proposals.append( + { + "proposal_id": proposal_id, + "domain": domain, + "status": "proposed", + "title": recommendation["title"], + "rationale": recommendation["detail"], + "proposed_action": recommendation["action"], + "requires_human_confirmation": True, + "confirmation_text": proposal_id, + "mutates_state": False, + "evidence": recommendation.get("evidence", []), + } + ) + return { + "domain": domain, + "action_tools_enabled": get_assistance_config(db).action_tools_enabled, + "proposals": proposals, + } diff --git a/backend/app/services/api_tokens.py b/backend/app/services/api_tokens.py index 4a92387..d9c5e28 100644 --- a/backend/app/services/api_tokens.py +++ b/backend/app/services/api_tokens.py @@ -15,11 +15,13 @@ from app.models.api_token import APIToken READ_REPORTS_SCOPE = "reports:read" READ_POSTURE_SCOPE = "posture:read" READ_TLS_SCOPE = "tls-reports:read" +MCP_READ_SCOPE = "mcp:read" PUBLIC_READ_SCOPES = { READ_REPORTS_SCOPE, READ_POSTURE_SCOPE, READ_TLS_SCOPE, + MCP_READ_SCOPE, } diff --git a/backend/app/templates/settings.html b/backend/app/templates/settings.html index 908b7a8..b8d000b 100644 --- a/backend/app/templates/settings.html +++ b/backend/app/templates/settings.html @@ -778,6 +778,90 @@ {% endcall %} {% endcall %} + + {% call card() %} + {% call card_header() %} + {% call card_title() %}AI and Agent Automation{% endcall %} + {% call card_description() %}Opt-in summaries, read-only MCP access, and human-confirmed proposal controls{% endcall %} + {% endcall %} + {% call card_content() %} +
+
+ AI and MCP features are disabled by default. Safe context redacts secrets, tokens, long opaque values, and email local-parts before it can be shared with model or agent surfaces. +
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ +
+
+ +
+
+ {% endcall %} + {% endcall %} + {% call card() %} {% call card_header() %} @@ -903,6 +987,31 @@ function settingsApp() { } }, + async saveAutomationSettings() { + this.saving = true; + const keys = Object.keys(this.s).filter(k => k.startsWith('ai.') || k.startsWith('mcp.')); + const settings = {}; + keys.forEach(k => { settings[k] = String(this.s[k] ?? ''); }); + try { + const res = await fetch('/api/v1/settings/bulk', { + method: 'POST', + headers: this.apiHeaders(), + body: JSON.stringify({ settings }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.showFlash('Save failed: ' + (data.detail || res.statusText), false); + } else { + data.forEach(r => { this.s[r.key] = r.value ?? ''; }); + this.showFlash('Automation settings saved.', true); + } + } catch (err) { + this.showFlash('Error saving automation settings: ' + err.message, false); + } finally { + this.saving = false; + } + }, + async sendTestNotification() { this.testingNotification = true; try { diff --git a/backend/app/tests/test_ai_mcp.py b/backend/app/tests/test_ai_mcp.py new file mode 100644 index 0000000..e247d62 --- /dev/null +++ b/backend/app/tests/test_ai_mcp.py @@ -0,0 +1,169 @@ +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models.setting import Setting +from app.services.api_tokens import MCP_READ_SCOPE, create_api_token +from app.services.report_store import ReportStore + +DOMAIN = "example.com" + +REPORT = { + "domain": DOMAIN, + "report_id": "ai-001", + "org_name": "Receiver", + "policy": {"p": "none", "pct": "100"}, + "records": [ + { + "source_ip": "192.0.2.10", + "count": 8, + "disposition": "none", + "dkim_result": "fail", + "spf_result": "fail", + }, + { + "source_ip": "192.0.2.11", + "count": 2, + "disposition": "none", + "dkim_result": "pass", + "spf_result": "pass", + }, + ], + "summary": {"total_count": 10, "passed_count": 2, "failed_count": 8}, +} + + +def _seed_report_store() -> None: + ReportStore.get_instance().add_report(REPORT) + + +def _set_setting(db: Session, key: str, value: str, category: str) -> None: + row = db.query(Setting).filter(Setting.key == key).first() + if row is None: + row = Setting(key=key, value=value, category=category, value_type="string") + db.add(row) + else: + row.value = value + db.commit() + + +def test_ai_settings_are_seeded(authed_client: TestClient): + response = authed_client.get("/api/v1/settings") + assert response.status_code == 200 + keys = {row["key"] for row in response.json()} + assert "ai.enabled" in keys + assert "ai.provider" in keys + assert "ai.action_tools_enabled" in keys + assert "mcp.enabled" in keys + + +def test_ai_summary_requires_explicit_opt_in(authed_client: TestClient): + _seed_report_store() + response = authed_client.get(f"/api/v1/ai/domains/{DOMAIN}/summary") + assert response.status_code == 403 + assert "AI assistance is disabled" in response.json()["detail"] + + +def test_ai_summary_is_evidence_first_and_redacted( + authed_client: TestClient, + db_session: Session, +): + _seed_report_store() + _set_setting(db_session, "ai.enabled", "true", "ai") + _set_setting(db_session, "ai.redaction_mode", "strict", "ai") + + response = authed_client.get(f"/api/v1/ai/domains/{DOMAIN}/summary") + + assert response.status_code == 200 + body = response.json()["summary"] + assert body["summary"]["domain"] == DOMAIN + assert body["summary"]["failed_messages"] == 8 + assert body["recommendations"] + assert body["recommendations"][0]["evidence"] + assert "**redacted**" not in body["safe_context"]["domain"] + + +def test_action_proposals_are_reviewable_and_not_mutating( + authed_client: TestClient, + db_session: Session, +): + _seed_report_store() + _set_setting(db_session, "ai.enabled", "true", "ai") + + response = authed_client.get(f"/api/v1/ai/domains/{DOMAIN}/action-proposals") + + assert response.status_code == 200 + body = response.json() + assert body["domain"] == DOMAIN + assert body["proposals"] + proposal = body["proposals"][0] + assert proposal["requires_human_confirmation"] is True + assert proposal["mutates_state"] is False + assert proposal["confirmation_text"] == proposal["proposal_id"] + + +def test_action_confirmation_requires_action_tools_enabled( + authed_client: TestClient, + db_session: Session, +): + _seed_report_store() + _set_setting(db_session, "ai.enabled", "true", "ai") + proposal = authed_client.get(f"/api/v1/ai/domains/{DOMAIN}/action-proposals").json()[ + "proposals" + ][0] + + denied = authed_client.post( + f"/api/v1/ai/domains/{DOMAIN}/action-proposals/confirm", + json={ + "proposal_id": proposal["proposal_id"], + "confirmation_text": proposal["proposal_id"], + }, + ) + assert denied.status_code == 403 + + _set_setting(db_session, "ai.action_tools_enabled", "true", "ai") + confirmed = authed_client.post( + f"/api/v1/ai/domains/{DOMAIN}/action-proposals/confirm", + json={ + "proposal_id": proposal["proposal_id"], + "confirmation_text": proposal["proposal_id"], + "note": "Reviewed by operator", + }, + ) + assert confirmed.status_code == 200 + assert confirmed.json()["status"] == "confirmed" + assert confirmed.json()["applied"] is False + + +def test_mcp_requires_enabled_scoped_token(client: TestClient, db_session: Session): + _seed_report_store() + token = create_api_token(db_session, name="mcp client", scopes=[MCP_READ_SCOPE]) + + disabled = client.post( + "/api/v1/mcp", + headers={"X-API-Key": token.secret}, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + assert disabled.status_code == 403 + + _set_setting(db_session, "mcp.enabled", "true", "mcp") + listed = client.post( + "/api/v1/mcp", + headers={"X-API-Key": token.secret}, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + assert listed.status_code == 200 + assert listed.json()["result"]["tools"][0]["readOnlyHint"] is True + + called = client.post( + "/api/v1/mcp", + headers={"X-API-Key": token.secret}, + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "domain_summary", "arguments": {"domain": DOMAIN}}, + }, + ) + assert called.status_code == 200 + result = called.json()["result"]["content"][0]["json"] + assert result["summary"]["domain"] == DOMAIN diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md index 2bc985c..47789a0 100644 --- a/docs/development/roadmap.md +++ b/docs/development/roadmap.md @@ -121,8 +121,8 @@ The milestone breakdown in `docs/milestones.md` is intentionally focused on exit ### Priority (Now / Next / Later) -- **Now**: Milestone 16 (optional AI/MCP automation). -- **Next**: release hardening and follow-up triage. +- **Now**: release hardening and follow-up triage. +- **Next**: production smoke testing and deployment feedback. - **Later**: production-driven improvements from deployment feedback. ### Tentative Release Plan (Subject to Change) diff --git a/docs/index.md b/docs/index.md index 1b9dafa..5d6abf3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,3 +28,5 @@ For Microsoft 365 setup, see [Microsoft 365 Mail Sources](user_guide/microsoft36 For SMTP TLS reporting imports and privacy controls, see [TLS Reports](user_guide/tls_reports.md). For aggregate-report parser support, known edge cases, and fixture guidance, see [DMARC Aggregate Format Compatibility](reference/dmarc-compatibility.md). + +For opt-in AI summaries and read-only MCP automation, see [AI and MCP Automation](reference/ai-mcp-automation.md). diff --git a/docs/milestones.md b/docs/milestones.md index e170935..a5f57d5 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -270,15 +270,28 @@ Exit criteria: ## Milestone 16: Optional AI + MCP Automation Layer -Status: Backlog +Status: Delivered Goal: provide opt-in assistance and agent-friendly automation without compromising privacy or safety. Planned: -- Evidence-first summaries and remediation plans that link back to the underlying DMARC data. -- Pluggable model provider support with strong redaction and “no secrets in prompts” guarantees. -- A DMARQ MCP server that starts read-only (posture queries, reports, recommendations). -- Optional action tools (e.g., proposing DNS changes) gated behind explicit human confirmation and audit logging. +- Evidence-first summaries and remediation plans that link back to the + underlying DMARC data. Delivered with redacted safe-context generation, + deterministic summaries, recommendations, and evidence links back to domain + detail views. +- Pluggable model provider support with strong redaction and “no secrets in + prompts” guarantees. Delivered with explicit admin settings for template, + local, and remote provider modes, model/base-URL configuration, strict or + balanced redaction, and no stored provider secrets. +- A DMARQ MCP server that starts read-only (posture queries, reports, + recommendations). Delivered with an opt-in `/api/v1/mcp` JSON-RPC surface, + `mcp:read` scoped API tokens, read-only tool metadata, and audited tool + calls. +- Optional action tools (e.g., proposing DNS changes) gated behind explicit + human confirmation and audit logging. Delivered as reproducible proposal + artifacts that never mutate DNS or DMARQ state unless action tools are + explicitly enabled and a human confirms the proposal ID; confirmation is + audited and no external changes are applied. Exit criteria: - Users can enable AI/agent workflows intentionally, understand what data is shared, and keep deployments safe by default. diff --git a/docs/reference/ai-mcp-automation.md b/docs/reference/ai-mcp-automation.md new file mode 100644 index 0000000..cea3adc --- /dev/null +++ b/docs/reference/ai-mcp-automation.md @@ -0,0 +1,69 @@ +# AI and MCP Automation + +Milestone 16 adds optional automation surfaces that are safe by default: + +- AI assistance is disabled until `ai.enabled=true`. +- MCP access is disabled until `mcp.enabled=true`. +- MCP requires scoped API tokens with `mcp:read`. +- Provider secrets are not stored in DMARQ settings. +- Safe context redacts secret-like key/value fragments, bearer tokens, long + opaque values, and email local-parts in strict mode. +- Action tools produce reviewable proposals first. Confirmation is audited, and + the current implementation does not apply DNS or other external changes. + +## Settings + +| Key | Default | Purpose | +| --- | --- | --- | +| `ai.enabled` | `false` | Enables AI assistance endpoints | +| `ai.provider` | `template` | `template`, `local`, or `remote` provider mode | +| `ai.model` | empty | Optional model name | +| `ai.remote_base_url` | empty | Optional remote provider URL | +| `ai.redaction_mode` | `strict` | `strict` or `balanced` safe-context redaction | +| `ai.action_tools_enabled` | `false` | Allows proposal confirmation records | +| `mcp.enabled` | `false` | Enables the read-only MCP endpoint | + +Use 1Password or another deployment secret injector for provider credentials. +Do not put provider API keys into DMARQ settings. + +## Safe Context + +`GET /api/v1/ai/domains/{domain}/context` builds the payload that model or +agent surfaces are allowed to inspect. It includes: + +- domain summary counts +- recent report metadata +- top sending sources +- evidence links to the UI +- redaction metadata + +The payload deliberately excludes raw report XML, mailbox credentials, OAuth +tokens, notification target URLs, and original forensic message content. + +## MCP + +`POST /api/v1/mcp` accepts minimal JSON-RPC requests for: + +- `initialize` +- `tools/list` +- `tools/call` + +The current tools are read-only: + +- `list_domains` +- `domain_summary` +- `action_proposals` + +Create a token with the `mcp:read` scope and send it through `X-API-Key`. + +## Audit + +DMARQ records sanitized workspace audit events for: + +- `ai.summary_generated` +- `ai.action_proposals_generated` +- `ai.action_proposal_confirmed` +- `mcp.tool_called` + +Audit details are sanitized with the same secret-field redaction used for other +workspace audit logs. diff --git a/docs/reference/api.md b/docs/reference/api.md index 9b43c6b..05a2aae 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -61,6 +61,7 @@ through the `/public` path and avoid UI-specific payloads. | `GET /public/domains/{domain_id}/reports` | `reports:read` | Recent DMARC aggregate report summaries | | `GET /public/domains/{domain_id}/posture` | `posture:read` | Evidence-first posture dashboard payload | | `GET /public/tls-reports/summary` | `tls-reports:read` | SMTP TLS report trends and failure groups | +| `POST /mcp` | `mcp:read` | Read-only MCP-style JSON-RPC tool endpoint | Successful public API calls update the token's last-used timestamp, source IP, and usage count for auditing. @@ -214,6 +215,86 @@ Request: Updates workspace retention controls and writes a sanitized `workspace.retention_updated` audit event. +### Optional AI Assistance + +AI assistance endpoints require administrator access and remain disabled until +`ai.enabled=true` is set in Settings. + +#### Read AI Configuration + +```text +GET /ai/config +``` + +Returns provider mode, model name, whether a remote base URL is configured, +redaction mode, action-tool state, MCP state, and data-handling guarantees. Raw +provider credentials are not stored or returned. + +#### Build Safe Context + +```text +GET /ai/domains/{domain}/context +``` + +Returns a redacted context payload for a domain. The payload includes summary +counts, recent reports, top sources, evidence links, and the redaction rules +that were applied. + +#### Build Evidence Summary + +```text +GET /ai/domains/{domain}/summary +``` + +Returns a deterministic evidence-first summary and remediation plan. Each +recommendation includes evidence links back to the underlying DMARC data. + +#### Build Action Proposals + +```text +GET /ai/domains/{domain}/action-proposals +``` + +Returns reproducible proposal artifacts. Proposal generation is read-only and +does not apply DNS or configuration changes. + +#### Confirm Proposal + +```text +POST /ai/domains/{domain}/action-proposals/confirm +``` + +Requires `ai.action_tools_enabled=true` and a `confirmation_text` equal to the +proposal ID. Confirmation is written to the workspace audit log. The current +implementation records human confirmation but does not apply external changes. + +### MCP Endpoint + +The MCP endpoint is disabled until `mcp.enabled=true` is set. It requires a +scoped API token with `mcp:read`. + +```text +POST /mcp +``` + +Supported JSON-RPC methods: + +| Method | Purpose | +| --- | --- | +| `initialize` | Return server capabilities | +| `tools/list` | List read-only tool metadata | +| `tools/call` | Call a read-only tool | + +Available tools: + +| Tool | Purpose | +| --- | --- | +| `list_domains` | List monitored domains and aggregate counts | +| `domain_summary` | Return an evidence-first summary for one domain | +| `action_proposals` | Return reviewable remediation proposals without applying changes | + +Every successful tool call is audited as `mcp.tool_called`. + ### Domains #### List Domains diff --git a/docs/reference/workspaces.md b/docs/reference/workspaces.md index 7ea0cf7..e589d46 100644 --- a/docs/reference/workspaces.md +++ b/docs/reference/workspaces.md @@ -59,6 +59,8 @@ Current audit coverage includes: - notification and forensic setting changes - webhook creation, update, disable, and test actions - manual DKIM selector add/remove actions +- AI summary generation, action proposal generation/confirmation, and MCP + read-only tool calls Audit details redact secret-like fields such as passwords, OAuth tokens, API keys, and webhook signing secrets.