Merge pull request #200 from christianlouis/codex/m16-ai-mcp-automation

[codex] Add optional AI and MCP automation
This commit is contained in:
Christian Krakau-Louis
2026-05-23 20:50:58 +02:00
committed by GitHub
14 changed files with 1183 additions and 8 deletions
+4
View File
@@ -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"])
+194
View File
@@ -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}
+163
View File
@@ -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,
},
)
+51 -1
View File
@@ -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(
+317
View File
@@ -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,
}
+2
View File
@@ -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,
}
+109
View File
@@ -778,6 +778,90 @@
{% endcall %}
{% endcall %}
<!-- ── Optional AI and MCP ────────────────────────────────────────────── -->
{% 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() %}
<form @submit.prevent="saveAutomationSettings()" class="space-y-4">
<div class="alert alert-info">
<span>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.</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['ai.enabled'] === 'true'"
@change="s['ai.enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Enable AI assistance</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['mcp.enabled'] === 'true'"
@change="s['mcp.enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Enable read-only MCP endpoint</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Provider</span></label>
<select x-model="s['ai.provider']" class="input input-bordered w-full">
<option value="template">Template</option>
<option value="local">Local</option>
<option value="remote">Remote</option>
</select>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Model</span></label>
<input type="text" x-model="s['ai.model']" class="input input-bordered w-full" placeholder="Optional model name" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Redaction</span></label>
<select x-model="s['ai.redaction_mode']" class="input input-bordered w-full">
<option value="strict">Strict</option>
<option value="balanced">Balanced</option>
</select>
</div>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Remote Base URL</span></label>
<input type="url" x-model="s['ai.remote_base_url']" class="input input-bordered w-full" placeholder="https://provider.example/v1" />
<label class="label"><span class="label-text-alt text-muted-foreground">Credentials are not stored here; inject provider secrets through the deployment environment.</span></label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['ai.action_tools_enabled'] === 'true'"
@change="s['ai.action_tools_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Allow human-confirmed action proposals</span>
</label>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</template>
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Save Automation Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- ── Mail Sources shortcut ──────────────────────────────────────────── -->
{% 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 {
+169
View File
@@ -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
+2 -2
View File
@@ -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)
+2
View File
@@ -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).
+18 -5
View File
@@ -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.
+69
View File
@@ -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.
+81
View File
@@ -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
+2
View File
@@ -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.