From 4a38000e7414395e16977b557590560eafa032c0 Mon Sep 17 00:00:00 2001 From: Dom Date: Sat, 27 Jun 2026 11:24:54 +0200 Subject: [PATCH] feat(agent_v1): helpers logging PII-safe (push-log-DGX, brique 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module agent_v1/core/log_safe.py — 3 helpers purs pour assainir les logs client à la source : _title_hash (SHA1[:8], corrélation sans révéler), _sanitize_metadata (drop title/active_window/window_title), _path_ext (extension seule). 6 tests unitaires verts. Module inerte (non encore wired) ; le branchement dans le code runtime suit en étape supervisée. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/core/log_safe.py | 48 ++++++++++++++++++++ tests/unit/test_log_safe.py | 73 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 agent_v0/agent_v1/core/log_safe.py create mode 100644 tests/unit/test_log_safe.py diff --git a/agent_v0/agent_v1/core/log_safe.py b/agent_v0/agent_v1/core/log_safe.py new file mode 100644 index 000000000..1bdbc59f8 --- /dev/null +++ b/agent_v0/agent_v1/core/log_safe.py @@ -0,0 +1,48 @@ +"""Helpers de logging PII-safe pour le client Léa (agent_v1). + +Convention : ne jamais logger le contenu brut d'une variable utilisateur +(texte tapé, titre de fenêtre, nom de workflow, réponse VLM, chemin fichier). +Le remplacer par : + - une longueur ou un hash court (corrélation de diagnostic sans révéler) ; + - un dict de métadonnées filtré (sans titre / fenêtre active). + +À importer dans tout module d'agent_v1 qui logge une donnée potentiellement +sensible. Branche feat/push-log-dgx — DETTE-020 (assainissement à la source). +""" + +from __future__ import annotations + +import hashlib +import os + + +def _title_hash(title: str) -> str: + """Hash SHA1 tronqué (8 hex) d'un titre. + + Corrélation stable (même titre → même hash → « même popup re-détectée ») + sans exposer le contenu. `errors="replace"` pour ne jamais lever sur un + encodage exotique (titres Windows multi-langues). + """ + return hashlib.sha1((title or "").encode("utf-8", errors="replace")).hexdigest()[:8] + + +# Clés de métadonnées susceptibles de contenir du contenu utilisateur (PII). +_PII_METADATA_KEYS = ("title", "active_window", "window_title") + + +def _sanitize_metadata(metadata: dict) -> dict: + """Copie d'un dict de métadonnées sans les clés porteuses de PII. + + Garde les champs techniques (resolution, dpi, theme, langue…), retire + titre / fenêtre active. Ne mute pas le dict d'origine. + """ + return {k: v for k, v in metadata.items() if k not in _PII_METADATA_KEYS} + + +def _path_ext(path: str) -> str: + """Extension seule d'un chemin (ex. « .png »), sans nom ni dossier. + + Un chemin peut nommer un patient ; l'extension suffit au diagnostic. + Chaîne vide si pas de chemin ou pas d'extension. + """ + return os.path.splitext(path)[1] if path else "" diff --git a/tests/unit/test_log_safe.py b/tests/unit/test_log_safe.py new file mode 100644 index 000000000..592a3c97e --- /dev/null +++ b/tests/unit/test_log_safe.py @@ -0,0 +1,73 @@ +"""Tests unitaires des helpers de logging PII-safe du client Léa (agent_v1). + +Assainissement des logs à la source : on ne logge jamais le contenu brut +(titres de fenêtre, noms de workflow, chemins, métadonnées sensibles). On le +remplace par un hash court stable, une longueur, ou un dict filtré. + +Branche feat/push-log-dgx — DETTE-020 (assainissement PII des logs, brique 4). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +_ROOT = str(Path(__file__).resolve().parents[2]) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +_HEX8 = re.compile(r"^[0-9a-f]{8}$") + + +def test_title_hash_is_short_stable_hex(): + from agent_v0.agent_v1.core.log_safe import _title_hash + + h = _title_hash("Dossier MOREL Catherine") + assert _HEX8.match(h), f"attendu 8 hex, obtenu {h!r}" + assert h == _title_hash("Dossier MOREL Catherine") # déterministe + + +def test_title_hash_never_reveals_raw_title(): + """Propriété PII centrale : le hash ne contient jamais le contenu brut.""" + from agent_v0.agent_v1.core.log_safe import _title_hash + + title = "Dossier MOREL Catherine" + h = _title_hash(title) + assert title not in h + assert "MOREL" not in h + + +def test_title_hash_distinguishes_different_titles(): + from agent_v0.agent_v1.core.log_safe import _title_hash + + assert _title_hash("popup A") != _title_hash("popup B") + + +def test_title_hash_handles_empty_and_non_ascii(): + from agent_v0.agent_v1.core.log_safe import _title_hash + + assert _HEX8.match(_title_hash("")) + assert _HEX8.match(_title_hash("Éléonore — café ☕")) + + +def test_sanitize_metadata_drops_pii_keys_keeps_technical(): + from agent_v0.agent_v1.core.log_safe import _sanitize_metadata + + meta = { + "resolution": "1920x1080", "dpi": 96, "theme": "dark", + "title": "Dossier Dupont", "active_window": "Medicare", "window_title": "x", + } + safe = _sanitize_metadata(meta) + + assert safe == {"resolution": "1920x1080", "dpi": 96, "theme": "dark"} + assert meta.get("title") == "Dossier Dupont" # original non muté + + +def test_path_ext_returns_extension_only(): + from agent_v0.agent_v1.core.log_safe import _path_ext + + assert _path_ext("/home/tim/Dossier Dupont 1980.png") == ".png" + assert "Dupont" not in _path_ext("/x/Dupont.png") + assert _path_ext("") == "" + assert _path_ext("/no/ext/here") == ""