Skip to content

Commit 07ae2d2

Browse files
authored
Merge pull request #160 from DataFog/release/4.7.0
Release v4.7.0
2 parents 5dbdffd + 80da01d commit 07ae2d2

13 files changed

Lines changed: 478 additions & 31 deletions

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 4.6.0
2+
current_version = 4.7.0
33
commit = True
44
tag = True
55
tag_name = v{new_version}

CHANGELOG.MD

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22

33
## [2026-07-02]
44

5+
### `datafog-python` [4.7.0]
6+
7+
#### Added
8+
9+
- **Allowlist support** on `scan()` and `redact()`: `allowlist=[...]` exempts
10+
exact entity texts (your own support address, documentation placeholders);
11+
`allowlist_patterns=[...]` exempts entities whose full text matches a regex
12+
(e.g. `^\d{10}$` so unix timestamps stop matching as phone numbers).
13+
Matching is deliberately strict: case-sensitive, no Unicode normalization,
14+
exact/fullmatch only — a partial match never suppresses a finding.
15+
Threaded through both agent adapters: `DATAFOG_HOOK_ALLOWLIST` /
16+
`DATAFOG_HOOK_ALLOWLIST_PATTERNS` environment variables for the Claude
17+
Code hook, `allowlist` / `allowlist_patterns` parameters for the LiteLLM
18+
guardrail. Patterns are operator configuration — treat them like code and
19+
never accept them from end users; patterns with nested quantifiers are
20+
rejected at configuration time (catastrophic-backtracking guard), pattern
21+
length is capped at 512 characters, and entities longer than 512
22+
characters skip pattern matching fail-safe (the finding is kept).
23+
- **Presidio-compatible entity aliases**: `EMAIL_ADDRESS` and `US_SSN` are
24+
accepted as input aliases for `EMAIL` and `SSN` (joining the existing
25+
`PHONE_NUMBER` alias), so Presidio configurations migrate without renames.
26+
- **`py.typed` marker**: the package now advertises its inline type
27+
annotations to type checkers (PEP 561).
28+
29+
#### Changed
30+
31+
- **LiteLLM guardrail observability**: redaction events are now recorded
32+
with `guardrail_status="guardrail_intervened"` (previously `"success"`),
33+
so compliance dashboards flag redactions as interventions. Guardrail
34+
logging metadata is attached to the request dict actually returned in
35+
redact mode, fixing dropped observability records for requests arriving
36+
without a pre-existing `metadata` key.
37+
- Documentation: corrected the engine entity-type list — the scan API
38+
returns `DATE` and `ZIP_CODE`; `DOB` and `ZIP` are accepted input aliases.
39+
540
### `datafog-python` [4.6.0]
641

742
#### Added

README.md

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,43 @@ DataFog is a Python library for detecting and redacting personally identifiable
55
It provides:
66

77
- Fast structured PII detection via regex
8+
- An offline PII firewall for AI agents: a Claude Code hook and a LiteLLM
9+
gateway guardrail (new in 4.6)
810
- Optional NER support via spaCy and GLiNER
911
- A simple agent-oriented API for LLM applications
1012
- Backward-compatible `DataFog` and `TextService` classes
1113

12-
## 4.5 Focus
14+
## Agent & Gateway Firewall (4.6)
1315

