Complete snapshot of /opt/microdao-daarion/ from NODE1 (144.76.224.179).
This represents the actual running production code that has diverged
significantly from the previous main branch.
Key changes from old main:
- Gateway (http_api.py): expanded from ~40KB to 164KB with full agent support
- Router: new /v1/agents/{id}/infer endpoint with vision + DeepSeek routing
- Behavior Policy: SOWA v2.2 (3-level: FULL/ACK/SILENT)
- Agent Registry: config/agent_registry.yml as single source of truth
- 13 agents configured (was 3)
- Memory service integration
- CrewAI teams and roles
Excluded from snapshot: venv/, .env, data/, backups, .tgz archives
Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
11 KiB
Python
368 lines
11 KiB
Python
"""
|
||
Privacy Gate Middleware
|
||
=======================
|
||
Обов'язковий middleware для контролю приватності на Router рівні.
|
||
|
||
Правила:
|
||
- public: повний доступ + логування контенту
|
||
- team: тільки для членів команди + логування metadata
|
||
- confidential: тільки sanitized або з consent + без логування контенту
|
||
- e2ee: НІКОЛИ plaintext на сервері
|
||
"""
|
||
|
||
import re
|
||
import logging
|
||
from typing import Optional, Dict, Any, Callable
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class PrivacyMode(str, Enum):
|
||
"""Режими приватності"""
|
||
PUBLIC = "public"
|
||
TEAM = "team"
|
||
CONFIDENTIAL = "confidential"
|
||
E2EE = "e2ee"
|
||
|
||
class GateAction(str, Enum):
|
||
"""Дії Privacy Gate"""
|
||
ALLOW = "allow"
|
||
SANITIZE = "sanitize"
|
||
BLOCK = "block"
|
||
REQUIRE_CONSENT = "require_consent"
|
||
|
||
@dataclass
|
||
class GateResult:
|
||
"""Результат перевірки Privacy Gate"""
|
||
action: GateAction
|
||
allow_content: bool
|
||
log_content: bool
|
||
transform: Optional[Callable[[str], str]] = None
|
||
reason: str = ""
|
||
consent_required: bool = False
|
||
|
||
# PII Patterns для sanitization
|
||
PII_PATTERNS = [
|
||
(r'\b\d{10,13}\b', '[PHONE]'), # Phone numbers
|
||
(r'\b[\w.-]+@[\w.-]+\.\w+\b', '[EMAIL]'), # Emails
|
||
(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b', '[CARD]'), # Credit cards
|
||
(r'\b\d{8,10}\b', '[ID]'), # IDs
|
||
(r'\b(?:\+38)?0\d{9}\b', '[UA_PHONE]'), # UA phone numbers
|
||
(r'\b\d{10}\b', '[IPN]'), # Ukrainian IPN
|
||
]
|
||
|
||
def remove_pii(text: str) -> str:
|
||
"""Видаляє PII з тексту"""
|
||
result = text
|
||
for pattern, replacement in PII_PATTERNS:
|
||
result = re.sub(pattern, replacement, result)
|
||
return result
|
||
|
||
def sanitize_context(context: str, max_length: int = 200) -> str:
|
||
"""
|
||
Sanitizes context for confidential mode.
|
||
|
||
1. Видаляє PII
|
||
2. Обмежує довжину
|
||
3. Узагальнює
|
||
"""
|
||
if not context:
|
||
return "[Empty context]"
|
||
|
||
# Step 1: Remove PII
|
||
sanitized = remove_pii(context)
|
||
|
||
# Step 2: Truncate
|
||
if len(sanitized) > max_length:
|
||
sanitized = sanitized[:max_length] + "..."
|
||
|
||
# Step 3: Add marker
|
||
return f"[Sanitized] {sanitized}"
|
||
|
||
def create_summary(context: str, max_words: int = 20) -> str:
|
||
"""
|
||
Створює короткий summary для handoff.
|
||
"""
|
||
if not context:
|
||
return "[No context]"
|
||
|
||
# Remove PII first
|
||
clean = remove_pii(context)
|
||
|
||
# Take first N words
|
||
words = clean.split()[:max_words]
|
||
summary = " ".join(words)
|
||
|
||
if len(clean.split()) > max_words:
|
||
summary += "..."
|
||
|
||
return summary
|
||
|
||
|
||
class PrivacyGate:
|
||
"""
|
||
Privacy Gate - контролює доступ до контенту на основі mode.
|
||
|
||
Usage:
|
||
gate = PrivacyGate()
|
||
result = gate.check(request)
|
||
|
||
if result.action == GateAction.BLOCK:
|
||
raise PrivacyViolation(result.reason)
|
||
|
||
if result.transform:
|
||
request.context = result.transform(request.context)
|
||
"""
|
||
|
||
def __init__(self, config: Dict[str, Any] = None):
|
||
self.config = config or {}
|
||
self._enabled = self.config.get("enabled", True)
|
||
|
||
def check(self,
|
||
mode: str,
|
||
user_id: str = None,
|
||
team_id: str = None,
|
||
user_consent: bool = False,
|
||
team_members: list = None) -> GateResult:
|
||
"""
|
||
Перевіряє запит і повертає рішення.
|
||
|
||
Args:
|
||
mode: Режим приватності (public/team/confidential/e2ee)
|
||
user_id: ID користувача
|
||
team_id: ID команди
|
||
user_consent: Чи є згода користувача
|
||
team_members: Список членів команди (для team mode)
|
||
|
||
Returns:
|
||
GateResult з рішенням
|
||
"""
|
||
if not self._enabled:
|
||
return GateResult(
|
||
action=GateAction.ALLOW,
|
||
allow_content=True,
|
||
log_content=True,
|
||
reason="Privacy Gate disabled"
|
||
)
|
||
|
||
try:
|
||
privacy_mode = PrivacyMode(mode.lower())
|
||
except ValueError:
|
||
# Unknown mode -> treat as confidential
|
||
privacy_mode = PrivacyMode.CONFIDENTIAL
|
||
logger.warning(f"Unknown privacy mode: {mode}, treating as confidential")
|
||
|
||
# Route to specific handler
|
||
if privacy_mode == PrivacyMode.PUBLIC:
|
||
return self._check_public()
|
||
elif privacy_mode == PrivacyMode.TEAM:
|
||
return self._check_team(user_id, team_id, team_members)
|
||
elif privacy_mode == PrivacyMode.CONFIDENTIAL:
|
||
return self._check_confidential(user_consent)
|
||
elif privacy_mode == PrivacyMode.E2EE:
|
||
return self._check_e2ee()
|
||
|
||
# Default: block unknown
|
||
return GateResult(
|
||
action=GateAction.BLOCK,
|
||
allow_content=False,
|
||
log_content=False,
|
||
reason="Unknown privacy mode"
|
||
)
|
||
|
||
def _check_public(self) -> GateResult:
|
||
"""Public mode: повний доступ"""
|
||
return GateResult(
|
||
action=GateAction.ALLOW,
|
||
allow_content=True,
|
||
log_content=True,
|
||
reason="Public mode - full access"
|
||
)
|
||
|
||
def _check_team(self, user_id: str, team_id: str,
|
||
team_members: list = None) -> GateResult:
|
||
"""Team mode: тільки для членів команди"""
|
||
# Check membership
|
||
if team_members and user_id and user_id not in team_members:
|
||
return GateResult(
|
||
action=GateAction.BLOCK,
|
||
allow_content=False,
|
||
log_content=False,
|
||
reason=f"User {user_id} is not a member of team {team_id}"
|
||
)
|
||
|
||
return GateResult(
|
||
action=GateAction.ALLOW,
|
||
allow_content=True,
|
||
log_content=False, # Тільки metadata
|
||
reason="Team mode - metadata logging only"
|
||
)
|
||
|
||
def _check_confidential(self, user_consent: bool) -> GateResult:
|
||
"""Confidential mode: sanitized або з consent"""
|
||
if user_consent:
|
||
# З consent - дозволяємо, але без логування контенту
|
||
return GateResult(
|
||
action=GateAction.ALLOW,
|
||
allow_content=True,
|
||
log_content=False,
|
||
reason="Confidential mode - user consent given"
|
||
)
|
||
else:
|
||
# Без consent - тільки sanitized
|
||
return GateResult(
|
||
action=GateAction.SANITIZE,
|
||
allow_content=True,
|
||
log_content=False,
|
||
transform=sanitize_context,
|
||
consent_required=True,
|
||
reason="Confidential mode - sanitizing content"
|
||
)
|
||
|
||
def _check_e2ee(self) -> GateResult:
|
||
"""E2EE mode: НІКОЛИ plaintext на сервері"""
|
||
return GateResult(
|
||
action=GateAction.BLOCK,
|
||
allow_content=False,
|
||
log_content=False,
|
||
reason="E2EE mode - server-side processing not allowed"
|
||
)
|
||
|
||
def apply_transform(self, content: str, result: GateResult) -> str:
|
||
"""Застосовує transform якщо потрібно"""
|
||
if result.transform and content:
|
||
return result.transform(content)
|
||
return content
|
||
|
||
|
||
class PrivacyViolation(Exception):
|
||
"""Виняток при порушенні приватності"""
|
||
pass
|
||
|
||
|
||
# ==================== MIDDLEWARE ====================
|
||
|
||
async def privacy_gate_middleware(request, call_next, gate: PrivacyGate = None):
|
||
"""
|
||
FastAPI/Starlette middleware для Privacy Gate.
|
||
|
||
Usage:
|
||
from privacy_gate import privacy_gate_middleware, PrivacyGate
|
||
|
||
gate = PrivacyGate()
|
||
|
||
@app.middleware("http")
|
||
async def privacy_middleware(request, call_next):
|
||
return await privacy_gate_middleware(request, call_next, gate)
|
||
"""
|
||
if gate is None:
|
||
gate = PrivacyGate()
|
||
|
||
# Extract mode from request
|
||
mode = "public" # Default
|
||
user_consent = False
|
||
|
||
# Try to get from headers
|
||
if hasattr(request, 'headers'):
|
||
mode = request.headers.get("X-Privacy-Mode", "public")
|
||
user_consent = request.headers.get("X-User-Consent", "false").lower() == "true"
|
||
|
||
# Check privacy
|
||
result = gate.check(
|
||
mode=mode,
|
||
user_consent=user_consent
|
||
)
|
||
|
||
# Handle based on result
|
||
if result.action == GateAction.BLOCK:
|
||
from fastapi.responses import JSONResponse
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"error": "Privacy violation", "reason": result.reason}
|
||
)
|
||
|
||
# Add privacy info to request state
|
||
if hasattr(request, 'state'):
|
||
request.state.privacy_result = result
|
||
request.state.log_content = result.log_content
|
||
|
||
# Continue with request
|
||
response = await call_next(request)
|
||
|
||
return response
|
||
|
||
|
||
# ==================== ROUTER INTEGRATION ====================
|
||
|
||
def check_privacy_for_handoff(
|
||
source_agent: str,
|
||
target_agent: str,
|
||
context: str,
|
||
mode: str = "public",
|
||
user_consent: bool = False
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Перевіряє приватність перед handoff між агентами.
|
||
|
||
Returns:
|
||
{
|
||
"allowed": bool,
|
||
"context": str (можливо sanitized),
|
||
"reason": str,
|
||
"consent_required": bool
|
||
}
|
||
"""
|
||
gate = PrivacyGate()
|
||
result = gate.check(mode=mode, user_consent=user_consent)
|
||
|
||
if result.action == GateAction.BLOCK:
|
||
return {
|
||
"allowed": False,
|
||
"context": None,
|
||
"reason": result.reason,
|
||
"consent_required": False
|
||
}
|
||
|
||
# Apply transform if needed
|
||
final_context = gate.apply_transform(context, result)
|
||
|
||
return {
|
||
"allowed": True,
|
||
"context": final_context,
|
||
"reason": result.reason,
|
||
"consent_required": result.consent_required,
|
||
"was_sanitized": result.transform is not None
|
||
}
|
||
|
||
|
||
# ==================== AUDIT HELPERS ====================
|
||
|
||
def create_privacy_audit_event(
|
||
action: str,
|
||
mode: str,
|
||
user_id: str,
|
||
result: GateResult,
|
||
content_hash: str = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Створює audit event для Privacy Gate.
|
||
|
||
Note: НІКОЛИ не включає контент, тільки hash якщо потрібно.
|
||
"""
|
||
from datetime import datetime
|
||
|
||
return {
|
||
"event_type": "privacy.check",
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
"action": action,
|
||
"mode": mode,
|
||
"user_id": user_id,
|
||
"gate_action": result.action.value,
|
||
"allow_content": result.allow_content,
|
||
"log_content": result.log_content,
|
||
"consent_required": result.consent_required,
|
||
"reason": result.reason,
|
||
"content_hash": content_hash # Hash, not content
|
||
}
|