Merge pull request #200 from christianlouis/codex/m16-ai-mcp-automation
[codex] Add optional AI and MCP automation
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user