feat(agent_v1): helpers logging PII-safe (push-log-DGX, brique 4)
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) <noreply@anthropic.com>
This commit is contained in:
48
agent_v0/agent_v1/core/log_safe.py
Normal file
48
agent_v0/agent_v1/core/log_safe.py
Normal file
@@ -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 ""
|
||||
73
tests/unit/test_log_safe.py
Normal file
73
tests/unit/test_log_safe.py
Normal file
@@ -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") == ""
|
||||
Reference in New Issue
Block a user