feat: add optional ai and mcp automation

This commit is contained in:
Christian Krakau-Louis
2026-05-23 20:45:19 +02:00
parent e566a2d1e9
commit 0d15c16e6a
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