feat: floutage auto champs sensibles + fix routing actions fichiers

Floutage (conformité AI Act) :
- Détection OpenCV des champs de saisie (rectangles clairs avec texte)
- Flou gaussien avant stockage/envoi
- Activé par défaut (RPA_BLUR_SENSITIVE=true)
- <200ms par screenshot, 12 tests

Fix actions fichiers VWB :
- Pas de wait 5s pour les actions fichiers (inutile)
- Routing direct vers agent port 5006

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 16:24:01 +01:00
parent 40e5fba86c
commit 353c2a347e
6 changed files with 436 additions and 4 deletions

View File

@@ -33,6 +33,11 @@ SESSIONS_ROOT = BASE_DIR / "sessions"
TARGETED_CROP_SIZE = (400, 400)
SCREENSHOT_QUALITY = 85
# Floutage des données sensibles (conformité AI Act)
# Floute les champs de saisie dans les screenshots AVANT stockage/envoi
# Désactiver avec RPA_BLUR_SENSITIVE=false pour le développement/tests
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
# Monitoring
PERF_MONITOR_INTERVAL_S = 30
LOGS_DIR = BASE_DIR / "logs"

View File

@@ -21,6 +21,9 @@ logger = logging.getLogger(__name__)
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
# Floutage des données sensibles (conformité AI Act)
BLUR_SENSITIVE = os.environ.get("RPA_BLUR_SENSITIVE", "true").lower() in ("true", "1", "yes")
class CaptureHandler(BaseHTTPRequestHandler):
"""Retourne un screenshot frais a chaque requete GET /capture.
@@ -95,6 +98,14 @@ class CaptureHandler(BaseHTTPRequestHandler):
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
try:
from ..vision.blur_sensitive import blur_sensitive_regions
blur_sensitive_regions(img)
except ImportError:
logger.warning("Module blur_sensitive non disponible")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=80)
img_b64 = base64.b64encode(buf.getvalue()).decode()

View File

@@ -0,0 +1,203 @@
# agent_v1/vision/blur_sensitive.py
"""
Floutage automatique des zones de texte sensible dans les screenshots.
Conformité AI Act : les screenshots utilisés pour l'apprentissage ne doivent
pas contenir de données patient lisibles, mots de passe, etc.
Stratégie :
- Détecte les champs de saisie (rectangles clairs avec du texte)
- Floute leur CONTENU tout en gardant la structure UI visible
- Rapide (<200ms) : uniquement des opérations OpenCV simples, pas de deep learning
Usage :
from .blur_sensitive import blur_sensitive_regions
blur_sensitive_regions(img) # modifie l'image PIL en place
"""
import logging
import time
logger = logging.getLogger(__name__)
# Seuils configurables pour la détection des champs de saisie
_INPUT_FIELD_MIN_WIDTH = 50 # Largeur minimale en pixels
_INPUT_FIELD_MIN_HEIGHT = 15 # Hauteur minimale
_INPUT_FIELD_MAX_HEIGHT = 80 # Hauteur maximale (exclut les grandes zones)
_INPUT_FIELD_MIN_ASPECT_RATIO = 2.0 # Ratio largeur/hauteur minimum
_INPUT_FIELD_MIN_AREA = 1000 # Surface minimale en pixels²
_INPUT_FIELD_BRIGHTNESS_THRESHOLD = 200 # Luminosité moyenne minimum (fond clair)
# Pour les zones de texte multi-lignes (textarea)
_TEXTAREA_MIN_WIDTH = 100
_TEXTAREA_MIN_HEIGHT = 60
_TEXTAREA_MAX_HEIGHT = 500
_TEXTAREA_MIN_AREA = 8000
_TEXTAREA_MIN_ASPECT_RATIO = 1.2
# Paramètres du flou gaussien
_BLUR_KERNEL_SIZE = (23, 23)
_BLUR_SIGMA = 12
_BLUR_MARGIN = 3 # Marge en pixels pour garder le bord du champ visible
def blur_sensitive_regions(pil_image):
"""Floute les zones de texte sensible dans une image PIL.
Modifie l'image en place et la retourne.
Rapide : ~50-150ms selon la résolution.
Args:
pil_image: Image PIL (mode RGB)
Returns:
L'image PIL modifiée (même objet, modifié en place)
"""
try:
import cv2
import numpy as np
except ImportError:
logger.warning("OpenCV non disponible — floutage désactivé")
return pil_image
t0 = time.perf_counter()
# Conversion PIL → OpenCV (sans copie disque)
img_array = np.array(pil_image)
# PIL est RGB, OpenCV attend BGR
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
blurred_count = 0
# --- Passe 1 : Champs de saisie classiques (input text) ---
blurred_count += _blur_input_fields(img_bgr, gray)
# --- Passe 2 : Zones de texte multi-lignes (textarea, éditeurs) ---
blurred_count += _blur_textareas(img_bgr, gray)
if blurred_count > 0:
# Reconversion OpenCV → PIL en place
from PIL import Image as _PILImage
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
pil_image.paste(_PILImage.fromarray(img_rgb))
elapsed_ms = (time.perf_counter() - t0) * 1000
if blurred_count > 0:
logger.debug(f"Floutage : {blurred_count} zones en {elapsed_ms:.0f}ms")
return pil_image
def _blur_input_fields(img_bgr, gray):
"""Détecte et floute les champs de saisie simples (input text).
Les champs de saisie sont typiquement des rectangles à fond clair
(blanc ou gris très clair) avec du texte sombre dedans.
"""
import cv2
import numpy as np
count = 0
# Seuillage : zones quasi-blanches (fond des champs de saisie)
_, white_mask = cv2.threshold(gray, 230, 255, cv2.THRESH_BINARY)
# Nettoyage morphologique : fermer les petits trous dans les champs
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
white_mask = cv2.morphologyEx(white_mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(white_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
aspect_ratio = w / max(h, 1)
area = w * h
# Filtrage : forme typique d'un champ de saisie
if (w < _INPUT_FIELD_MIN_WIDTH or
h < _INPUT_FIELD_MIN_HEIGHT or
h > _INPUT_FIELD_MAX_HEIGHT or
aspect_ratio < _INPUT_FIELD_MIN_ASPECT_RATIO or
area < _INPUT_FIELD_MIN_AREA):
continue
# Vérifier la luminosité moyenne (les boutons ont souvent un fond coloré)
roi = gray[y:y+h, x:x+w]
mean_val = np.mean(roi)
if mean_val < _INPUT_FIELD_BRIGHTNESS_THRESHOLD:
continue # Pas assez clair, probablement pas un champ de saisie
# Vérifier qu'il y a du contenu (variation de luminosité = texte présent)
std_val = np.std(roi)
if std_val < 5:
continue # Zone uniformément blanche, pas de texte à flouter
# Appliquer le flou gaussien sur le contenu (garder le bord visible)
m = _BLUR_MARGIN
y1, y2 = y + m, y + h - m
x1, x2 = x + m, x + w - m
if y2 > y1 and x2 > x1:
roi_color = img_bgr[y1:y2, x1:x2]
if roi_color.size > 0:
blurred = cv2.GaussianBlur(roi_color, _BLUR_KERNEL_SIZE, _BLUR_SIGMA)
img_bgr[y1:y2, x1:x2] = blurred
count += 1
return count
def _blur_textareas(img_bgr, gray):
"""Détecte et floute les zones de texte multi-lignes (textarea, éditeurs).
Ces zones sont plus grandes que les champs simples, avec un fond clair
et beaucoup de texte.
"""
import cv2
import numpy as np
count = 0
# Seuillage un peu plus tolérant pour les textareas (parfois gris clair)
_, light_mask = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
# Nettoyage morphologique plus agressif pour les grandes zones
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 10))
light_mask = cv2.morphologyEx(light_mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(light_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
aspect_ratio = w / max(h, 1)
area = w * h
# Filtrage : forme typique d'une textarea
if (w < _TEXTAREA_MIN_WIDTH or
h < _TEXTAREA_MIN_HEIGHT or
h > _TEXTAREA_MAX_HEIGHT or
aspect_ratio < _TEXTAREA_MIN_ASPECT_RATIO or
area < _TEXTAREA_MIN_AREA):
continue
# Vérifier la luminosité et la présence de texte
roi = gray[y:y+h, x:x+w]
mean_val = np.mean(roi)
std_val = np.std(roi)
if mean_val < 190 or std_val < 8:
continue # Pas un textarea avec du contenu
# Flou sur le contenu
m = _BLUR_MARGIN + 2 # Marge un peu plus grande pour les textarea
y1, y2 = y + m, y + h - m
x1, x2 = x + m, x + w - m
if y2 > y1 and x2 > x1:
roi_color = img_bgr[y1:y2, x1:x2]
if roi_color.size > 0:
blurred = cv2.GaussianBlur(roi_color, _BLUR_KERNEL_SIZE, _BLUR_SIGMA)
img_bgr[y1:y2, x1:x2] = blurred
count += 1
return count

View File

@@ -10,7 +10,8 @@ import logging
import hashlib
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
from .blur_sensitive import blur_sensitive_regions
logger = logging.getLogger(__name__)
@@ -40,6 +41,10 @@ class VisionCapturer:
return "" # Pas de changement, on économise la fibre
self.last_img_hash = current_hash
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
return path
@@ -66,6 +71,11 @@ class VisionCapturer:
if anonymize:
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
blur_sensitive_regions(crop_img)
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)

View File

@@ -0,0 +1,198 @@
# tests/unit/test_blur_sensitive.py
"""
Tests unitaires pour le floutage des données sensibles dans les screenshots.
Conformité AI Act : les champs de saisie doivent être floutés,
mais la structure UI (boutons, labels, menus) doit rester visible.
"""
import importlib.util
import sys
from pathlib import Path
import pytest
import numpy as np
from PIL import Image
# --- Import direct du module blur_sensitive (évite le conflit agent_v0 standalone) ---
_BLUR_MODULE_PATH = (
Path(__file__).resolve().parents[2]
/ "agent_v0" / "agent_v1" / "vision" / "blur_sensitive.py"
)
def _import_blur_module():
"""Importe blur_sensitive directement depuis son chemin fichier."""
spec = importlib.util.spec_from_file_location("blur_sensitive", _BLUR_MODULE_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def blur_fn():
"""Fournit la fonction blur_sensitive_regions."""
mod = _import_blur_module()
return mod.blur_sensitive_regions
@pytest.fixture
def _create_screenshot_with_input_fields():
"""Crée un screenshot synthétique avec des champs de saisie simulés."""
def _make(width=800, height=600):
# Fond gris moyen (simule un arrière-plan d'application)
img = Image.new("RGB", (width, height), (180, 180, 180))
pixels = np.array(img)
# --- Champ de saisie 1 : input text classique (blanc, horizontal) ---
# Cadre blanc 300x30 à position (100, 100)
pixels[100:130, 100:400] = [255, 255, 255]
# Texte simulé : lignes sombres dans le champ
for y_offset in range(8, 22, 4):
pixels[100 + y_offset, 110:380] = [30, 30, 30]
# --- Champ de saisie 2 : input password (blanc, horizontal) ---
# Cadre blanc 250x30 à position (200, 300)
pixels[200:230, 300:550] = [255, 255, 255]
# Points simulant des caractères masqués
for x_pos in range(310, 540, 15):
pixels[210:220, x_pos:x_pos+8] = [40, 40, 40]
# --- Bouton (fond bleu — ne doit PAS être flouté) ---
pixels[300:340, 100:250] = [50, 120, 220]
# Texte blanc sur bouton
pixels[315:325, 130:220] = [255, 255, 255]
# --- Label texte (fond gris — ne doit PAS être flouté) ---
pixels[95:110, 100:200] = [180, 180, 180]
pixels[100:108, 105:190] = [40, 40, 40]
# --- Textarea (zone de texte multi-ligne) ---
# Grand rectangle blanc 400x120
pixels[350:470, 100:500] = [250, 250, 250]
# Plusieurs lignes de texte simulé
for line in range(0, 100, 16):
pixels[360 + line, 110:480] = [30, 30, 30]
pixels[362 + line, 110:460] = [30, 30, 30]
return Image.fromarray(pixels)
return _make
class TestBlurSensitiveRegions:
"""Tests du module blur_sensitive."""
def test_import(self, blur_fn):
"""Le module s'importe correctement."""
assert callable(blur_fn)
def test_returns_same_image_object(self, blur_fn, _create_screenshot_with_input_fields):
"""La fonction retourne le même objet image (modification en place)."""
img = _create_screenshot_with_input_fields()
result = blur_fn(img)
assert result is img
def test_blurs_input_fields(self, blur_fn, _create_screenshot_with_input_fields):
"""Les champs de saisie sont modifiés (floutés)."""
img = _create_screenshot_with_input_fields()
original_pixels = np.array(img).copy()
blur_fn(img)
blurred_pixels = np.array(img)
# La zone du champ de saisie 1 (100:130, 100:400) doit avoir changé
input_field_before = original_pixels[103:127, 103:397]
input_field_after = blurred_pixels[103:127, 103:397]
# Le contenu du champ doit être différent (flouté)
diff = np.abs(input_field_before.astype(float) - input_field_after.astype(float))
mean_diff = np.mean(diff)
assert mean_diff > 5, f"Le champ de saisie devrait être flouté (diff moyenne: {mean_diff})"
def test_preserves_button(self, blur_fn, _create_screenshot_with_input_fields):
"""Les boutons (fond coloré) ne sont PAS floutés."""
img = _create_screenshot_with_input_fields()
original_pixels = np.array(img).copy()
blur_fn(img)
blurred_pixels = np.array(img)
# La zone du bouton (300:340, 100:250, fond bleu) ne doit PAS changer
button_before = original_pixels[300:340, 100:250]
button_after = blurred_pixels[300:340, 100:250]
diff = np.abs(button_before.astype(float) - button_after.astype(float))
mean_diff = np.mean(diff)
assert mean_diff < 1, f"Le bouton ne devrait PAS être flouté (diff moyenne: {mean_diff})"
def test_handles_tiny_image(self, blur_fn):
"""Ne plante pas sur une image très petite."""
tiny = Image.new("RGB", (10, 10), (128, 128, 128))
result = blur_fn(tiny)
assert result is tiny
def test_handles_all_white_image(self, blur_fn):
"""Ne plante pas sur une image entièrement blanche."""
white = Image.new("RGB", (800, 600), (255, 255, 255))
result = blur_fn(white)
assert result is white
def test_handles_all_black_image(self, blur_fn):
"""Ne plante pas sur une image entièrement noire (aucune zone claire)."""
black = Image.new("RGB", (800, 600), (0, 0, 0))
original = np.array(black).copy()
result = blur_fn(black)
after = np.array(result)
# Rien ne devrait changer sur une image noire
assert np.array_equal(original, after)
def test_performance_under_200ms(self, blur_fn, _create_screenshot_with_input_fields):
"""Le floutage prend moins de 200ms (même sur un screenshot Full HD)."""
import time
# Créer une image Full HD
img = _create_screenshot_with_input_fields(width=1920, height=1080)
t0 = time.perf_counter()
blur_fn(img)
elapsed_ms = (time.perf_counter() - t0) * 1000
assert elapsed_ms < 200, f"Floutage trop lent : {elapsed_ms:.0f}ms (max 200ms)"
def test_preserves_gray_background(self, blur_fn, _create_screenshot_with_input_fields):
"""Le fond gris de l'application n'est pas altéré."""
img = _create_screenshot_with_input_fields()
original_pixels = np.array(img).copy()
blur_fn(img)
blurred_pixels = np.array(img)
# Zone de fond gris (coin bas-droit, loin de tout élément UI)
bg_before = original_pixels[500:580, 600:780]
bg_after = blurred_pixels[500:580, 600:780]
diff = np.abs(bg_before.astype(float) - bg_after.astype(float))
mean_diff = np.mean(diff)
assert mean_diff < 1, f"Le fond gris ne devrait PAS être modifié (diff: {mean_diff})"
class TestBlurConfig:
"""Tests de la configuration du floutage."""
def test_blur_sensitive_default_true(self):
"""BLUR_SENSITIVE est True par défaut."""
assert "true" in ("true", "1", "yes")
def test_blur_env_parsing_enabled(self):
"""Les valeurs qui activent le floutage sont reconnues."""
for val in ("true", "True", "TRUE", "1", "yes", "Yes"):
assert val.lower() in ("true", "1", "yes")
def test_blur_env_parsing_disabled(self):
"""Les valeurs qui désactivent le floutage sont reconnues."""
for val in ("false", "False", "0", "no", "No"):
assert val.lower() not in ("true", "1", "yes")

View File

@@ -885,9 +885,14 @@ def execute_windows():
if not data:
return jsonify({'error': 'Aucune donnée'}), 400
# Injecter un délai de 5s avant la première action
# pour laisser le temps à l'utilisateur de réduire le navigateur
if 'actions' in data and data['actions']:
# Vérifier si ce sont uniquement des actions fichiers (pas besoin de wait ni replay)
all_file_actions = all(
a.get('type', '') in _FILE_ACTION_TYPES
for a in data.get('actions', [])
) if data.get('actions') else False
# Injecter un délai de 5s SEULEMENT pour les actions UI (pas les fichiers)
if not all_file_actions and 'actions' in data and data['actions']:
data['actions'].insert(0, {
'type': 'wait',
'action_id': 'wait_before_start',