Skip to main content

llmveil

Reversible PII redaction for LLM pipelines. Masks personal data before text reaches a model, and puts the original values back in the answer.

Local, deterministic, and it never touches the network.

pip install llmveil
from llmveil import redact, restore

result = redact("Email anna@example.de about invoice 4711.", locales=["de_DE"])
print(result.masked)                      # Email [EMAIL_1] about invoice 4711.
answer = call_your_model(result.masked)   # the model never sees the address
print(restore(answer, result.mapping))    # ...and you get it back

Read this first

Detection is best effort and incomplete. This library reduces exposure. It does not guarantee that no personal data reaches the model, and it is not a compliance control. Measured coverage, including what it misses, is in the table below. What it does and does not protect against is in docs/threat-model.md.

What it does

Validates instead of guessing. Anything with a check digit gets it verified: Luhn, IBAN mod-97 with per-country lengths, NHS mod-11, the German tax ID and its repeated-digit rule, the USt-IdNr, the Rentenversicherungsnummer weighted sum, ABA, Codice Fiscale, DNI. Where a checksum exists, a pattern without a validator is a false-positive generator.

Uses context, weighted by distance. Bestellnummer 47036892816 is a checksum-valid German tax ID by shape. The word in front of it says otherwise, and the nearest keyword wins, so a label two clauses away cannot override the one sitting right next to the number.

Restores tolerantly. Models return **[PERSON_1]**, <person_1>, [ PERSON_1 ], or the same placeholder five times. All of that restores. A placeholder the model invented is left exactly where it is and reported, because substituting a value there would be fabricating data.

Treats the mapping as a secret. It is never in a repr, never in an exception message, never in the stats, never in a scan report. Span objects carry offsets and labels only, so they are safe to log.

Never uses the network. Enforced by a test that blocks socket and then exercises every code path, including a check that the block itself works.

Measured coverage

Run python evaluation/run_eval.py to reproduce. The corpus is 33 labelled cases, 13 of them hard negatives.

Entity Precision Recall F1 TP FP FN
ADDRESS 1.00 1.00 1.00 2 0 0
API_KEY 1.00 1.00 1.00 2 0 0
CREDIT_CARD 1.00 1.00 1.00 1 0 0
DE_HANDELSREGISTER 1.00 1.00 1.00 1 0 0
DE_POSTAL_CODE 1.00 1.00 1.00 1 0 0
DE_SOZIALVERSICHERUNG 1.00 1.00 1.00 1 0 0
DE_STEUER_ID 1.00 1.00 1.00 1 0 0
DE_USTIDNR 1.00 1.00 1.00 1 0 0
EMAIL 1.00 1.00 1.00 2 0 0
GB_ACCOUNT_NUMBER 1.00 1.00 1.00 1 0 0
GB_NHS_NUMBER 1.00 1.00 1.00 1 0 0
GB_NINO 1.00 1.00 1.00 1 0 0
GB_POSTCODE 1.00 1.00 1.00 1 0 0
GB_SORT_CODE 1.00 1.00 1.00 1 0 0
IBAN 1.00 1.00 1.00 1 0 0
PERSON 1.00 0.00 0.00 0 0 2
PHONE 1.00 1.00 1.00 2 0 0
US_EIN 1.00 1.00 1.00 1 0 0
US_SSN 1.00 1.00 1.00 1 0 0
US_ZIP 1.00 1.00 1.00 1 0 0
overall 1.00 0.92 0.96 23 0 2

The gaps, which are the useful part

PERSON recall is zero without the NER extra. Names have no fixed shape, so nothing in the deterministic layer can find them. If names are your concern, pip install llmveil[ner] and set use_ner=True. Without it, plan another control for names.

Only three locales are complete. en_US, de_DE and en_GB. fr_FR, es_ES, it_IT, nl_NL, ch_CH and at_AT are scaffolded and raise a clear error rather than silently detecting nothing.

Unvalidated identifiers depend on context. Driver's licence numbers, US licence plates, passport numbers, UK account numbers and UTRs have no checksum and shapes that collide with ordinary strings. They ship at low confidence and only survive when a keyword supports them. Expect misses when the surrounding text gives no hint.

This corpus is small and written by the author. It exercises the hard paths deliberately. Numbers on your own data will be lower. Run llmveil scan over a sample of your corpus before trusting any of this.

Performance

10 240 chars   min 17.7 ms   median 24.1 ms   128 detections

Python 3.10 on Windows, the slowest supported configuration. This misses the 10 ms target that was set for it, by a wide margin. The time goes into 38 independent regex passes over the same text. The fix would be to combine them into a single alternation, which is a rewrite with real correctness risk and has not been done.

The median is load dependent and not worth quoting precisely: the same unchanged code measured anywhere between 13 and 30 ms on the same machine depending on what else was running. The minimum is the stable figure, because noise only ever makes a run slower, never faster, and it is what benchmarks/run_benchmark.py --baseline gates on. Measure on your own hardware before planning around any of these numbers.

The API

from llmveil import Redactor, redact, restore, restore_report, scan

