Skip to content

Commit 61de0b5

Browse files
feat(pii): custom user-supplied regex patterns for redaction (#5732)
* feat(pii): custom user-supplied regex patterns for redaction * fix(pii): enforce custom-regex syntax + safety at the boundary schema * improvement(pii): always wrap custom-pattern redaction token in angle brackets * chore(pii): register guardrails_validate in the dev minimal tool registry * fix(pii): coerce empty guardrails entity-type checkbox (null) so the contract accepts it * fix(pii): keep detect-all when a custom pattern is added; custom patterns win overlaps
1 parent caa454a commit 61de0b5

30 files changed

Lines changed: 971 additions & 80 deletions

File tree

apps/pii/server.py

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import time
1111
from typing import Any
1212

13-
from fastapi import FastAPI
13+
import regex as regex_module
14+
from fastapi import FastAPI, HTTPException
1415
from presidio_analyzer import (
1516
AnalyzerEngine,
1617
BatchAnalyzerEngine,
@@ -220,9 +221,11 @@ def _analyze_one(
220221
entities: list[str] | None,
221222
score_threshold: float | None,
222223
return_decision_process: bool = False,
224+
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
223225
):
224226
# Regex-only requests reuse a blank NlpArtifacts to skip the spaCy NLP pass;
225-
# otherwise analyze() computes artifacts (runs spaCy) as usual.
227+
# otherwise analyze() computes artifacts (runs spaCy) as usual. Custom-pattern
228+
# recognizers are regex-based, so they run fine against the blank artifacts.
226229
nlp_artifacts = (
227230
_BLANK_ARTIFACTS.get(language) if _regex_only(entities, score_threshold) else None
228231
)
@@ -233,6 +236,7 @@ def _analyze_one(
233236
score_threshold=score_threshold,
234237
return_decision_process=return_decision_process,
235238
nlp_artifacts=nlp_artifacts,
239+
ad_hoc_recognizers=ad_hoc_recognizers or None,
236240
)
237241

238242

@@ -241,6 +245,7 @@ def _analyze_many(
241245
language: str,
242246
entities: list[str] | None,
243247
score_threshold: float | None,
248+
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
244249
):
245250
"""Analyze many texts, skipping the spaCy pass for regex-only requests."""
246251
if _regex_only(entities, score_threshold):
@@ -252,6 +257,7 @@ def _analyze_many(
252257
entities=entities,
253258
score_threshold=score_threshold,
254259
nlp_artifacts=blank,
260+
ad_hoc_recognizers=ad_hoc_recognizers or None,
255261
)
256262
for text in texts
257263
]
@@ -261,33 +267,116 @@ def _analyze_many(
261267
language=language,
262268
entities=entities or None,
263269
score_threshold=score_threshold,
270+
ad_hoc_recognizers=ad_hoc_recognizers or None,
264271
)
265272
)
266273

267274

268275
app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None)
269276

