from __future__ import annotations import base64 import re import secrets from copy import deepcopy from typing import Any, Iterable from app.policies.policy_engine import PolicyAction, PolicyBlocked, PolicyEngine from app.privacy.models import PrivacyAction from app.privacy.vault import ( InMemoryVault, MappingItem, VaultBudget, VaultCapacityExceeded, ) from .detector import Entity from .surrogate_generator import SurrogateGenerator __all__ = ["MappingItem", "MaskingSession", "ReservedTokenInjection", "VaultCapacityExceeded"] RESERVED_TOKEN_PATTERN = re.compile( r"(?:|)" ) class ReservedTokenInjection(PolicyBlocked): def __init__(self) -> None: self.entity_types = ["RESERVED_TOKEN"] Exception.__init__(self, "Input contains reserved MaskGate token syntax") class MaskingSession: def __init__( self, mode: str, policy: PolicyEngine, *, token_namespace: str | None = None, max_mappings: int = 5_000, max_sensitive_bytes: int = 2 * 1024 * 1024, ) -> None: if mode not in {"placeholder", "semantic_placeholder", "surrogate", "redact"}: raise ValueError(f"Unsupported masking mode: {mode}") self.mode = mode self.policy = policy namespace = token_namespace or secrets.token_hex(6) if not re.fullmatch(r"[A-Za-z0-9_-]{4,64}", namespace): raise ValueError("Invalid placeholder token namespace") self.token_namespace = namespace self.surrogates = SurrogateGenerator() self.vault = InMemoryVault(VaultBudget(max_mappings, max_sensitive_bytes)) self._placeholder_counters: dict[str, int] = {} @property def items(self) -> list[MappingItem]: return list(self.vault.items) def clone(self, *, policy: PolicyEngine | None = None) -> "MaskingSession": """Copy a vault so a blocked turn cannot partially mutate a conversation.""" cloned = deepcopy(self) if policy is not None: cloned.policy = policy return cloned def prune_to_references(self, values: Iterable[Any]) -> None: """Drop originals whose reversible replacements are no longer referenced.""" self.vault.prune_to_references(values) def _placeholder(self, entity_type: str) -> str: if self.mode == "placeholder": while True: opaque_id = base64.b32encode(secrets.token_bytes(16)).decode("ascii").rstrip("=") token = f"" if not self.vault.contains_replacement(token) and not self.vault.contains_original( token ): return token self._placeholder_counters[entity_type] = self._placeholder_counters.get(entity_type, 0) + 1 return ( f"" ) def _replacement(self, entity: Entity, action: PolicyAction) -> MappingItem | None: if action is PolicyAction.ALLOW: return None existing = self.vault.find_original(entity.text) if existing is not None: return existing if action is PolicyAction.REDACT: replacement = f"[REDACTED_{entity.type}]" restore = False elif self.mode in {"placeholder", "semantic_placeholder"}: replacement = self._placeholder(entity.type) restore = True elif self.mode == "surrogate": replacement = self.surrogates.generate(entity.type) restore = True else: replacement = f"[REDACTED_{entity.type}]" restore = False item = MappingItem(entity.type, entity.text, replacement, restore) self.vault.add(item) return item def mask_text(self, text: str, entities: list[Entity]) -> str: if RESERVED_TOKEN_PATTERN.search(text): raise ReservedTokenInjection replacements: list[tuple[int, int, str]] = [] for entity in entities: action = self.policy.action_for_entity(entity.type, entity.text) if action is PolicyAction.BLOCK: raise PolicyBlocked([entity.type]) item = self._replacement(entity, action) if item is not None: replacements.append((entity.start, entity.end, item.replacement)) result = text for start, end, replacement in reversed(replacements): result = result[:start] + replacement + result[end:] return result def mask_text_with_actions( self, text: str, entities: list[Entity], actions: list[PrivacyAction], ) -> str: """Apply explicit Policy Module v2 decisions to detected spans.""" if len(entities) != len(actions): raise ValueError("each entity must have exactly one privacy action") if RESERVED_TOKEN_PATTERN.search(text): raise ReservedTokenInjection replacements: list[tuple[int, int, str]] = [] blocked: list[str] = [] for entity, action in zip(entities, actions, strict=True): if action in { PrivacyAction.BLOCK, PrivacyAction.REQUIRE_REVIEW, PrivacyAction.GENERALIZE, PrivacyAction.HASH, }: blocked.append(entity.type) continue if action is PrivacyAction.ALLOW: continue existing = self.vault.find_original(entity.text) if existing is not None: item = existing elif action is PrivacyAction.REDACT: item = MappingItem( entity.type, entity.text, f"[REDACTED_{entity.type}]", False, ) self.vault.add(item) elif action is PrivacyAction.SURROGATE: item = MappingItem( entity.type, entity.text, self.surrogates.generate(entity.type), True, ) self.vault.add(item) else: # TOKENIZE preserves the explicitly selected compatibility # mode while opaque placeholder remains the secure default. item = self._replacement(entity, PolicyAction.MASK) if item is None: # pragma: no cover - MASK cannot produce None raise RuntimeError("tokenization did not produce a mapping") replacements.append((entity.start, entity.end, item.replacement)) if blocked: raise PolicyBlocked(list(dict.fromkeys(blocked))) result = text for start, end, replacement in reversed(replacements): result = result[:start] + replacement + result[end:] return result def mask_text( text: str, entities: list[Entity], mode: str, policy: PolicyEngine, ) -> tuple[str, list[MappingItem]]: session = MaskingSession(mode, policy) return session.mask_text(text, entities), session.items