feat(security): API streaming fail-closed + /image privé + target_memory prefix fix

P0-B — /api/v1/traces/stream/image retiré de _PUBLIC_PATHS :
- Bearer token obligatoire pour upload d'image
- Évite uploads anonymes de contenu arbitraire

P0-C — Fail-closed si RPA_API_TOKEN absent :
- sys.exit(1) au démarrage avec message fatal
- Mode dev : RPA_AUTH_DISABLED=true pour désactiver explicitement
- Log INFO des 8 premiers chars du token (diagnostic)

Fix target_memory prefix empilé :
- Strip "memory_" répétés avant stockage dans replay_memory.py
- Évite "memory_memory_memory_template_matching" en base

live_session_manager : améliorations mineures de la gestion sessions.

10 tests auth API stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-14 16:49:02 +02:00
parent 376e4a88b3
commit 93ef93e563
4 changed files with 490 additions and 13 deletions

View File

@@ -37,6 +37,13 @@ from .execution_plan_runner import (
inject_plan_into_queue,
)
# Pipeline d'anonymisation PII (OCR + NER côté serveur).
# Import paresseux : on ne charge pas docTR tant qu'aucune image n'est reçue.
try:
from core.anonymisation import blur_pii_on_image as _blur_pii_on_image
except ImportError:
_blur_pii_on_image = None
# Instance globale du vérificateur de replay (comparaison screenshots avant/après)
_replay_verifier = ReplayVerifier()
_replay_learner = ReplayLearner()
@@ -82,25 +89,77 @@ logger = logging.getLogger("api_stream")
# =========================================================================
# Authentification par token Bearer (sécurité HIGH)
# =========================================================================
# Le token est lu depuis l'environnement ou généré au démarrage.
# Le token est lu depuis l'environnement obligatoirement.
# Tous les endpoints requièrent le header Authorization: Bearer <token>,
# sauf /health, /docs et /openapi.json (publics).
API_TOKEN = os.environ.get("RPA_API_TOKEN", secrets.token_hex(32))
#
# Fail-closed P0-C :
# - En production (défaut), RPA_API_TOKEN DOIT être défini.
# - Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true
# Dans ce mode, aucun token n'est requis et l'API log un WARNING au boot.
# - Sans token ET sans RPA_AUTH_DISABLED=true → arrêt immédiat du process
# (sys.exit 1) avec message fatal clair. On NE génère PLUS de token
# aléatoire en silence : cela cassait tous les agents clients sans bruit.
_AUTH_DISABLED = os.environ.get("RPA_AUTH_DISABLED", "").lower() in (
"1", "true", "yes",
)
_API_TOKEN_ENV = os.environ.get("RPA_API_TOKEN", "").strip()
if _AUTH_DISABLED:
# Mode dev explicite : on tolère l'absence de token mais on log très fort.
logger.warning(
"[SÉCURITÉ] RPA_AUTH_DISABLED=true — authentification Bearer DÉSACTIVÉE. "
"NE JAMAIS utiliser cette configuration en production. Tous les "
"endpoints sont accessibles sans token."
)
API_TOKEN = _API_TOKEN_ENV or secrets.token_hex(32)
elif not _API_TOKEN_ENV:
# Fail-closed : pas de génération silencieuse. On arrête le serveur.
_FATAL_MSG = (
"[SÉCURITÉ] FATAL — RPA_API_TOKEN est absent ou vide. "
"Refus de démarrer le serveur de streaming : générer un token "
"aléatoire interne casserait tous les agents clients qui utilisent "
"le token persistant (.env.local). "
"Pour fixer : définir RPA_API_TOKEN=<32 hex chars> dans l'environnement. "
"Pour désactiver l'auth en dev local : RPA_AUTH_DISABLED=true."
)
logger.critical(_FATAL_MSG)
print(_FATAL_MSG, flush=True)
# Utiliser sys.exit pour un arrêt propre (raise RuntimeError est accroché
# par uvicorn sur Python 3.11, sys.exit remonte BaseException).
import sys as _sys
_sys.exit(1)
else:
API_TOKEN = _API_TOKEN_ENV
# Log non-sensible : 8 premiers caractères seulement pour aider au diagnostic.
logger.info(
f"[SÉCURITÉ] Token API chargé (8 premiers caractères : "
f"{API_TOKEN[:8]}…) — auth Bearer obligatoire"
)
# Endpoints publics (pas besoin de token)
# En production, /docs et /redoc sont désactivés (voir ci-dessous)
# Paths publics : pas de token requis
# /replay/next est public car l'agent Rust legacy n'envoie pas de token
# et c'est un endpoint read-only (polling, pas d'écriture)
#
# Fix P0-B : /api/v1/traces/stream/image RETIRÉ de la liste publique.
# L'upload d'image écrit sur disque + déclenche du travail VLM : exiger
# un token Bearer. Tous les agents V1 déployés envoient déjà le token
# (cf. agent_v0/agent_v1/network/streamer.py:_auth_headers).
_PUBLIC_PATHS = {
"/health", "/docs", "/openapi.json", "/redoc",
"/api/v1/traces/stream/replay/next",
"/api/v1/traces/stream/image",
}
async def _verify_token(request: Request):
"""Middleware de vérification du token API Bearer."""
"""Middleware de vérification du token API Bearer.
Bypass si RPA_AUTH_DISABLED=true (mode dev local uniquement).
"""
if _AUTH_DISABLED:
return
if request.url.path in _PUBLIC_PATHS:
return
auth = request.headers.get("Authorization", "")
@@ -490,6 +549,12 @@ class ReplayResultReport(BaseModel):
target_spec: Optional[Dict[str, Any]] = None # Spec complete de la cible
# Correction humaine (mode apprentissage supervisé)
correction: Optional[Dict[str, Any]] = None # {x_pct, y_pct, uia_snapshot, crop_b64}
# Sécurité : signalement d'un dialogue système critique détecté
# (UAC, CredUI, SmartScreen...). Quand ce champ est présent, l'agent
# refuse toute interaction et le serveur bascule en paused_need_help.
# Cf. agent_v1/core/system_dialog_guard.py
system_dialog: Optional[Dict[str, Any]] = None # {category, matched_signal, matched_value, reason, context}
needs_human: Optional[bool] = None
class ErrorCallbackConfig(BaseModel):
@@ -837,6 +902,40 @@ _som_enrichment_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="som_enrich",
)
# ThreadPool dédié à l'anonymisation PII (OCR + NER).
# Activable via RPA_PII_BLUR_SERVER (default : true). 1 worker suffit, le
# pipeline est rapide (<2 s par screenshot) et le blur peut prendre du retard
# sur la capture sans bloquer ni le replay ni le grounding (ils utilisent le
# fichier _full.png brut).
_PII_BLUR_ENABLED = os.environ.get("RPA_PII_BLUR_SERVER", "true").lower() in ("true", "1", "yes")
_pii_blur_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="pii_blur",
)
def _produce_blurred_version(raw_path: str, shot_id: str) -> None:
"""Exécute (en thread) le pipeline de blur PII sur un screenshot brut.
Écrit `<stem>_blurred.png` à côté du fichier brut pour l'affichage
dashboard/cleaner. Le fichier brut `<stem>.png` reste intact pour le
grounding, le replay et l'entraînement.
"""
if _blur_pii_on_image is None:
return
try:
raw = Path(raw_path)
out = raw.with_name(f"{raw.stem}_blurred{raw.suffix or '.png'}")
# Évite de retraiter si déjà floutée (robustesse aux doubles réceptions)
if out.exists() and out.stat().st_mtime >= raw.stat().st_mtime:
return
result = _blur_pii_on_image(raw, out)
logger.debug(
"pii_blur : %s%d PII (%.0fms, ner=%s)",
shot_id, result.count, result.elapsed_ms, result.ner_engine,
)
except Exception as e: # noqa: BLE001
logger.warning("pii_blur : échec sur %s (%s)", shot_id, e)
# Clics en attente d'enrichissement (le screenshot n'est pas encore arrivé)
# Clé : (session_id, screenshot_id) → dict avec les infos nécessaires
_pending_click_enrichments: Dict[tuple, Dict[str, Any]] = {}
@@ -1163,6 +1262,20 @@ async def stream_image(
file_path_str = str(file_path)
# Anonymisation PII côté serveur (OCR + NER + blur ciblé).
# On ne floute QUE les screenshots affichés dans le dashboard / cleaner :
# shot_XXXX_full (screenshots d'action) et heartbeats (vue live).
# Les crops, focus, window sont utilisés pour le grounding/template — pas
# d'affichage humain direct donc pas besoin de version floutée.
# Le fichier brut (shot_XXXX_full.png) reste intact pour le replay,
# le grounding VLM et l'entraînement. La version floutée est écrite en
# parallèle sous shot_XXXX_full_blurred.png.
if _PII_BLUR_ENABLED and _blur_pii_on_image is not None and (
("_full" in shot_id and shot_id.startswith("shot_"))
or shot_id.startswith("heartbeat_")
):
_pii_blur_executor.submit(_produce_blurred_version, file_path_str, shot_id)
# Crops : traitement léger (pas d'analyse ScreenAnalyzer)
if "_crop" in shot_id:
result = worker.process_crop_direct(session_id, shot_id, file_path_str)
@@ -3212,6 +3325,92 @@ async def report_action_result(report: ReplayResultReport):
replay_state["completed_actions"] += 1
replay_state["current_action_index"] += 1
elif not report.success and (report.system_dialog or (report.error or "").startswith("system_dialog:")):
# ── SÉCURITÉ : dialogue système Windows détecté (UAC / CredUI / SmartScreen) ──
# L'agent REFUSE de cliquer automatiquement sur ces dialogues.
# On bascule immédiatement en paused_need_help — l'humain doit
# valider manuellement (saisir mdp, autoriser l'élévation…).
# Cf. agent_v1/core/system_dialog_guard.py
_sys_info = report.system_dialog or {}
_sys_category = (
_sys_info.get("category")
or (report.error or "system_dialog:unknown").split(":", 1)[-1]
)
_sys_reason = _sys_info.get("reason", "")
_tspec_sys = (original_action or {}).get("target_spec") or report.target_spec or {}
# Message utilisateur adapté à la catégorie
_cat_messages = {
"uac_consent": (
"Une demande d'élévation de privilèges (UAC) est apparue. "
"Je ne clique jamais automatiquement dessus — merci de valider "
"ou refuser toi-même, puis relance-moi."
),
"windows_credential_prompt": (
"Windows me demande un mot de passe / identifiants. "
"Merci de remplir toi-même, puis relance-moi."
),
"smartscreen": (
"SmartScreen a bloqué l'application. "
"Merci de vérifier et débloquer manuellement si légitime."
),
"windows_defender": (
"Windows Defender signale une alerte. "
"Merci de vérifier manuellement."
),
"driver_install": (
"Une installation de pilote est demandée. "
"Merci de valider manuellement."
),
}
_pause_msg_sys = _cat_messages.get(
_sys_category,
"Un dialogue système Windows est apparu. "
"Je ne clique pas automatiquement dessus — merci de gérer manuellement."
)
replay_state["status"] = "paused_need_help"
replay_state["failed_action"] = {
"action_id": action_id,
"type": (original_action or {}).get("type", "unknown"),
"target_description": f"Dialogue système : {_sys_category}",
"screenshot_b64": screenshot_after or report.screenshot,
"target_spec": _tspec_sys,
"reason": "system_dialog",
"system_dialog": _sys_info,
"error_detail": _sys_reason or (report.error or ""),
}
replay_state["pause_message"] = _pause_msg_sys
error_entry = {
"action_id": action_id,
"error": f"system_dialog:{_sys_category}",
"retry_count": retry_count,
"timestamp": time.time(),
}
replay_state["error_log"].append(error_entry)
logger.critical(
f"[SECURITE] Replay PAUSE supervisee (dialogue systeme) : "
f"{action_id} — categorie={_sys_category}"
f"signal={_sys_info.get('matched_signal', '?')}='{_sys_info.get('matched_value', '?')}' "
f"— reason={_sys_reason}"
)
try:
log_replay_failure(
replay_id=replay_state["replay_id"],
action_id=action_id,
target_spec=_tspec_sys,
screenshot_b64=screenshot_after or report.screenshot,
error=f"system_dialog:{_sys_category}",
extra={
"system_dialog": _sys_info,
"category": _sys_category,
"matched_signal": _sys_info.get("matched_signal", ""),
"matched_value": _sys_info.get("matched_value", ""),
},
)
except Exception as _log_exc:
logger.debug("log_replay_failure skip (system_dialog): %s", _log_exc)
elif not report.success and agent_warning == "wrong_window":
# L'agent a détecté en pré-vérification que la fenêtre active
# n'est pas celle attendue. Même philosophie que no_screen_change :

View File

@@ -65,7 +65,8 @@ class LiveSessionState:
class LiveSessionManager:
"""Gère les sessions live en mémoire côté serveur avec persistance disque."""
def __init__(self, persist_dir: str = "data/streaming_sessions"):
def __init__(self, persist_dir: str = "data/streaming_sessions",
live_sessions_dir: Optional[str] = None):
self._sessions: Dict[str, LiveSessionState] = {}
self._lock = threading.Lock()
self._persist_dir = Path(persist_dir)
@@ -74,11 +75,16 @@ class LiveSessionManager:
self._persist_counter = 0 # Compteur pour limiter la fréquence de persistance
self._persist_interval = 10 # Persister toutes les N modifications
# Dossier des sessions live (JSONL + screenshots)
self._live_sessions_dir = Path(live_sessions_dir) if live_sessions_dir else None
# Charger les sessions persistées au démarrage
self._load_persisted_sessions()
# Reconstruire les sessions depuis les live_events.jsonl sur disque
self._discover_sessions_from_disk()
def _load_persisted_sessions(self):
"""Charger les sessions sauvegardées au démarrage."""
"""Charger les sessions sauvegardées au démarrage (JSON state files)."""
count = 0
for session_file in sorted(self._persist_dir.glob("sess_*.json")):
try:
@@ -92,6 +98,66 @@ class LiveSessionManager:
if count:
logger.info(f"{count} session(s) restaurée(s) depuis {self._persist_dir}")
def _discover_sessions_from_disk(self):
"""Découvrir les sessions depuis les live_events.jsonl sur disque.
Reconstruit les sessions manquantes du session_manager en scannant :
- live_sessions/sess_*/live_events.jsonl (sessions racine)
- live_sessions/{machine_id}/sess_*/live_events.jsonl (multi-machine)
Ne touche pas aux sessions déjà chargées depuis le JSON persist.
"""
if self._live_sessions_dir is None:
return
live_dir = self._live_sessions_dir
if not live_dir.exists():
return
discovered = 0
for jsonl_file in sorted(live_dir.glob("**/live_events.jsonl")):
session_dir = jsonl_file.parent
session_id = session_dir.name
if not session_id.startswith("sess_"):
continue
if session_id in self._sessions:
continue
# Déduire le machine_id depuis le chemin parent
parent_name = session_dir.parent.name
if parent_name == live_dir.name:
machine_id = "default"
else:
machine_id = parent_name
# Compter events et screenshots
events_count = 0
try:
with open(jsonl_file, 'r', encoding='utf-8') as f:
for _ in f:
events_count += 1
except Exception:
pass
shots_dir = session_dir / "shots"
shots_count = len(list(shots_dir.glob("shot_*_full.png"))) if shots_dir.exists() else 0
# Créer la session en mémoire
session = LiveSessionState(
session_id=session_id,
machine_id=machine_id,
finalized=False,
)
# Stocker le nombre d'events/shots dans les métadonnées
session.shot_paths = {f"shot_{i:04d}": "" for i in range(shots_count)}
self._sessions[session_id] = session
discovered += 1
if discovered:
logger.info(
f"{discovered} session(s) découverte(s) depuis {live_dir} "
f"(total: {len(self._sessions)} sessions en mémoire)"
)
def _persist_session(self, session_id: str):
"""Sauvegarder une session sur disque (appelé périodiquement)."""
session = self._sessions.get(session_id)
@@ -102,7 +168,7 @@ class LiveSessionManager:
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(session.to_dict(), f, ensure_ascii=False)
except Exception as e:
logger.debug(f"Erreur persistance session {session_id}: {e}")
logger.warning(f"Erreur persistance session {session_id}: {e}")
def _maybe_persist(self, session_id: str):
"""Persister si le compteur atteint l'intervalle."""
@@ -180,6 +246,17 @@ class LiveSessionManager:
if meta_val is not None:
info[meta_key] = meta_val
session.last_window_info = info
# Exploiter window_capture (envoyé par l'agent avec la capture fenêtre)
# pour enrichir last_window_info avec le titre précis de la fenêtre cliquée
window_capture = event_data.get("window_capture")
if window_capture and isinstance(window_capture, dict):
wc_title = window_capture.get("title", "").strip()
wc_app = window_capture.get("app_name", "").strip()
if wc_title:
session.last_window_info["title"] = wc_title
if wc_app:
session.last_window_info["app_name"] = wc_app
# Accumuler les titres/apps pour le nommage automatique
title = session.last_window_info.get("title", "").strip()
app_name = session.last_window_info.get("app_name", "").strip()
@@ -221,18 +298,41 @@ class LiveSessionManager:
import socket
# Construire les événements au format RawSession
# Important : copier TOUTES les données de l'événement (pos, text, keys, button...)
# car Event.from_dict() met tout sauf t/type/window/screenshot_id dans event.data,
# et le GraphBuilder utilise event.data pour construire les actions.
events = []
for evt in session.events:
window_info = {
"title": evt.get("window_title", session.last_window_info.get("title", "")),
"app_name": evt.get("app_name", session.last_window_info.get("app_name", "unknown")),
}
events.append({
# Extraire window info (plusieurs formats possibles)
window_raw = evt.get("window")
if isinstance(window_raw, dict):
window_info = {
"title": window_raw.get("title", session.last_window_info.get("title", "")),
"app_name": window_raw.get("app_name", session.last_window_info.get("app_name", "unknown")),
}
else:
window_info = {
"title": evt.get("window_title", session.last_window_info.get("title", "")),
"app_name": evt.get("app_name", session.last_window_info.get("app_name", "unknown")),
}
raw_event = {
"t": evt.get("timestamp", 0),
"type": evt.get("type", "unknown"),
"window": window_info,
"screenshot_id": evt.get("screenshot_id"),
})
}
# Copier les données spécifiques au type d'événement
# (pos, button, text, keys, etc.) — indispensable pour le replay
_skip_keys = {"type", "timestamp", "window", "window_title",
"app_name", "screenshot_id", "machine_id",
"screen_metadata", "vision_info"}
for key, value in evt.items():
if key not in _skip_keys and key not in raw_event:
raw_event[key] = value
events.append(raw_event)
# Construire les screenshots au format RawSession
screenshots = []

View File

@@ -248,7 +248,14 @@ def memory_record_success(
try:
from core.learning.target_memory_store import TargetFingerprint
# Stripper les préfixes "memory_" empilés pour ne garder que
# la méthode de résolution originale (ex: template_matching).
# Sans ça, le cycle lookup → record → lookup empile "memory_"
# indéfiniment : memory_memory_memory_template_matching.
method_clean = method or "v4_unknown"
while method_clean.startswith("memory_"):
method_clean = method_clean[len("memory_"):]
method_clean = method_clean or "v4_unknown"
fingerprint = TargetFingerprint(
element_id=f"v4_{method_clean}",
bbox=(x_pct, y_pct, 0.0, 0.0),