result = redact(text, locales=["de_DE"])
result.masked      # str
result.mapping     # Mapping: placeholder -> original. This is the secret.
result.spans       # list[Span]: offsets, labels, confidence. No values.
result.stats       # counts and timing. No values.

text, report = restore_report(model_output, result.mapping)
report.restored, report.missing, report.unknown, report.repeated

Build a Redactor once and share it. It is immutable after construction and safe to use from several threads; add_pattern returns a new instance rather than mutating a shared one.

Wrapping a model call

from llmveil.adapters import protect, protect_runnable

# any object with .invoke(str), or any callable taking a string
chain = protect_runnable(prompt | llm | parser, locales=["de_DE"])
answer = chain.invoke("Schreib an anna@example.de")

@protect(locales=["de_DE"])
def ask(prompt: str) -> str:
    return call_the_model(prompt)

Both clear the mapping when the call returns, so it never outlives the request that produced it.

Multi-turn conversations

Pass the mapping forward. A seed alone is not enough, and this is documented rather than left to be discovered later:

first = redact(turn_one, locales=["de_DE"])
second = redact(turn_two, locales=["de_DE"], mapping=first.mapping)
# anyone seen in turn one keeps the same placeholder in turn two

Options

Option Default Effect
locales ("en_US",) Locale packs. Universal detectors always run.
placeholder_style "bracket" bracket, angle, token, brace, surrogate
min_confidence 0.4 Global floor; thresholds overrides per entity
allowlist, allowlist_patterns () Never redact these
denylist () Always redact these
entities, exclude_entities Restrict what is detected
use_context True The distance-weighted keyword layer
use_entropy True Generic high-entropy secret detection
use_ner False Needs llmveil[ner]
explain False Record why each span matched
strict False Raise instead of reporting

Config comes from code, a dict, a TOML [llmveil] table, or LLMVEIL_* environment variables. A misspelled environment variable is an error, not a silent default: a security setting must not fail open.

Placeholder styles

bracket is the default and the safest. It survives tokenisation as a stable token sequence the model has no reason to alter, and it restores exactly.

surrogate generates realistic fakes instead: a German name for a German name, an IBAN with a genuinely valid checksum, a Luhn-valid card in a published test range. Use it when a model behaves badly on bracket tokens. It restores less reliably, because a fake name is a word and models inflect words: "Müller" comes back as "Müllers Anfrage" and no longer matches. That trade is why bracket is the default.

Command line

llmveil redact notes.txt -l de_DE --mapping map.json -o masked.txt
llmveil restore masked.txt --mapping map.json
llmveil scan ./docs --report              # find PII, change nothing, print no values
llmveil scan ./docs --fail-on-findings    # as a CI gate
llmveil locales

scan is worth using on its own, before any model is involved. It never prints a detected value, only offsets, labels and counts.

Installation

pip install llmveil              # deterministic and context layers
pip install llmveil[ner]         # adds names, organisations, locations
pip install llmveil[crypto]      # encrypted mapping serialisation
pip install llmveil[all]

There is no framework extra. The model wrapper is duck-typed and imports nothing, so it works with LangChain, LlamaIndex or a plain function without any of them being installed.

Python 3.10+. The core has no dependencies at all, except tomli on 3.10, which has no tomllib in the standard library. All dependency licences are Apache, MIT or BSD.

The NER extra needs a model, installed once by you and never downloaded by this library:

pip install llmveil[ner]
python -m spacy download de_core_news_sm

Development

pip install -e ".[dev]"
pytest                              # 354 tests
ruff check . && ruff format --check . && mypy
python evaluation/run_eval.py       # regenerates the table above
python benchmarks/run_benchmark.py

The numbers in this README are generated, not typed. If the table and evaluation/run_eval.py disagree, the table is wrong.

Documentation

Author

Built by Lars Gross. More of my work at larsgross.com.

License

Apache-2.0. See LICENSE.

Metadata

Release files for llmveil 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for llmveil 0.1.0
File Size Uploaded Provenance Actions
llmveil-0.1.0.tar.gz 80.5 kB With provenance Details for llmveil-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for llmveil 0.1.0
File Interpreter ABI Platform Actions
llmveil-0.1.0-py3-none-any.whl Python 3 none any Details for llmveil-0.1.0-py3-none-any.whl

Total release size: 153.2 kB

Release files / llmveil-0.1.0.tar.gz

Download URL llmveil-0.1.0.tar.gz
Size 80.5 kB
Tags Source
SHA-256 checksum
How to use checksums
465a927e6ed9bd1df95b52c98824e54262709e2b3cb021d5d0356c7979835918
BLAKE2b-256 checksum
How to use checksums
17322ffa28750a6305013ac911cc249aca816ffd31fb7f4b5061388bd53f38bf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.

Transparency log

Release files / llmveil-0.1.0-py3-none-any.whl

Download URL llmveil-0.1.0-py3-none-any.whl
Size 72.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
66f86762333be43aef5e328e7aed9404b9d142e1288b9a84ad4dcf824c330464
BLAKE2b-256 checksum
How to use checksums
32e0826c249b3a1c5ab5ba41a4f22d9fe2c03e39e72a33e3464dad6a585344a2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.

Transparency log

Security for llmveil 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for llmveil-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 4, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page