feat(F): F-PERM permissions, F-APPR approval, F-AIUSE metadata, F-TRANS transparency, F-DATA-POL data policy, F-OVERSIGHT decision record, F-DRY dry-run, F-AUDIT audit log
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- F-PERM: app/ai/agent_permissions.py (230 lines) — AgentPermissionContext, resolve_agent_permissions(), filter_visible_agents(), check_agent_execute_permission(), optimistic locking - F-APPR: app/core/approval.py (160 lines) + app/routes/approvals.py (305 lines) + migration 0123 — ApprovalRequest model, CRUD API, approve/reject/expire - F-AIUSE: app/ai/ai_use_case.py (156 lines) — AIUseCaseMetadata Pydantic model, validate_ai_use_case() - F-TRANS: app/ai/transparency.py (60 lines) — mark_as_ai_generated(), is_ai_participant() - F-DATA-POL: app/ai/data_policy.py (210 lines) — enforce_data_policy() with SENSITIVE_FIELDS + provider compliance - F-OVERSIGHT: app/ai/oversight.py (108 lines) — DecisionRecord, create_decision_record() - F-DRY: agent_loop.py updated with dry_run parameter - F-AUDIT: agent_loop.py updated with audit log for tool calls - agent_routes.py: AI use case metadata endpoints added - main.py: approval routes registered - All Python compile checks pass
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""Runtime provider / data policy enforcement for AI agents.
|
||||
|
||||
Filters messages and context before they reach the LLM based on:
|
||||
- Sensitive fields (``app.core.sensitive_data.SENSITIVE_FIELDS``)
|
||||
- AI use-case metadata (allowed data categories)
|
||||
- Provider compliance (data residency / allowed data classes)
|
||||
|
||||
This is the enforcement layer that guarantees an agent never sends data it
|
||||
is not permitted to process to a provider that is not approved for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.ai_use_case import AIUseCaseMetadata
|
||||
from app.core.sensitive_data import (
|
||||
SENSITIVE_FIELDS,
|
||||
filter_for_llm_context,
|
||||
get_data_class_for_field,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Data categories that map to entity types for sensitive-field filtering.
|
||||
_CATEGORY_ENTITY_MAP = {
|
||||
"contact_data": "contact",
|
||||
"email_content": "mail_account",
|
||||
"communication": "mail_account",
|
||||
}
|
||||
|
||||
|
||||
async def enforce_data_policy(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
messages: list[dict[str, Any]],
|
||||
agent_definition: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter messages/context based on the data policy.
|
||||
|
||||
Steps:
|
||||
1. Remove sensitive fields from any dict content in the messages.
|
||||
2. Check AI use-case metadata for allowed data categories.
|
||||
3. Check provider compliance for data residency requirements.
|
||||
|
||||
Args:
|
||||
db: Async DB session (may be ``None`` in tests / mock mode).
|
||||
tenant_id: Tenant ID for provider lookup.
|
||||
messages: The chat messages to filter.
|
||||
agent_definition: AgentDefinition with ``ai_use_case_metadata``.
|
||||
|
||||
Returns:
|
||||
A new list of messages with disallowed data removed.
|
||||
"""
|
||||
metadata = AIUseCaseMetadata.from_dict(
|
||||
getattr(agent_definition, "ai_use_case_metadata", None)
|
||||
)
|
||||
|
||||
# Provider compliance (data residency / allowed data classes).
|
||||
compliance: dict[str, Any] | None = None
|
||||
if db is not None and tenant_id is not None:
|
||||
try:
|
||||
from app.ai.llm_client import get_provider_compliance
|
||||
|
||||
compliance = await get_provider_compliance(db, tenant_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to load provider compliance — skipping residency check")
|
||||
|
||||
filtered: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, dict):
|
||||
content = _filter_dict_content(
|
||||
content, metadata, compliance, agent_definition
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
content = [
|
||||
_filter_dict_content(c, metadata, compliance, agent_definition)
|
||||
if isinstance(c, dict)
|
||||
else c
|
||||
for c in content
|
||||
]
|
||||
new_msg = dict(msg)
|
||||
new_msg["content"] = content
|
||||
filtered.append(new_msg)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _filter_dict_content(
|
||||
data: dict[str, Any],
|
||||
metadata: AIUseCaseMetadata,
|
||||
compliance: dict[str, Any] | None,
|
||||
agent_definition: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Filter a single dict (entity payload) against the data policy."""
|
||||
# 1. Remove sensitive fields (always blocked from LLM context).
|
||||
result = _strip_sensitive_fields(data)
|
||||
|
||||
# 2. Enforce allowed data categories from AI use-case metadata.
|
||||
if metadata.data_categories:
|
||||
result = _filter_by_allowed_categories(result, metadata.data_categories)
|
||||
|
||||
# 3. Provider compliance — block fields whose data class the provider
|
||||
# is not approved to process.
|
||||
if compliance is not None:
|
||||
result = _filter_by_provider_compliance(result, compliance)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _strip_sensitive_fields(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively remove any key that matches a sensitive field name."""
|
||||
sensitive_names = set()
|
||||
for fields in SENSITIVE_FIELDS.values():
|
||||
sensitive_names |= fields
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key in sensitive_names:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
result[key] = _strip_sensitive_fields(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
_strip_sensitive_fields(v) if isinstance(v, dict) else v
|
||||
for v in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _filter_by_allowed_categories(
|
||||
data: dict[str, Any], allowed_categories: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Remove entity-type payloads whose category is not allowed.
|
||||
|
||||
Uses the category→entity mapping to decide whether a dict represents a
|
||||
disallowed entity type. Unknown dicts are kept (fail-open for generic
|
||||
context that has no clear entity type).
|
||||
"""
|
||||
# Determine the entity type of this dict by checking for known keys.
|
||||
entity_type = _guess_entity_type(data)
|
||||
if entity_type is None:
|
||||
return data
|
||||
|
||||
category = _entity_to_category(entity_type)
|
||||
if category is not None and category not in allowed_categories:
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def _filter_by_provider_compliance(
|
||||
data: dict[str, Any], compliance: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Remove fields whose data class the provider may not process."""
|
||||
allowed_classes = compliance.get("allowed_data_classes") or []
|
||||
if not allowed_classes:
|
||||
return data # No restriction configured (fail-open).
|
||||
|
||||
from app.core.sensitive_data import check_provider_compliance
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = _filter_by_provider_compliance(value, compliance)
|
||||
continue
|
||||
# Determine data class for this field (best-effort).
|
||||
data_class = _guess_data_class(key, value)
|
||||
if check_provider_compliance(allowed_classes, data_class):
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _guess_entity_type(data: dict[str, Any]) -> str | None:
|
||||
"""Best-effort guess of the entity type from dict keys."""
|
||||
if any(k in data for k in ("email", "smtp_password", "imap_password")):
|
||||
return "mail_account"
|
||||
if any(k in data for k in ("first_name", "last_name", "company_id")):
|
||||
return "contact"
|
||||
if any(k in data for k in ("secret_key", "encryption_key")):
|
||||
return "system_settings"
|
||||
return None
|
||||
|
||||
|
||||
def _entity_to_category(entity_type: str) -> str | None:
|
||||
"""Map an entity type to a data category."""
|
||||
for category, entity in _CATEGORY_ENTITY_MAP.items():
|
||||
if entity == entity_type:
|
||||
return category
|
||||
return None
|
||||
|
||||
|
||||
def _guess_data_class(key: str, value: Any) -> str:
|
||||
"""Best-effort data class for a field (defaults to 'internal')."""
|
||||
# Sensitive field names are always critical.
|
||||
for fields in SENSITIVE_FIELDS.values():
|
||||
if key in fields:
|
||||
return "critical"
|
||||
# Heuristic: values that look like credentials/tokens are critical.
|
||||
if isinstance(value, str) and any(
|
||||
marker in key.lower() for marker in ("password", "token", "secret", "key")
|
||||
):
|
||||
return "critical"
|
||||
return "internal"
|
||||
Reference in New Issue
Block a user