277+
# Internal entity id assigned to the i-th user-supplied custom pattern. Never
278+
# surfaced: the anonymizer maps it back to the pattern's chosen `replacement`, and
279+
# callers relabel any leftover CUSTOM_<i> span to the pattern's display name.
280+
CUSTOM_ENTITY_PREFIX = "CUSTOM_"
281+
282+
283+
class CustomPattern(BaseModel):
284+
"""A user-supplied regex pattern. Matches are replaced with `replacement`,
285+
wrapped in angle brackets (see `_wrap_token`)."""
286+
287+
regex: str
288+
replacement: str = ""
289+
name: str = ""
290+
291+
292+
def _wrap_token(replacement: str) -> str:
293+
"""Wrap the redaction token in angle brackets so custom matches read like the
294+
built-in Presidio tokens (`<PERSON>`, `<EMAIL_ADDRESS>`). A value the user
295+
already bracketed is left as-is so it never double-wraps to `<<X>>`."""
296+
if len(replacement) >= 2 and replacement.startswith("<") and replacement.endswith(">"):
297+
return replacement
298+
return f"<{replacement}>"
299+
300+
301+
def custom_operators(patterns: list[CustomPattern] | None) -> dict[str, dict[str, Any]]:
302+
"""Raw replace-operator per custom pattern, keyed by its internal entity id."""
303+
return {
304+
f"{CUSTOM_ENTITY_PREFIX}{i}": {"type": "replace", "new_value": _wrap_token(p.replacement)}
305+
for i, p in enumerate(patterns or [])
306+
}
307+
308+
309+
def build_custom_recognizers(
310+
patterns: list[CustomPattern] | None, language: str
311+
) -> tuple[list[PatternRecognizer], list[str]]:
312+
"""Ad-hoc PatternRecognizers + their entity ids for the given custom patterns.
313+
314+
Each regex is precompiled so a malformed pattern fails fast as a 400 rather
315+
than surfacing later as an opaque analyze-time 500."""
316+
recognizers: list[PatternRecognizer] = []
317+
entity_ids: list[str] = []
318+
for i, p in enumerate(patterns or []):
319+
try:
320+
regex_module.compile(p.regex)
321+
except regex_module.error as exc:
322+
raise HTTPException(
323+
status_code=400, detail=f"Invalid custom pattern regex: {exc}"
324+
) from exc
325+
entity = f"{CUSTOM_ENTITY_PREFIX}{i}"
326+
recognizers.append(
327+
PatternRecognizer(
328+
supported_entity=entity,
329+
# Score 1.0 so a user's explicit pattern wins any overlap with a
330+
# built-in detector (e.g. spaCy tagging "EMP-123456" as ORGANIZATION
331+
# under detect-all). Presidio resolves overlapping spans by score, so
332+
# the custom replacement — not the built-in token — is applied.
333+
patterns=[Pattern(name=p.name or entity, regex=p.regex, score=1.0)],
334+
supported_language=language,
335+
)
336+
)
337+
entity_ids.append(entity)
338+
return recognizers, entity_ids
339+
340+
341+
def resolve_entities(
342+
req_entities: list[str] | None, custom_entity_ids: list[str]
343+
) -> list[str] | None:
344+
"""Effective entity filter.
345+
346+
`None` means detect-all built-ins (the guardrails "empty selection = detect
347+
everything" convention); the ad-hoc custom recognizers still fire under `None`,
348+
so adding a custom pattern augments detect-all rather than silently disabling
349+
the built-in detectors. An explicit list — including the empty list, which is
350+
the data-retention "only these custom patterns" shape — is used verbatim, with
351+
the custom ids appended."""
352+
if req_entities is None:
353+
return None
354+
return list(req_entities) + custom_entity_ids
355+
270356

271357
class AnalyzeRequest(BaseModel):
272358
text: str
273359
language: str = "en"
274360
entities: list[str] | None = None
275361
score_threshold: float | None = None
276362
return_decision_process: bool = False
363+
patterns: list[CustomPattern] | None = None
277364

278365

279366
class AnalyzeBatchRequest(BaseModel):
280367
texts: list[str]
281368
language: str = "en"
282369
entities: list[str] | None = None
283370
score_threshold: float | None = None
371+
patterns: list[CustomPattern] | None = None
284372

285373

286374
class AnonymizeRequest(BaseModel):
287375
text: str
288376
analyzer_results: list[dict[str, Any]] = []
289377
anonymizers: dict[str, dict[str, Any]] | None = None
290378
operators: dict[str, dict[str, Any]] | None = None
379+
patterns: list[CustomPattern] | None = None
291380

292381

293382
class AnonymizeBatchItem(BaseModel):
@@ -299,6 +388,7 @@ class AnonymizeBatchRequest(BaseModel):
299388
items: list[AnonymizeBatchItem] = []
300389
anonymizers: dict[str, dict[str, Any]] | None = None
301390
operators: dict[str, dict[str, Any]] | None = None
391+
patterns: list[CustomPattern] | None = None
302392

303393

304394
class RedactRequest(BaseModel):
@@ -308,6 +398,7 @@ class RedactRequest(BaseModel):
308398
score_threshold: float | None = None
309399
anonymizers: dict[str, dict[str, Any]] | None = None
310400
operators: dict[str, dict[str, Any]] | None = None
401+
patterns: list[CustomPattern] | None = None
311402

312403

313404
class RedactBatchRequest(BaseModel):
@@ -317,6 +408,7 @@ class RedactBatchRequest(BaseModel):
317408
score_threshold: float | None = None
318409
anonymizers: dict[str, dict[str, Any]] | None = None
319410
operators: dict[str, dict[str, Any]] | None = None
411+
patterns: list[CustomPattern] | None = None
320412

321413

