18df2fe7b4
Standalone Python stdlib pipeline that reads an agent's past sessions, compares them against installed skills, and generates structured improvement proposals gated by an evaluation framework before anything mutates. Host-agnostic via HostAdapter (Hermes, Claude Code). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
133 lines
5.7 KiB
Python
133 lines
5.7 KiB
Python
"""PII masking on the text that crosses the external-provider boundary.
|
|
|
|
The GEPA optimizer plan's Risk 1 flagged that session excerpts reaching an external
|
|
provider got only secret-pattern redaction, with no PII handling, and required explicit
|
|
sign-off before shipping. It shipped without one. Measured against the real session DB,
|
|
what actually crosses (first 300 chars of each message, messages with detected secrets
|
|
dropped wholesale) still carried: 71 messages with a Luhn-passing 13-19 digit run, 28 with
|
|
an email address, 27 with an international phone number, and 1 with a RUC.
|
|
|
|
Design, and the reason it differs from secret handling:
|
|
|
|
- A detected **secret** drops the whole message (`_summarize_messages` skips it). Correct
|
|
for credentials: there is no version of that message worth sending.
|
|
- Detected **PII** is *masked in place*. An email in a paragraph of useful debugging
|
|
evidence should cost that email, not the paragraph.
|
|
- **Money amounts are deliberately NOT masked.** They appear in 120 of the messages that
|
|
cross, and `money-admin`-style skills are exactly what the analyzer needs to reason
|
|
about; masking them would remove the evidence rather than protect an identity. The
|
|
distinction is identifiers vs. amounts.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
|
|
|
import pytest
|
|
|
|
from fetch_sessions import redact_pii
|
|
|
|
MASKED = [
|
|
("email", "escribe a juan.perez@example.com para confirmar"),
|
|
("email with plus", "usa carlo+test@sub.domain.pe si falla"),
|
|
("intl phone", "mi numero es +51 987 654 321 por si acaso"),
|
|
("intl phone dashes", "llama al +1-415-555-0132 manana"),
|
|
# Luhn-valid, card-shaped (leading digit 3-6, standard length)
|
|
("visa-shaped", "la tarjeta 4111 1111 1111 1111 fue rechazada"),
|
|
("mastercard-shaped", "probamos con 5500005555555559 y fallo"),
|
|
("amex-shaped", "el amex 378282246310005 tampoco paso"),
|
|
("iban", "transferir a DE89370400440532013000 hoy"),
|
|
("ruc", "el RUC 20100070970 de la empresa"),
|
|
("dni", "su DNI 12345678 no coincide"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("label,text", MASKED, ids=[m[0] for m in MASKED])
|
|
def test_identifiers_are_masked(label, text):
|
|
out = redact_pii(text)
|
|
assert out != text, f"{label} was not masked"
|
|
assert "[PII" in out
|
|
# the surrounding sentence survives -- masking, not dropping
|
|
assert text.split()[0] in out
|
|
|
|
|
|
PRESERVED = [
|
|
("money PEN", "el gasto fue S/ 1,250.00 en comida"),
|
|
("money USD", "cobre $3,400.50 del cliente"),
|
|
("money bare EUR", "presupuesto EUR 990 aprobado"),
|
|
("token count", "MAX_TOKENS=1024 en la config"),
|
|
("version string", "actualizamos a la 3.12.2 sin problemas"),
|
|
("short number", "hay 42 sesiones pendientes"),
|
|
("timestamp-ish", "corrio a las 2026-07-26T02:02:29 con exito"),
|
|
("session id", "la sesion 20260714_130038_f8f8da fallo"),
|
|
("byte count", "el archivo pesa 102716 bytes"),
|
|
("prose about email", "revisa tu correo antes de responder"),
|
|
("hash-like non-luhn", "commit 1234567890123456 no aplica"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("label,text", PRESERVED, ids=[p[0] for p in PRESERVED])
|
|
def test_amounts_and_ordinary_numbers_are_preserved(label, text):
|
|
"""Over-masking removes the evidence the analyzer reasons about."""
|
|
assert redact_pii(text) == text, f"{label} was masked but carries no identifier"
|
|
|
|
|
|
def test_masking_preserves_surrounding_evidence():
|
|
text = ("El usuario reporto que el deploy fallo tras cambiar la config. "
|
|
"Contacto: ana.lopez@example.org. El error fue un timeout de 30s.")
|
|
out = redact_pii(text)
|
|
|
|
assert "ana.lopez@example.org" not in out
|
|
assert "el deploy fallo tras cambiar la config" in out
|
|
assert "timeout de 30s" in out
|
|
|
|
|
|
def test_mask_names_the_kind_of_pii():
|
|
"""A reviewer reading a proposal should know what was removed, not just that something was."""
|
|
assert "email" in redact_pii("x j@e.com y").lower()
|
|
assert "phone" in redact_pii("x +51 987 654 321 y").lower()
|
|
|
|
|
|
def test_multiple_occurrences_all_masked():
|
|
out = redact_pii("a@b.com y luego c@d.org")
|
|
assert "a@b.com" not in out and "c@d.org" not in out
|
|
assert out.lower().count("pii") == 2
|
|
|
|
|
|
def test_empty_and_plain_text_unchanged():
|
|
assert redact_pii("") == ""
|
|
assert redact_pii("nada sensible aqui") == "nada sensible aqui"
|
|
|
|
|
|
def test_applied_to_the_text_that_crosses_the_boundary(tmp_path, monkeypatch):
|
|
"""End-to-end: _summarize_messages must mask, not just expose a helper."""
|
|
import sqlite3
|
|
import fetch_sessions
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(db)
|
|
conn.executescript(
|
|
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, model TEXT, title TEXT,"
|
|
" started_at REAL);"
|
|
"CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT,"
|
|
" content TEXT, tool_calls TEXT, timestamp REAL);"
|
|
)
|
|
conn.execute("INSERT INTO sessions VALUES ('s1','cli','m','t', 9e9)")
|
|
conn.execute(
|
|
"INSERT INTO messages VALUES (1,'s1','user',?,NULL, 9e9)",
|
|
("Mi correo es dev@example.com y el gasto fue S/ 500.00",),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# monkeypatch, not a bare assignment: STATE_FILE is a module constant, so assigning it
|
|
# directly leaks into every later test in the session (it broke
|
|
# test_state_schema_compat.py's check that both modules resolve the same path).
|
|
monkeypatch.setattr(fetch_sessions, "STATE_FILE", str(tmp_path / "state.json"))
|
|
sessions = fetch_sessions.fetch_sessions(db_path=str(db), lookback_hours=10**6, dry_run=True)
|
|
|
|
blob = str(sessions)
|
|
assert "dev@example.com" not in blob, "PII crossed the boundary"
|
|
assert "S/ 500.00" in blob, "the amount was masked, removing analysable evidence"
|