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
- Threat model, what this protects against and what it does not
- Locales, coverage per country and how to add one
- Custom patterns
- Tuning precision
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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.