14-
DataFog 4.5 is focused on lightweight text PII screening: a small core install,
15-
fast regex-based scan/redact helpers, explicit optional extras, and a clearer
16-
path toward future middleware use cases. Dedicated Sentry, OpenTelemetry,
17-
logging-framework, and cloud DLP adapters are future-facing work and are not
18-
part of the 4.5 release.
16+
DataFog 4.6 adds two ready-made enforcement points that catch PII at the
17+
moment it would leave your machine — offline, in microseconds, with matched
18+
values never echoed into logs or transcripts:
19+
20+
- **Claude Code hook** (`datafog-hook`): gates agent tool calls (shell
21+
commands, web requests, file writes, MCP tools) and warns the model when
22+
prompts or tool results carry PII. ~70ms per invocation including process
23+
startup. Easiest install is the
24+
[Claude Code plugin](https://github.com/DataFog/datafog-claude-plugin):
25+
26+
```
27+
/plugin marketplace add DataFog/datafog-claude-plugin
28+
/plugin install datafog@datafog
29+
```
30+
31+
Manual hook setup and limitations: [examples/claude_code_hook/](examples/claude_code_hook/).
32+
33+
- **LiteLLM guardrail** (`DataFogGuardrail`): redacts or blocks PII in
34+
requests and responses at the gateway, for any LiteLLM-proxied provider.
35+
In-process (~31µs per request), no sidecar service. Setup:
36+
[examples/litellm_guardrail/](examples/litellm_guardrail/).
37+
38+
Both default to the high-precision entity set (`EMAIL`, `PHONE`,
39+
`CREDIT_CARD`, `SSN`); noisier types are opt-in. Known-safe values can be
40+
exempted with an allowlist: `scan(text, allowlist=[...])` for exact values,
41+
`allowlist_patterns=[...]` for full-match regexes (e.g. `^\d{10}$` to stop
42+
unix timestamps matching as phone numbers) — available in both adapters and
43+
the API. Presidio-style entity names (`EMAIL_ADDRESS`, `PHONE_NUMBER`,
44+
`US_SSN`) are accepted as aliases for easy migration.
1945

2046
## Installation
2147

@@ -42,7 +68,7 @@ pip install datafog[all]
4268
Python 3.13 support is certified for the core SDK, CLI, `nlp`,
4369
`nlp-advanced`, and `ocr` install profiles. Donut OCR still requires a model
4470
that is available locally before runtime use. `distributed` and `all` are not
45-
newly certified on Python 3.13 in the 4.5 line.
71+
newly certified on Python 3.13 in the 4.x line.
4672

4773
## Quick Start
4874

@@ -117,7 +143,7 @@ Use the engine that matches your accuracy and dependency constraints:
117143

118144
- `regex`:
119145
- Fastest and always available.
120-
- Best for default structured entities: `EMAIL`, `PHONE`, `SSN`, `CREDIT_CARD`, `IP_ADDRESS`, `DATE`, `ZIP_CODE`.
146+
- Best for default structured entities: `EMAIL`, `PHONE`, `SSN`, `CREDIT_CARD`, `IP_ADDRESS`, `DATE`, `ZIP_CODE` (`DOB` and `ZIP` are accepted as input aliases).
121147
- Use `locales=["de"]` for German structured IDs such as `DE_VAT_ID`, `DE_IBAN`, `DE_TAX_ID`, `DE_POSTAL_CODE`, and passport or residence permit numbers.
122148
- `spacy`:
123149
- Requires `pip install datafog[nlp]`.
@@ -131,7 +157,7 @@ Use the engine that matches your accuracy and dependency constraints:
131157

132158
## Optional OCR And Spark Surfaces
133159

134-
DataFog 4.5 keeps the main package story centered on lightweight text PII
160+
The 4.x line keeps the main package story centered on lightweight text PII
135161
screening. OCR and Spark remain supported optional surfaces for users who
136162
already rely on them, but they are not required for the core import, default
137163
scan/redact helpers, or guardrail helpers.
@@ -151,7 +177,7 @@ scan/redact helpers, or guardrail helpers.
151177
- A Java runtime is required by PySpark.
152178

153179
OCR and Spark are not deprecated. Their broader API and packaging overhaul is
154-
deferred; the 4.5 goal is to keep them explicit, documented, and isolated from
180+
deferred; the 4.x goal is to keep them explicit, documented, and isolated from
155181
the lightweight core path.
156182

157183
## Backward-Compatible APIs

datafog/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.6.0"
1+
__version__ = "4.7.0"

datafog/__init__.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,28 @@ def scan(
153153
engine: str = "regex",
154154
entity_types: list[str] | None = None,
155155
locales: list[str] | None = None,
156+
allowlist: list[str] | None = None,
157+
allowlist_patterns: list[str] | None = None,
156158
) -> ScanResult:
157159
"""
158160
v5-preview scan entrypoint.
159161
160162
Defaults to the lightweight regex engine so the core install works without
161163
optional dependency fallback warnings.
164+
165+
``allowlist`` exempts exact entity texts (your own support address, doc
166+
placeholders); ``allowlist_patterns`` exempts entities whose full text
167+
matches a regex (e.g. ``^\\d{10}$`` so unix timestamps stop matching as
168+
phone numbers).
162169
"""
163-
return _scan(text=text, engine=engine, entity_types=entity_types, locales=locales)
170+
return _scan(
171+
text=text,
172+
engine=engine,
173+
entity_types=entity_types,
174+
locales=locales,
175+
allowlist=allowlist,
176+
allowlist_patterns=allowlist_patterns,
177+
)
164178

165179

166180
def redact(
@@ -171,12 +185,17 @@ def redact(
171185
strategy: str = "token",
172186
preset: str | None = None,
173187
locales: list[str] | None = None,
188+
allowlist: list[str] | None = None,
189+
allowlist_patterns: list[str] | None = None,
174190
) -> RedactResult:
175191
"""
176192
v5-preview redaction entrypoint.
177193
178194
If entities are provided, redact those spans. Otherwise, scan text first
179-
using the selected engine and redact the detected entities.
195+
using the selected engine and redact the detected entities. ``allowlist``
196+
and ``allowlist_patterns`` exempt findings from redaction (exact text and
197+
full-text regex match respectively); they apply to the scan path and are
198+
rejected when explicit ``entities`` are supplied.
180199
"""
181200
if preset is not None:
182201
try:
@@ -186,6 +205,11 @@ def redact(
186205
raise ValueError(f"preset must be one of: {allowed}") from exc
187206

188207
if entities is not None:
208+
if allowlist or allowlist_patterns:
209+
raise ValueError(
210+
"allowlist/allowlist_patterns cannot be combined with explicit "
211+
"entities; filter the entities before calling redact"
212+
)
189213
return _redact_entities(text=text, entities=entities, strategy=strategy)
190214

191215
return _scan_and_redact(
@@ -194,6 +218,8 @@ def redact(
194218
entity_types=entity_types,
195219
strategy=strategy,
196220
locales=locales,
221+
allowlist=allowlist,
222+
allowlist_patterns=allowlist_patterns,
197223
)
198224

199225

datafog/engine.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import hashlib
6+
import re
67
import warnings
78
from dataclasses import dataclass
89
from functools import lru_cache
@@ -23,6 +24,9 @@
2324
"SOCIAL_SECURITY_NUMBER": "SSN",
2425
"CREDIT_CARD_NUMBER": "CREDIT_CARD",
2526
"DATE_OF_BIRTH": "DATE",
27+
# Presidio-compatible aliases, so configs migrate without renames.
28+
"EMAIL_ADDRESS": "EMAIL",
29+
"US_SSN": "SSN",
2630
}
2731

2832
ALL_ENTITY_TYPES = {
@@ -277,6 +281,74 @@ def _filter_entity_types(
277281
return [entity for entity in entities if entity.type in allowed]
278282

279283

284+
# Python's re module backtracks; a quantified group containing another
285+
# quantifier (e.g. ``(a+)+``) can take exponential time on adversarial
286+
# input, and entity text can be attacker-influenced (LLM messages, tool
287+
# output). Reject that construct outright rather than matching under it.
288+
_NESTED_QUANTIFIER = re.compile(
289+
r"\((?:[^()\\]|\\.)*(?<!\\)[+*}](?:[^()\\]|\\.)*\)\s*[+*{]"
290+
)
291+
MAX_ALLOWLIST_PATTERN_LENGTH = 512
292+
# Entities longer than this skip pattern matching (fail-safe: the finding
293+
# is kept, never suppressed) so match time stays bounded.
294+
MAX_PATTERN_SUBJECT_LENGTH = 512
295+
296+
297+
def _compile_allowlist_patterns(
298+
allowlist_patterns: Optional[list[str]],
299+
) -> list["re.Pattern[str]"]:
300+
compiled = []
301+
for raw in allowlist_patterns or []:
302+
if len(raw) > MAX_ALLOWLIST_PATTERN_LENGTH:
303+
raise ValueError(
304+
"allowlist_patterns entries must be at most "
305+
f"{MAX_ALLOWLIST_PATTERN_LENGTH} characters"
306+
)
307+
if _NESTED_QUANTIFIER.search(raw):
308+
raise ValueError(
309+
"allowlist_patterns contains a quantified group with a nested "
310+
f"quantifier ({raw!r}), which risks catastrophic backtracking; "
311+
"rewrite the pattern without nesting quantifiers"
312+
)
313+
try:
314+
compiled.append(re.compile(raw))
315+
except re.error as exc:
316+
raise ValueError(
317+
f"allowlist_patterns contains an invalid regex: {raw!r} ({exc})"
318+
) from None
319+
return compiled
320+
321+
322+
def _apply_allowlist(
323+
entities: list[Entity],
324+
allowlist: Optional[list[str]],
325+
allowlist_patterns: Optional[list[str]],
326+
) -> list[Entity]:
327+
"""Drop entities whose exact text is allowlisted.
328+
329+
Matching semantics, deliberately strict for a security boundary:
330+
exact values are case-sensitive with no Unicode normalization, and
331+
patterns must fullmatch the entity text, so a partial match never
332+
suppresses a finding. Allowlist entries and patterns are operator
333+
configuration; treat them like code and never accept them from end
334+
users.
335+
"""
336+
if not allowlist and not allowlist_patterns:
337+
return entities
338+
exact = set(allowlist or [])
339+
patterns = _compile_allowlist_patterns(allowlist_patterns)
340+
return [
341+
entity
342+
for entity in entities
343+
if entity.text not in exact
344+
and not any(
345+
pattern.fullmatch(entity.text)
346+
for pattern in patterns
347+
if len(entity.text) <= MAX_PATTERN_SUBJECT_LENGTH
348+
)
349+
]
350+
351+
280352
def _needs_ner(entity_types: Optional[list[str]]) -> bool:
281353
if entity_types is None:
282354
return True
@@ -289,14 +361,25 @@ def scan(
289361
engine: str = "smart",
290362
entity_types: Optional[list[str]] = None,
291363
locales: Optional[list[str]] = None,
364+
allowlist: Optional[list[str]] = None,
365+
allowlist_patterns: Optional[list[str]] = None,
292366
) -> ScanResult:
293-
"""Scan text for PII entities."""
367+
"""Scan text for PII entities.
368+
369+
``allowlist`` exempts exact entity texts (e.g. your own support email);
370+
``allowlist_patterns`` exempts entities whose full text matches a regex
371+
(e.g. ``^\\d{10}$`` to stop unix timestamps matching as phone numbers).
372+
"""
294373
if not isinstance(text, str):
295374
raise TypeError("text must be a string")
296375

297376
if engine not in {"regex", "spacy", "gliner", "smart"}:
298377
raise ValueError("engine must be one of: regex, spacy, gliner, smart")
299378

379+
# Validate patterns up front so config errors fail fast even when the
380+
# text contains no entities.
381+
_compile_allowlist_patterns(allowlist_patterns)
382+
300383
regex_entities = _regex_entities(
301384
text,
302385
entity_types=entity_types,
@@ -305,6 +388,7 @@ def scan(
305388

306389
if engine == "regex":
307390
filtered = _filter_entity_types(regex_entities, entity_types)
391+
filtered = _apply_allowlist(filtered, allowlist, allowlist_patterns)
308392
return ScanResult(
309393
entities=_dedupe_entities(filtered), text=text, engine_used="regex"
310394
)
@@ -367,6 +451,7 @@ def scan(
367451
)
368452

369453
filtered = _filter_entity_types(combined, entity_types)
454+
filtered = _apply_allowlist(filtered, allowlist, allowlist_patterns)
370455
deduped = _dedupe_entities(filtered)
371456
return ScanResult(
372457
entities=deduped,
@@ -437,12 +522,16 @@ def scan_and_redact(
437522
entity_types: Optional[list[str]] = None,
438523
strategy: str = "token",
439524
locales: Optional[list[str]] = None,
525+
allowlist: Optional[list[str]] = None,
526+
allowlist_patterns: Optional[list[str]] = None,
440527
) -> RedactResult:
441528
"""Convenience wrapper: scan then redact."""
442529
scan_result = scan(
443530
text=text,
444531
engine=engine,
445532
entity_types=entity_types,
446533
locales=locales,
534+
allowlist=allowlist,
535+
allowlist_patterns=allowlist_patterns,
447536
)
448537
return redact(text=text, entities=scan_result.entities, strategy=strategy)

0 commit comments

Comments
 (0)