SensitiveDataMasker
On this page
Purpose
Scans free-form text (or nested dicts/lists) for common PII/secret patterns and replaces matches with placeholder tokens like [EMAIL] or [SSN], before that content is stored or sent to an LLM. This is the class AIMemoryManager uses (when enable_privacy_masking=True, the default) to mask user_input/assistant_output before they enter any memory tier.
Constructor
def __init__(
self,
custom_patterns: Optional[List[Tuple[str, str]]] = None,
placeholder_fmt: str = "[{label}]",
) -> None
Public methods
mask(text: str) -> Tuple[str, Dict[str, int]]
Returns (masked_text, report) where report maps each pattern label that matched at least once to its match count. Labels with zero matches are omitted from report entirely (not present as 0).
mask_entry_content(content: Any) -> Tuple[Any, Dict[str, int]]
Recursively masks strings inside arbitrarily nested dict/list structures, merging per-label counts across the whole structure. Non-str/dict/list values (e.g. int, float, None) are returned unchanged with an empty report — not stringified and scanned.
has_sensitive_data(text: str) -> bool
any(pattern.search(text) for _, pattern in self.patterns) — a fast boolean check without performing any replacement, useful for pre-flight checks before deciding whether to log/store raw text at all.
Raises
re.error if a custom_patterns regex string is invalid — propagates from re.compile() inside __init__.
Thread safety
Read-only after construction (the compiled pattern list never mutates post-__init__), so calling mask/mask_entry_content/has_sensitive_data concurrently from multiple threads on the same instance is safe — each call only reads self.patterns and operates on its own local text/report variables.
Complexity
O(n × p) per call, where n is text length and p is the number of patterns (10 + any custom ones) — each pattern does a full-text findall/sub pass.
Examples
from zerda.memory import SensitiveDataMasker
masker = SensitiveDataMasker()
# 1. Basic
clean, report = masker.mask("Contact me at jane@example.com or 555-123-4567")
print(clean) # "Contact me at [EMAIL] or [PHONE_INTL]"
print(report) # {"EMAIL": 1, "PHONE_INTL": 1}
# 2. Intermediate — nested structure
content = {"input": "My SSN is 123-45-6789", "meta": {"note": "call 192.168.1.1"}}
cleaned, report = masker.mask_entry_content(content)
print(cleaned)
# {"input": "My SSN is [SSN]", "meta": {"note": "call [IP_V4]"}}
# 3. Advanced — custom pattern for an internal ID format
masker2 = SensitiveDataMasker(custom_patterns=[("EMPLOYEE_ID", r"EMP-\d{6}")])
clean2, _ = masker2.mask("Assigned to EMP-004821")
print(clean2) # "Assigned to [EMPLOYEE_ID]"
# 4. Error handling — invalid custom regex
try:
SensitiveDataMasker(custom_patterns=[("BAD", r"(unclosed[")])
except Exception as e:
print(type(e).__name__, e) # re.error
# 5. Production — pre-flight check before logging raw user input
if masker.has_sensitive_data(raw_user_input):
logger.warning("Sensitive data detected in input, masking before log")
raw_user_input, _ = masker.mask(raw_user_input)
logger.info(raw_user_input)
Best practices
- Run
has_sensitive_data()before any raw-text logging path, separate from the masking used for LLM prompts/storage. - Add domain-specific
custom_patternsfor identifiers unique to your application (employee IDs, internal ticket numbers, etc.) rather than relying only on the generic built-ins. - Treat the built-in
CREDIT_CARD/IBAN/PHONE_INTLpatterns as heuristics — validate against representative samples of your own traffic for false-positive rate before depending on them for compliance.
Common mistakes
- Assuming
custom_patternsrespects the case-sensitivity implied by how you wrote the regex — all patterns (built-in and custom) are compiled withre.I. - Passing non-string leaf values into
mask_entry_contentand expecting them scanned — numbers/None/etc. pass through untouched.
Related APIs
MemoryEncryptor— complementary at-rest encryption.AIMemoryManager.save_interaction— applies this masker automatically whenenable_privacy_masking=True.