322414
def build_operators(
@@ -332,6 +424,17 @@ def build_operators(
332424
return operators
333425

334426

427+
def resolve_operators(
428+
anonymizers: dict[str, dict[str, Any]] | None,
429+
operators: dict[str, dict[str, Any]] | None,
430+
patterns: list[CustomPattern] | None,
431+
) -> dict[str, OperatorConfig] | None:
432+
"""Merge the caller's operators with the per-custom-pattern replace operators."""
433+
raw = dict(anonymizers or operators or {})
434+
raw.update(custom_operators(patterns))
435+
return build_operators(raw)
436+
437+
335438
def run_anonymize(
336439
text: str,
337440
raw_results: list[dict[str, Any]],
@@ -366,12 +469,15 @@ def supported_entities(language: str = "en") -> list[str]:
366469
@app.post("/analyze")
367470
def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
368471
started = time.perf_counter()
472+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
473+
entities = resolve_entities(req.entities, custom_ids)
369474
results = _analyze_one(
370475
req.text,
371476
req.language,
372-
req.entities,
477+
entities,
373478
req.score_threshold,
374479
req.return_decision_process,
480+
recognizers,
375481
)
376482
logger.info(
377483
"analyze lang=%s chars=%d entities=%d duration_ms=%.1f",
@@ -387,14 +493,16 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
387493
def analyze_batch(req: AnalyzeBatchRequest) -> list[list[dict[str, Any]]]:
388494
"""Analyze many texts in one pass (spaCy nlp.pipe), returning one span list
389495
per input in request order — the batched counterpart to /analyze."""
390-
results = _analyze_many(req.texts, req.language, req.entities, req.score_threshold)
496+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
497+
entities = resolve_entities(req.entities, custom_ids)
498+
results = _analyze_many(req.texts, req.language, entities, req.score_threshold, recognizers)
391499
return [[r.to_dict() for r in per_text] for per_text in results]
392500

393501

394502
@app.post("/anonymize")
395503
def anonymize(req: AnonymizeRequest) -> dict[str, Any]:
396504
started = time.perf_counter()
397-
operators = build_operators(req.anonymizers or req.operators)
505+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
398506
result = run_anonymize(req.text, req.analyzer_results, operators)
399507
logger.info(
400508
"anonymize chars=%d spans=%d duration_ms=%.1f",
@@ -422,7 +530,7 @@ def anonymize_batch(req: AnonymizeBatchRequest) -> dict[str, list[str]]:
422530
"""Mask many texts in one pass, returning masked text per item in request
423531
order — the batched counterpart to /anonymize. Anonymization is pure string
424532
work (no NLP), so callers should send only items with detected spans."""
425-
operators = build_operators(req.anonymizers or req.operators)
533+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
426534
return {
427535
"texts": [
428536
run_anonymize(item.text, item.analyzer_results, operators).text
@@ -438,8 +546,12 @@ def redact(req: RedactRequest) -> dict[str, str]:
438546
with no detected PII passes through unchanged. The analyzer results feed the
439547
anonymizer directly (no dict round-trip)."""
440548
started = time.perf_counter()
441-
operators = build_operators(req.anonymizers or req.operators)
442-
results = _analyze_one(req.text, req.language, req.entities, req.score_threshold)
549+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
550+
entities = resolve_entities(req.entities, custom_ids)
551+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
552+
results = _analyze_one(
553+
req.text, req.language, entities, req.score_threshold, ad_hoc_recognizers=recognizers
554+
)
443555
text = (
444556
req.text
445557
if not results
@@ -466,8 +578,10 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
466578
the anonymizer directly (no dict round-trip), and anonymization runs only on
467579
texts that actually matched."""
468580
started = time.perf_counter()
469-
operators = build_operators(req.anonymizers or req.operators)
470-
analyzed = _analyze_many(req.texts, req.language, req.entities, req.score_threshold)
581+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
582+
entities = resolve_entities(req.entities, custom_ids)
583+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
584+
analyzed = _analyze_many(req.texts, req.language, entities, req.score_threshold, recognizers)
471585
masked: list[str] = []
472586
total_spans = 0
473587
for text, per_text in zip(req.texts, analyzed):
@@ -484,8 +598,8 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
484598
"redact_batch lang=%s texts=%d entities=%s nlp=%s spans=%d duration_ms=%.1f",
485599
req.language,
486600
len(req.texts),
487-
len(req.entities) if req.entities else "all",
488-
"skip" if _regex_only(req.entities, req.score_threshold) else "full",
601+
len(entities) if entities else "all",
602+
"skip" if _regex_only(entities, req.score_threshold) else "full",
489603
total_spans,
490604
(time.perf_counter() - started) * 1000,
491605
)

apps/sim/app/api/guardrails/mask-batch/route.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ describe('POST /api/guardrails/mask-batch', () => {
5252
expect(res.status).toBe(200)
5353
const json = await res.json()
5454
expect(json.masked).toEqual(['M(a@b.com)', 'M(hello)'])
55-
expect(mockMaskPIIBatch).toHaveBeenCalledWith(['a@b.com', 'hello'], ['EMAIL_ADDRESS'], 'en')
55+
expect(mockMaskPIIBatch).toHaveBeenCalledWith(
56+
['a@b.com', 'hello'],
57+
['EMAIL_ADDRESS'],
58+
'en',
59+
undefined
60+
)
5661
})
5762

5863
it('rejects an invalid body with 400', async () => {

apps/sim/app/api/guardrails/mask-batch/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2525
const parsed = await parseRequest(guardrailsMaskBatchContract, request, {})
2626
if (!parsed.success) return parsed.response
2727

28-
const { texts, entityTypes, language } = parsed.data.body
28+
const { texts, entityTypes, language, customPatterns } = parsed.data.body
2929

3030
try {
3131
const startedAt = performance.now()
32-
const masked = await maskPIIBatch(texts, entityTypes, language)
32+
const masked = await maskPIIBatch(texts, entityTypes, language, customPatterns)
3333
logger.info('Masked PII batch', {
3434
count: texts.length,
3535
durationMs: Math.round(performance.now() - startedAt),

apps/sim/app/api/guardrails/validate/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
1717
import { generateRequestId } from '@/lib/core/utils/request'
1818
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
19+
import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities'
1920
import { validateHallucination } from '@/lib/guardrails/validate_hallucination'
2021
import { validateJson } from '@/lib/guardrails/validate_json'
2122
import { validatePII } from '@/lib/guardrails/validate_pii'
@@ -63,6 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6364
piiEntityTypes,
6465
piiMode,
6566
piiLanguage,
67+
piiCustomPatterns,
6668
} = body
6769

6870
if (!validationType) {
@@ -280,6 +282,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
280282
piiEntityTypes,
281283
piiMode,
282284
piiLanguage,
285+
piiCustomPatterns,
283286
authHeaders,
284287
requestId
285288
)
@@ -392,6 +395,7 @@ async function executeValidation(
392395
piiEntityTypes: string[] | undefined,
393396
piiMode: string | undefined,
394397
piiLanguage: string | undefined,
398+
piiCustomPatterns: CustomPiiPattern[] | undefined,
395399
authHeaders: { cookie?: string; authorization?: string; billingAttribution?: string } | undefined,
396400
requestId: string
397401
): Promise<{
@@ -450,6 +454,7 @@ async function executeValidation(
450454
entityTypes: piiEntityTypes || [], // Empty array = detect all PII types
451455
mode: (piiMode as 'block' | 'mask') || 'block', // Default to block mode
452456
language: piiLanguage || 'en',
457+
customPatterns: piiCustomPatterns,
453458
requestId,
454459
})
455460
}

apps/sim/blocks/blocks/guardrails.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,17 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
214214
},
215215
dependsOn: ['validationType'],
216216
},
217+
{
218+
id: 'piiCustomPatterns',
219+
title: 'Custom Patterns',
220+
type: 'table',
221+
columns: ['Name', 'Pattern', 'Replacement'],
222+
condition: {
223+
field: 'validationType',
224+
value: ['pii'],
225+
},
226+
dependsOn: ['validationType'],
227+
},
217228
],
218229
tools: {
219230
access: ['guardrails_validate'],
@@ -260,6 +271,10 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
260271
type: 'string',
261272
description: 'Language for PII detection (default: en)',
262273
},
274+
piiCustomPatterns: {
275+
type: 'json',
276+
description: 'Custom regex patterns to detect and replace (name, pattern, replacement rows)',
277+
},
263278
},
264279
outputs: {
265280
input: {

0 commit comments

Comments
 (0)