PII scrub on both sides
Guardrails run twice on every request: once on the input before the model sees it, once on the output before the user sees it. The two runs are the same code, just pointed at different text. That symmetry is the whole point: PII must not enter the model, and PII must not leave the model.
Guardrails on both sides of the model
Input scrubbing protects the model; output scrubbing protects the user.
MAX_INPUT_LEN = 4000
EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
PHONE_RE = re.compile(r"\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")
SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
@dataclass
class GuardrailResult:
text: str
flags: List[str]
class GuardrailsLayer:
def check_input(self, text: str) -> GuardrailResult:
if len(text) > MAX_INPUT_LEN:
raise ValueError("input exceeds maximum length")
return self._scrub(text)
def check_output(self, text: str) -> GuardrailResult:
return self._scrub(text)
def _scrub(self, text: str) -> GuardrailResult:
flags: List[str] = []
def _flag(name, pattern, replacement, src):
if pattern.search(src):
flags.append(name)
return pattern.sub(replacement, src)
return src
text = _flag("email", EMAIL_RE, "[REDACTED_EMAIL]", text)
text = _flag("phone", PHONE_RE, "[REDACTED_PHONE]", text)
text = _flag("ssn", SSN_RE, "[REDACTED_SSN]", text)
return GuardrailResult(text=text, flags=flags)One scrubber, two entry points. check_input also enforces the length cap. Both return a result object with the cleaned text and the list of flags, which lands in the trace so you can audit redactions.
Regex is a floor, not a ceiling. It catches the most common patterns cheaply and predictably, and it runs in microseconds. For broader coverage you swap the scrubber for an ML-based detector like Presidio or a hosted API. The interface stays the same: text in, GuardrailResult out. Start with the regex floor, measure false negatives, upgrade when the data says so.
Quiz: Quiz
Loading practice…