Changelog¶
All notable changes to wardcat will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Fixed¶
-
A pre-Ampere CUDA card no longer gets a dtype it cannot run. The Transformers backend hardcoded
bfloat16; those cards have no bf16 support at all, andtorch.cuda.is_bf16_supported()says so, so they getfloat16now. -
with_llm(dtype=...)chooses the weight dtype. A torch dtype name ("float16","bfloat16","float32"); an unknown name is refused rather than silently ignored.
The default stays bfloat16 everywhere else, including Apple Silicon, and
that is worth writing down because the obvious change does not work. Metal has
no bf16 arithmetic unit and emulates it, so fp16 ought to be faster there —
but loading a model as float16 with device_map="auto" on MPS segfaults
the interpreter on the torch/transformers versions this package supports,
where the same model as bfloat16 answers in 13 seconds (measured on an M1
with SmolLM2-135M-Instruct). A default that crashes is worse than one that is
merely slow. The argument is there for anyone whose stack does better.
[1.2.0] — 2026-09-08¶
Added¶
- Eight more checksum-verified filters, and the tier that makes them usable.
CRYPTO_WALLET(Bitcoin Base58Check plus bech32/bech32m segwit, both fully verified; Ethereum0xaddresses on their shape — EIP-55 needs keccak-256, which the standard library does not carry),NHS_NUMBER(mod-11),BANK_ROUTING(ABA mod-10 over a Federal Reserve prefix) andIMEI(Luhn).EU_NATIONAL_IDgains Dutch BSN and Polish PESEL, and — the larger change — every scheme it already claimed is now actually checked: a Spanish DNI whose letter does not follow from its digits, or an INSEE number with the wrong two-digit key, used to match on format alone.
Three of these checksums are weak: a bare digit run passes the ABA check, the
NHS mod-11 or the IMEI Luhn roughly once in ten. Refusing the bare form costs
real coverage; treating it as proven flags ordinary reference numbers. Both
forms are matched, and a match with no supporting keyword beside it lands in a
new uncued confidence tier at 0.70.
min_confidence— a floor on what gets acted on. Spans below it are dropped after overlap resolution. It defaults to0.8, which sits above the uncued tier and below every other one, so nothing detected before this release changes and the new bare-form matches are found but left alone.with_min_confidence(0.6)acts on them;with_min_confidence(0.95)goes the other way, keeping only checksummed and high-precision structural matches.
Applying the floor after overlap resolution is what makes the NHS filter
safe: the NHS 3-3-4 grouping is also the US phone grouping, and mod-11 lets
about one US-format number in eleven through. A number both layers claim is
resolved as PHONE at 0.97 rather than dropped as a weak NHS match.
USERNAME— the account name beside the password. An online identifier tied to a person, and the other thing in a personnel record with no shape of its own:ahmet.yilmazis a handle in one sentence and a filename in the next. Cued the same way as the credential —kullanıcı adı,kullanıcı kodu,hesap adı,username,user id,login,nick— with the Turkish suffixes allowed for.
How much the keyword proves depends on whether it is assigning anything. With
a connector — username: jsmith, hesap adı = jsmith — somebody is plainly
naming a handle, so any handle-shaped token counts. Without one the keyword
sits in ordinary prose as often as not ("the login page", "kullanıcı adı
alanı"), and no stoplist can enumerate what follows it, so the token has to
carry a mark an ordinary word does not: a dot, underscore, hyphen or digit.
Measured over 32 sentences, that rule gives no misses and no false positives;
a stoplist alone gave eight false positives out of twelve.
Two details worth knowing. The value is matched case-sensitively, because
re.IGNORECASE folds the Turkish dotless "ı" into [A-Za-z] and let "alanı"
through as a handle. And a trailing full stop is taken with the value rather
than trimmed: it reads like the sentence's, but a password or handle may
genuinely end in one, and trimming the wrong one leaves a character of the
real value in the text. Reported at 0.90, value only, keyword left in place.
- A credential written out in prose is now caught. The secret patterns keyed
on a provider prefix —
sk-,ghp_,AKIA— and a password typed into a sentence has no prefix, soparolası ise TestPass!2026went straight through. The word introducing it is the evidence instead:password,passphrase,api key,access token,erişim kodu, and the Turkish roots with their possessive and case suffixes (şifresi,parolanız), joined by=,:,is,iseor nothing at all.
Prose puts ordinary words in that position too — "şifre yanlış",
"password is unknown" — so the value must look like a credential: at least six
characters mixing two of {lower, upper, digit, symbol}. A lower-case word never
does. Reported at 0.90, the heuristic tier, not the 0.97 a recognised
prefix earns, and only the value is taken so the sentence still reads:
kullanıcı şifresi: [CUSTOM_SECRET].
-
More provider secrets. GitHub fine-grained PATs (
github_pat_), Hugging Face tokens, Shopify, DigitalOcean, Slack app-level tokens, Azure storage account keys, Google service-account key ids, and the AWS secret access key — which has no prefix, only a 40-character shape, so it is matched under the name it is written with. Sentry DSNs too, which fixes a mislabel: the DSN's public key sits where an email address's local part would, so it was reported as anEMAILunder that type'swarnaction and left in the text. -
Reversible masking —
Action.TOKENIZEandScanResult.restore(). The existing actions are one-way;tokenizereplaces each value with a numbered placeholder and keeps the mapping on the result, so the values can be put back afterwards. A placeholder is[TYPE_index_contextid]—[EMAIL_1_9f3a2c8b71d4]. The index is per entity type in order of appearance and one token is reused for one distinct value, so a repeated name stays a single referent for the model reading the text.
The context id is drawn once per scan (ScanResult.context_id, 12 hex
characters) and stamped into that scan's tokens. Two requests arriving together
both hold an "EMAIL number 1"; without it their placeholders would be the same
string, and restoring one request's answer against the other's result would
silently substitute the wrong person's value. With it there is nothing to match:
the token is reported as foreign, left in the text, and is_complete is
False. restore(answer, strict=True) raises ContextMismatch instead — the
right default for a request handler — and restore(answer, also=[earlier])
accepts an earlier turn's placeholders in a multi-turn exchange. The same
mechanism covers a model that mangles a token: no match, so the value does not
come back, and it can never come back as somebody else's.
guard = Wardcat(salt="s").add_entities([Entity.EMAIL], action=Action.TOKENIZE)
result = guard.scan("Mail ali@example.com") # → 'Mail [EMAIL_1_9f3a2c8b71d4]'
answer = call_llm(result.sanitized_text) # the model never sees the value
print(result.restore(answer))
restore(text) puts the originals back and returns a RestoredText whose
str() appends an ordered source list — per placeholder, the filter that caught
it, the action applied, the value it stood for, an occurrence count and the
detection confidence — with .text, .sources_block(), .substitutions,
.unrestored and .is_complete for programmatic use. It also reverses hash
output; redact/mask placeholders that stood for more than one value are
reported as ambiguous and left in the text rather than guessed, and
reapply(Action.TOKENIZE) derives a reversible view of an existing scan without
re-detecting. ScanResult.token_map exposes the placeholder → value mapping for
callers that must restore in another process.
Anonymization runs after detection, so this is layer-independent: a span found by regex, SpaCy NER or the LLM layer tokenizes and restores identically (covered by a test with a stubbed LLM backend).
The reverse map is raw PII by design — token_map, restore() and the
source list all carry original values, so they belong on the trusted side.
New exports: RestoredText, Substitution, UnrestoredValue, TokenAllocator,
ContextMismatch.
with_phone_regions()— libphonenumber-backed PHONE detection. The built-in pattern is precision-first and covers Turkish, French and German national forms plus E.164; a number written the way it is written in Manchester or Madrid falls through it. On presidio-research's corpus that meant 21% recall — 73 of 92 numbers missed. More regex will not close this: every numbering plan is its own set of shapes, and telling a valid Madrid mobile from three arbitrary digit groups requires knowing the plan.
guard.with_phone_regions("GB", "ES") # libphonenumber for these regions
guard.with_phone_regions() # back to the built-in pattern
Opt-in and optional: pip install "wardcat[phone]", and a missing package logs
a warning and falls back to the pattern. Callers who never call it get
byte-identical behaviour. Matches report 0.90 confidence rather than the
pattern's 0.97 — a numbering-plan check is stronger than a digit run but
weaker than a checksum, and each added region widens what counts as a number.
PHONE F1 on that corpus: 0.342 → 0.770 (recall 21% → 67%).
Changed¶
- Two kinds of NER span moved into types of their own — enable them or lose
the coverage. SpaCy's
GPE/LOClabels were folded intoADDRESS, so "Germany" and "Moda Caddesi No:42" arrived as the same kind of finding; andNORP— nationality, religious and political group, GDPR Article 9 data — was folded intoORG, which both mistyped it and, underORG'swarnaction, left it in the text. Place names areLOCATIONnow and group names areNRP.
This narrows an existing configuration. A guard with ADDRESS enabled on
the NER layer no longer redacts "Germany"; one with ORG enabled no longer
reports "Kurdish". Nothing errors and nothing is over-detected — the spans
simply stop being reported, which is the failure that does not announce
itself, so the first scan of an affected guard now logs a one-time warning
naming the type to add:
guard.add_entity(Entity.LOCATION, Action.REDACT, layers=["ner"])
guard.add_entity(Entity.NRP, Action.REDACT, layers=["ner"])
LOCATION is on in the shipped example policy. NRP is off: "Turkish" and
"Catholic" are ordinary vocabulary, and redacting every occurrence would wreck
the text for anyone not doing Article 9 work.
-
tokenizeis now a built-in action name. It was the running example of a custom action in the README and docs; those now usevaultinstead.register_action("tokenize", ...)still wins over the built-in — overriding a registered name is unchanged behaviour — but a project that did so is now shadowing a built-in rather than adding a new action. -
with_llm()now says what it switches on. Unlikewith_ner(), which enables no entity by itself, the LLM layer carries its own default entity policy (31 types, 27 of them on, each with its own action), so.with_llm(...).add_entity(EMAIL, ...)has always detected and anonymized far more than the one type named — under the policy's actions, not the caller's. The behaviour is unchanged; the first scan now logs a one-time warning naming the entities that came along and how to take control of them (add_entity(name, action, layers=["llm"])/remove_entity(name)). Callers who supply a YAMLconfig_pathchose their own policy and are not warned. Thewith_llmdocstring no longer claims to mirrorwith_ner, and the asymmetry is documented in the README and the configuration guide.
Fixed¶
-
phone_regionswas rejected as an unknown YAML key. It has been a valid configuration key since the libphonenumber work, but was never added to the known-key set, so a config file that used it logged a typo warning. -
Credit card ranges the issuer prefixes were missing. Measured against presidio-research's 1500-sample corpus,
CREDIT_CARDrecall was 54% — 62 of 136 cards undetected, and none of them failed Luhn. The checksum gate was working; the prefixes simply had no branch for JCB (35xxand the legacy 15-digit1800/2131), Maestro, 19-digit Visa, or the MasterCard 2-series (2221–2720), which has been issued since 2017. All added, all Luhn-validated like the existing branches; the 19-digit Visa branch is ordered ahead of the 16-digit one so the shorter branch cannot consume the first sixteen digits and then be rejected by the trailing boundary check.
Widening the prefixes costs no precision — Luhn remains the gate — which is why
the false-positive suite gained four near-miss numbers that sit inside the new
ranges and fail the checksum. CREDIT_CARD F1 on that corpus: 0.705 → 0.958
(recall 54% → 92%, precision unchanged at 100%).
- The NER span filters were rejecting real names. Three faults, each measured
against presidio-research's corpus with
en_core_web_sm— the point being to get more out of the model already in use rather than reach for a bigger one.
The capital-letter rule fired where capitals mean nothing. A span whose words are all lower-case was rejected on the reasoning that names are capitalized and common-word sequences are not — but that reasoning only holds in a document that capitalizes, and every span it wrongly rejected sat in a document with zero uppercase letters (chat logs, ASR output, lower-cased pipelines). Counted over the corpus it removed 15 real names to remove 7 false ones. It now applies only where the surrounding text uses capitals at all.
This is a deliberate trade: in a lower-cased document a common-word sequence
can now come through as a PERSON. Over-flagging is the safer direction for a
redaction tool, and the measurement says it is also the more accurate one.
Edge punctuation is now trimmed rather than rejected — a model that swallows the
colon after a speaker's name produced tracy:"i'm, and the digit rule threw the
name away with the punctuation. And ORG gained the digit/address-punctuation
filter PERSON already had, removing 42 address fragments labelled as companies
at the cost of 4 real ones; the street-keyword list is deliberately not applied
to ORG, since it would take "Wall Street Journal" with it.
PERSON F1 0.670 → 0.680, ORGANIZATION 0.299 → 0.305. Modest, and that is
itself the finding: raw en_core_web_sm recall for PERSON is 64% and the
filters now pass 64%, so what remains is the model's ceiling, not ours.
-
An IP address is no longer read out of a longer dotted run.
03.93.92.16.85is a French phone number whose first four groups are a syntactically valid dotted quad. The bug predates this release — it never showed because the built-in PHONE pattern scored the same confidence and won the overlap. Anchors either side now require the quad to stand alone:IP 10.0.0.7.is still an address,1.2.3.4.5is not. -
Test suite: registering an action in one test no longer leaks into the rest of the session (the registry is process-global; a
conftestfixture now restores it after each test).
[1.1.2] — 2026-07-29¶
Fixed¶
with_llm(backend="vllm")no longer silently talks to Ollama.with_llmhardcodedbase_url="http://localhost:11434"(Ollama's port) as its default, which overrode each backend factory's own default — so selecting thevllmbackend without an explicitbase_urlsent requests to Ollama's port instead of vLLM'shttp://localhost:8000/v1.base_urlnow defaults to unset and each backend applies its own default (Ollama/OpenAI-compatible →11434, vLLM →8000/v1); passbase_urlonly to point at a non-default host. An explicitbase_urlis still respected, and reconfiguring to another backend resets to that backend's default.
1.1.1 — 2026-07-14¶
Changed¶
- Broadened the project description.
wardcatdetects and anonymizes not only PII but sensitive data in general — secrets, credentials, and confidential content surfaced by theis_sensitivesemantic gate. Updated the PyPI summary, README tagline/prose, and the repository description accordingly. No code or API change.
1.1.0 — 2026-07-14¶
Added¶
ScanResult.reapply(action, entities=None)— a public method to derive a differently-anonymized result from a single scan without re-detecting. Detection (regex + NER + LLM) is the expensive step and runs once;reapplyreuses the detected spans and re-runs only the cheap anonymization stage, reusing the originating guard's salt sohashoutput stays consistent. Passentitiesto re-anonymize a subset of the detected types. Returns a newScanResult(the original is not mutated); raisesConfigErroron an unknown action.
result = guard.scan(text)
masked = result.reapply(Action.MASK).sanitized_text
hashed = result.reapply(Action.HASH).sanitized_text
This promotes what wardcat-mcp previously did with private internals into
supported public API.
1.0.1 — 2026-07-14¶
Security¶
- Fixed a regex-denial-of-service (ReDoS) in the built-in
EMAILand URI-credential patterns. Their unbounded character classes backtracked in quadratic time on adversarial input such as"a.a.a…"; a ~500 KB string (under the defaultmax_text_bytescap) could burn minutes of CPU in a singlescan(), and the URI-credential pattern ran on every scan regardless of which entities were enabled. The patterns now use bounded quantifiers (RFC-justified: email local part ≤64, domain ≤255) so matching is linear, guarded by a cheap literal pre-filter. Worst-case input at the size cap now completes in well under a second. Added a ReDoS regression test. - Hardened SpaCy model auto-download.
download_model()now validates the model name against^[a-z]{2,3}_[a-z0-9]+(?:_[a-z0-9]+)*$before it reachesspacy download/pip install/ a release-download URL, so an application that lets end users choose a model name cannot turn it into an arbitrary package or URL path segment. - The custom-pattern ReDoS timeout now returns promptly on timeout instead of blocking on the still-running match thread (the executor is shut down with
wait=False).
1.0.0 — 2026-07-13¶
First stable release — published to PyPI (pip install wardcat). The public
API (Wardcat, the with_ner/with_llm builders, Entity/Action/Language/
Backend, the ScanResult/Violation result types, and the exception
hierarchy) is now considered stable and follows semantic versioning: breaking
changes only in a future 2.0.
Fixed¶
- README Quick Start crashed: the example printed
v.action.value, butViolation.actionis a plainstrby design (it compares equal to theActionconstants) — running the snippet raisedAttributeError. The example now printsv.action. - Misleading salt warnings: the empty-salt warnings in
Wardcatand the engine told users to "set theWARDCAT_SALTenvironment variable", but the library never reads environment variables (configuration is explicit by design). The warnings now say to passsalt=...toWardcat(...).
CI¶
- The test matrix now runs on macOS in addition to Linux (× Python 3.11–3.13), so the "tested on Linux | macOS" badge is honest.
- The release workflow now publishes to PyPI via Trusted Publishing on every
v*tag (in addition to cutting a GitHub Release).
Docs¶
- README readability: added a status-badge row (CI, release, platforms, Python, license, Ruff, coverage) and a compact table of contents.
- Added a short Disclaimer to the README: wardcat is a best-effort PII detector, not legal advice or a substitute for compliance review (GDPR/KVKK), and users are responsible for validating it against their own data. (The MIT
LICENSEalready covers warranty/liability.) - Installation instructions (README + docs) now lead with
pip install wardcat; the from-source instructions remain for development. - Removed stale docstring references to constructor
llm_*/spacy_*arguments that no longer exist (the constructor takes onlyconfig_pathandsalt; layers are configured with the builders).
0.9.3 — 2026-07-11¶
Docs¶
- Documented the async/concurrency API.
scan_asyncwas essentially undocumented (the intro and examples were all synchronous). Added an "Async & concurrency" section to the README and the layers guide —scan_async/scan_batch_async/is_sensitive_async,asyncio.gatherfor overlapping requests, an async FastAPI handler, the "build one guard, share it, don't reconfigure while serving" rule, and the note that the LLM server (vLLM /OLLAMA_NUM_PARALLEL) is the real parallelism ceiling. Also fixed the docs FastAPI example (was calling syncscan()in an async handler). - Example prose in
examples/demo.pyis now English (docstring, print output, salt) while keeping the Turkish PII sample that demonstrates Turkish-specific entities likeTC_ID.
0.9.2 — 2026-07-10¶
Fixed¶
- Repaired the dev scripts under
scripts/(live_scan_test.py,filter_coverage_test.py), which still called the removed constructor NER/LLM arguments (Wardcat(use_ner=…, use_llm=…, llm_model=…)) and would crash — rewritten to thewith_ner()/with_llm()builders.
Docs¶
- Consistency pass over README + docs (and docstrings that surface in the API reference): corrected the stale "localhost HTTP is warned" claim (loopback is allowed silently since 0.7.0 — only remote HTTP is blocked); updated the
Backendenum /model_catalogdocstrings that still showed the removedWardcat(use_llm=…)constructor andmodels setupCLI; renamed leftoveraiguard-named tests and a comment referencing the deletedtest_cli.py.
0.9.1 — 2026-07-10¶
Fixed¶
- Adjacent IBANs are now split. Two (or more) IBANs run together with no separator (
TR…1326DE…3000) used to be read as one span that failed the checksum, so neither was flagged. The regex now captures the whole run and_segment_ibans()splits it back into individual IBANs using the ISO 13616 per-country lengths — each segment is mod-97-validated, so a valid IBAN followed by garbage is not force-split. Removed the correspondingxfail.
Docs¶
- Trimmed the README "Known Limitations": dropped the now-fixed adjacent-IBAN row and the stale Transformers-backend row (the transformers backend is exercised against a real model in CI — nightly and on backend-file changes). Noted that a bare 5-digit number is still caught as the generic
POSTAL_CODE. - Added a Mitigation column to the "Known Limitations" table, so each remaining limitation states how to work around it (mostly: enable the LLM layer for contextual/unlabeled cases).
0.9.0 — 2026-07-09¶
Added¶
- Localized
is_sensitive()prompt —with_llm(language=...). The semantic sensitivity gate can now run its system prompt in one of the base languages —tr,de,fr(oren) — selected viawith_llm(language="tr"). A prompt in the text's own language can improve smaller models' judgement; any other/unset value keeps the English, multilingual-aware prompt. This affects onlyis_sensitive(), not thescan()entity-detection prompt (which stays multilingual by design). Available on the YAML side asllm_detector.language.
Removed¶
- BREAKING — LLM backends are no longer user-extensible. Removed the public
register_backend()/registered_backends()helpers and droppedBaseLLMBackendfrom the package's public API. A user-supplied backend sits outside wardcat's safety checks (the plaintext-HTTP-to-remote guard, PII handling), which is exactly where sensitive data would leak, so backends are now a fixed set of the four built-ins —ollama,openai_compatible,vllm,transformers— selected via theBackendenum. Migration: point a built-in at your endpoint instead;openai_compatiblecovers most OpenAI-style gateways (LM Studio, LocalAI, LiteLLM, hosted OpenAI-compatible APIs). Pluggable actions (register_action) are unaffected.
0.8.1 — 2026-07-09¶
Changed¶
is_sensitive()now shares the engine's input safeguards. It previously went straight to the LLM, bypassing the size limit and chunking. It now rejects oversized input (max_text_bytes, likescan()) and chunks long text at paragraph boundaries — any chunk classified sensitive makes the whole text sensitive (short-circuits on the first hit) — so a long document can no longer be silently truncated into a misleadingFalse. Chunking logic is now shared (wardcat.utils.text.chunk_by_paragraph) between the LLM detector and this gate.is_sensitive()prompt hardened against injection. The classification prompt now states the text is untrusted data, not instructions, and to ignore embedded commands (e.g. "answer false"). Best-effort, not a guarantee — seeSECURITY.md.
Security / Docs¶
SECURITY.md: documented thathashis deterministic (records are linkable by their hashes) and that theis_sensitive()guardrail is prompt-injectable; pair it withscan()in adversarial settings.
Internal¶
- Stricter typing:
disallow_untyped_defsis now on in mypy; annotated the remaining untyped defs.
0.8.0 — 2026-07-09¶
Added¶
Wardcat.is_sensitive(text) -> bool— a semantic sensitivity gate (LLM-only). A holistic true/false decision about whether a text contains sensitive information (PII, credentials, financial, health/special-category, or confidential business data), as opposed toscan()'s per-entity extraction. It runs a single classification call against the configured LLM — no regex/NER, no entities to enable — so it catches things the enumerated detectors miss (e.g. unreleased financials or a confidential project). Configure it through the existingwith_llm(...)builder and callguard.is_sensitive(text)(orawait guard.is_sensitive_async(text)). Requires the LLM layer (raisesConfigErrorotherwise); empty text isFalse; fail-closed — if the backend is unreachable the error propagates rather than silently returningFalse.
0.7.0 — 2026-07-09¶
Added¶
supported_languages()— a language-selection hook. Exposes the sorted ISO 639-1 codes wardcat ships a SpaCy NER model for (de, en, es, fr, it, nl, pt, tr), exported from the package root. wardcat deliberately does not bundle language detection (that would add an opinion and a dependency to apyyaml+httpxcore), so this supports the detect-then-select pattern: detect the language with your own tool, checkcode in supported_languages(), then pass it toWardcat().with_ner(language=...). Documented under the NER layer guide.- Orphan-entity warning. Enabling an entity whose supporting layer is off — e.g.
add_entity(Entity.PERSON)with neitherwith_ner()norwith_llm()— was a silent no-op.scan()now logs a one-time warning naming the entity and the fix, so a mis-wired policy is no longer invisible. - Actionable "model not found" error (Ollama). A request for a model that has not been pulled now raises a
ConnectionErrorthat lists the installed models and the exactollama pull …command, instead of a bare HTTP 404.
Changed¶
- BREAKING — NER is configured only through the
with_ner()builder. The constructor no longer acceptsuse_ner,spacy_model,language,spacy_size, orspacy_auto_download;Wardcat()now takes justsaltand an optionalconfig_path. This removes the dual configuration surface (constructor and builder did the same thing). Migration:Wardcat(language="de")→Wardcat().with_ner(language="de");Wardcat(use_ner=False)→Wardcat(); thespacy_auto_download=argument isauto_download=onwith_ner(). A YAML config may still setuse_ner: truewith aspacy_model. Builder order does not matter. - Loopback LLM over HTTP no longer warns or needs
allow_http. Traffic tolocalhost/127.0.0.1/::1never leaves the machine, so the common local-Ollama setup works with a barewith_llm(...)— no warning, noallow_http=True. Remote HTTP still raises unlessallow_http=Trueis passed.
Removed¶
- GLiNER zero-shot NER layer. The
glinerdetection layer (thewith_gliner()builder, thewardcat[gliner]extra,gliner_detectorconfig, theGLiNERDetector, and the"gliner"layer selector) has been removed from the library. Detection is now regex + SpaCy NER + LLM (three layers). Ongoing GLiNER work continues on thefeature/glinerbranch and may return in a future release. Migration: replacewith_gliner()withwith_ner(...)(SpaCy) and/or the LLM layer, and drop thewardcat[gliner]extra.
0.6.0 — 2026-07-06¶
Added¶
- Transformers backend is now tested against a real model automatically. The live pipeline test existed but its workflow was
workflow_dispatchonly, so it never ran unless someone remembered to click it — which is how the mock-only unit tests let thedtype/torch_dtyperegression ship. Thereal-model-testsworkflow now runs nightly (catching drift from a newtransformers/torchrelease) and on PRs/pushes that touchtransformers_backend.pyor its deps (gating the exact code the mocks can't cover), with HuggingFace model caching so it stays quick. The fast PR suite is unchanged. - Precision/recall evaluation harness + CI gate.
tests/benchmark/eval_harness.pyscores the detectors over a labelled, multilingual, checksum-valid corpus and reports per-entity precision/recall/F1 (run it:python -m tests.benchmark.eval_harness). A companion regression gate (test_precision_recall.py) asserts full recall and zero false positives on the curated corpus, andtests/benchmark/now runs in CI — previously onlytests/unitandtests/integrationdid, so the existing false-positive suite never actually gated merges. Widen coverage by adding rows toCORPUS. - Confusable ("homoglyph") folding —
normalize_confusables(on by default). A common evasion is swapping a Latin character for a visually identical one from another script so an ASCII-oriented regex misses it:ali@tеst.comuses a Cyrillicеin the domain;4111…/٤111…use fullwidth / Arabic-Indic digits. Regex matching now runs on a confusable-folded copy of the input, so these are detected. Folding is length-preserving (wardcat.utils.normalize.fold_confusables), so spans are still reported against — and redaction still removes — the original substring; checksums (card/IBAN/TC) are validated on the folded canonical form. The map is a curated skeleton of unambiguous same-case Latin lookalikes plus digit/fullwidth ranges (not the full Unicode confusables table, and not NFKC — which can change length). Disable withnormalize_confusables: false. Also corrected the README "Known Limitations" — card double-space (4111 1111…) and dot (4111.1111…) separators were already handled. - First-class vLLM backend (
Backend.VLLM/backend="vllm"). Talk to a model served by vLLM directly. vLLM exposes an OpenAI-compatible API, so this reuses the OpenAI-compatible transport but adds vLLM-appropriate defaults (base_urldefaults tohttp://localhost:8000/v1;api_keyoptional) and a native chat path —complete_messages()posts the real messages array (system/user roles preserved) to/chat/completionsinstead of the flattened single-prompt fallback, which matters for instruct models served with a chat template. Enable it withwith_llm(backend=Backend.VLLM, model="…", base_url="http://…:8000/v1"). The model is served vLLM-side, sopull_model()raises (as with any OpenAI-compatible endpoint). Generic OpenAI-compatible servers (LM Studio, LocalAI, LiteLLM) continue to useBackend.OPENAI_COMPATIBLE.
[0.5.0] — 2026-07-04¶
⚠ BREAKING — the project was renamed
ai-guard→wardcat. The import package is nowwardcat(from wardcat import Wardcat), the main classAIGuardis nowWardcat, and the distribution and its extras arewardcat/wardcat[ner]/wardcat[gliner]/wardcat[transformers]/wardcat[all]. Example env-var names in the docs are nowWARDCAT_*. Update your imports and install commands — no runtime behaviour changed.
Added¶
- GLiNER zero-shot NER layer. A new detector layer wraps the PII-tuned GLiNER2 model (
fastino/gliner2-privacy-filter-PII-multi, Apache-2.0) — a lightweight bidirectional-encoder NER that sits between SpaCy NER and the LLM. Enable it with the chainablewith_gliner()builder (mirrorswith_ner()/with_llm()); entity types are opt-in viaadd_entity(...). It runs as a SpaCy alternative or alongside SpaCy — the engine merges both layers' spans and a regex span always wins an overlap (GLiNER spans are capped at 0.88 confidence — below the lowest regex tier, so any deterministic match beats a GLiNER guess). The new"gliner"layer is selectable vialayers=["gliner"]andsupported_entities("gliner"). Ships as an optional extra —pip install "wardcat[gliner]"(pulls in torch viagliner2[local]); the base install stayspyyaml+httpx. The default model covers EN/FR/ES/DE/IT/PT/NL (not Turkish — keep the regex/LLM layers for Turkish text). Long inputs are automatically chunked (chunk_size, default 1500 chars) so the model's fixed maximum length does not silently truncate long documents. Configurable via YAML undergliner_detector:(enabled,model,threshold,quantize,chunk_size). - Degraded-scan visibility —
ScanResult.warnings. When a detector layer cannot run (most commonly the LLM backend being unreachable), the scan now records the failure onresult.warnings(and inredacted()) instead of silently swallowing it. The other layers still run and return results, but a non-emptywarningslist tells the caller detection was degraded — no more thinking the LLM ran when it never connected. A backendConnectionErrornow propagates from the LLM detector so the engine can surface it uniformly for any layer. - Value propagation (
with_propagation()). Once any layer detects a value, every other whole-token occurrence of it in the text is anonymized too — closing the gap where a model-based layer (GLiNER/NER/LLM) reports a repeated value only once (verified: a name GLiNER caught 2 of 3 times leaked one occurrence; with propagation, 0 leaked). Off by default (it can over-redact); only exact, token-bounded matches ≥min_lengthchars (default 3) propagate, and deterministic regex spans still win overlaps. Config keys:propagate_matches,propagate_min_length.
Fixed¶
- Turkish
ADDRESSregex over-capture. The pattern grabbed 1–5 words of any case before the street-type keyword, so it swallowed lowercase filler and crossed sentence boundaries (e.g."iletişime geçilebilir. İkamet adresi Bağdat Caddesi") while missing theNo:/Daire:tail. It now takes only 1–3 capitalized preceding words and optionally captures aNo:/Daire:/Kat:suffix —"… Bağdat Caddesi No:127 Daire:8"is captured cleanly. DATE_OF_BIRTHis now an LLM entity. It was only aregex/glinerentity, so the LLM layer was never asked for birth dates — a date the regex missed (e.g."14.03.1985 doğumlu", keyword after the date) fell through both active layers. Added aDATE_OF_BIRTHdescription to the LLM prompt so the LLM catches contextual birth dates. (The prompt already had few-shot examples for it.)- Transformers backend real inference (version-aware dtype kwarg). The HuggingFace pipeline was built with a
dtype=kwarg, which only exists in transformers ≥ 4.56; on older versions it was silently forwarded togenerate()and rejected — so real inference failed with "model_kwargs are not used: ['dtype']" on every call. The backend now selects the dtype kwarg by the installed transformers version —torch_dtypeon 4.40–4.55,dtypeon 4.56+ (wheretorch_dtypeis deprecated and removed in a later major) — so real inference works across the whole>=5.13,<6range and older installs. This was never caught because the backend's tests are mock-only and it had not been run against a real model; it is now verified live under transformers 5.13 (SmolLM2-135M) and covered by parametrized regression tests across the 4.55/4.56/5.x boundaries.
Removed¶
- The command-line interface is gone. The
wardcatconsole script and thepython -m wardcatentry point (scan,batch,spacy,modelssub-commands) were removed, along with the[project.scripts]entry point and theWARDCAT_*environment-variable handling that only the CLI used.wardcatis a library; drive it from Python (from wardcat import Wardcat). Manage models with their native tools instead: SpaCy models viapython -m spacy download <model>(or thelanguage=builder, which auto-downloads), and on-prem LLMs viaollama pull <model>(orwith_llm(model=..., auto_pull=True)).
Changed¶
- Overlap resolution is confidence-first and robust to chained overlaps. When detected spans overlap, the engine now keeps the strongest span — highest confidence first (a checksum/regex
1.0span beats a longer fuzzy NER/LLM0.85span), then longest, then earliest — instead of blindly keeping the longest. Every candidate is checked against all already-kept spans, closing a gap where a chained/nested overlap could let a span slip through. This prevents a Luhn-validated card from being lost to an overlappingADDRESSguess. - Tiered regex confidence. Regex detections no longer all report
1.0. Confidence is now tiered by how the match is validated — checksum-validated1.0(TC_ID/IBAN/CREDIT_CARD), structural0.97(well-formed patterns likeEMAIL/PHONE), fuzzy0.90(heuristic patterns likeADDRESS/VEHICLE_PLATE) — so overlap resolution and ensemble adjudication reason about certainty correctly. In adjudication the LLM may relabel/drop model-based candidates but every regex tier is protected (threshold0.90); for a PII tool, never letting the LLM drop a deterministic match means over-redaction beats a leak. - The library no longer prints. Progress and status output was going to
stdout/stderrdirectly. SpaCy model download now routes through the standardloggingmodule (level chosen byverbose), andModelManager.pull()accepts anon_progresscallback so a caller can drive their own UI/logger; the built-in terminal progress bar is now opt-in (used only when no callback is supplied).
[0.4.0] — 2026-06-21¶
Includes breaking changes (class and method renames). Deprecated aliases are kept for one release cycle.
Breaking¶
- LLM is configured via
with_llm()(or YAML), not constructor arguments. Theuse_llm,llm_backend,llm_model,llm_base_url,llm_api_key,llm_timeout,llm_allow_http,llm_adjudicate,auto_pull,llm_device_map,llm_load_in_8bit,llm_load_in_4bitconstructor parameters were removed — useWardcat(...).with_llm(backend=..., model=..., ...). This slims the constructor (19 → 7 params) and removes the duplicate path. NER constructor args (use_ner,language,spacy_model, …) are unchanged. - Detection is opt-in: a bare
Wardcat()starts empty. Previously every regex/NER entity was on by default; now nothing is enabled until youadd_entity()/add_entities(). Enable everything withadd_entity(Entity.ALL, action=...). (ThewardcatCLI keeps a sensible "detect common PII" default policy, since it is an application.) The unsalted-hash warning now fires from the first rebuild that activates ahashaction, not only at construction. LLMGuard→Wardcat: the main class is renamed and theLLMGuardname is removed (the guard is not LLM-specific — it is regex/NER/LLM hybrid). Update imports tofrom wardcat import Wardcat.configure_entity()→add_entity()andconfigure_entities()→add_entities(). The old method names were removed (no aliases).add_entity()/add_entities()no longer take anenabledargument. Adding an entity always enables it; useremove_entity()/remove_entities()to turn entities off. This removes the contradictoryadd_entity(..., enabled=False)form.- Default action is now
hash(waswarn). Callingadd_entity()/add_entities()without anactionenables the entity withaction="hash"(the safest default) and logs a warning; passaction=...explicitly to silence it. - NER is off by default and ships no default model.
use_nernow defaults to off, and the oldspacy_model="en_core_web_sm"default is gone (constructor anddefault.yaml). Enable NER explicitly withlanguage=...(recommended) orspacy_model=...;use_ner=Truewithout a model raisesConfigError. A named-but-missing model is auto-downloaded. - The library no longer reads environment variables.
load_config()/Wardcat()ignore the environment entirely — pass configuration explicitly via constructor arguments or a YAMLconfig_path. Reading env vars is now confined to thewardcatCLI (an application), whereWARDCAT_SALT,WARDCAT_LLM_URL,WARDCAT_LLM_MODEL,WARDCAT_LLM_API_KEYact as defaults for the matching flags. (The oldLLMGUARD_*names are gone.) - HTTP-to-remote-LLM override is a parameter, not an env var: the
LLMGUARD_ALLOW_HTTPenv var was removed; passWardcat(llm_allow_http=True)(orallow_http=on a backend) to permit plaintext HTTP to a remote host (still blocked by default).
Added¶
Entityconstants: a newEntityenum exposes every known entity type as a constant (Entity.CREDIT_CARD,Entity.EMAIL, …) for IDE autocomplete and typo-proof configuration. Use it anywhere a string entity type was accepted —guard.add_entity(Entity.CREDIT_CARD, action=Action.HASH). Bare strings still work;Entityis its string value (Entity.EMAIL == "EMAIL").Entity.ALLsentinel:add_entity(Entity.ALL)enables every known entity type in one call (andadd_entities([Entity.ALL, ...])expands it inline). It is excluded fromKNOWN_ENTITY_TYPES. (Entity.Allis kept as a deprecated PascalCase alias.)remove_entity()/remove_entities(): disable one or many entity types (across all detector layers).remove_entity(Entity.ALL)disables everything. The natural pattern is "enable all, then prune":guard.add_entity(Entity.ALL, action="hash").remove_entity(Entity.ORG). Removing an entity that was never enabled is a no-op; an unknown name logs a warning (likeadd_entity) to catch typos.change_entity_action(): retarget the action of an entity that is currently enabled without changing its layers —guard.change_entity_action(Entity.EMAIL, Action.HASH). It refuses to silently re-enable: changing the action of a removed or never-added entity raisesConfigError(enable it first withadd_entity()).change_entity_action(Entity.ALL, ...)changes the action of every currently-enabled entity.- Introspection:
enabled_entities()returns the set of currently-enabled entity types;get_entity_action(entity)returns an entity's action (orNoneif it is not enabled);entity_policy()returns the full{entity: action}mapping. This rounds out the write API (add/remove/change) with a read API. - Pluggable LLM backends (Open/Closed): a backend registry replaces the hard-coded
if/elifbackend selection. Register a custom backend without touching the core —register_backend("name", factory)— then use it viawith_llm(backend="name").BaseLLMBackend,register_backend, andregistered_backendsare exported fromwardcat; backend validation now reflects the live registry. - Pluggable actions (Open/Closed): anonymization actions live in a registry instead of a hard-coded
if/elif. Register a custom action —register_action("tokenize", lambda span, ctx: ...)— and use it like any built-in (add_entity("EMAIL", "tokenize")). Built-ins (warn/hash/redact/mask) are registered the same way;register_action,registered_actions, andActionContextare exported. Action validation reflects the live registry. - Detection ⊥ anonymization split: action application moved out of
DetectionEngineinto a separateAnonymizer(analysis finds spans → anonymization transforms them), mirroring the analyze/anonymize split of mature PII pipelines.Violation.actionis now the action name (str); it still compares equal to the matchingActionconstant (v.action == Action.HASH). - Discoverability:
Wardcat.supported_entities(layer=None)returns the entity types wardcat can detect — all of them, or just one layer's set ("regex"/"ner"/"llm"). - Typed
redacted():ScanResult.redacted()now returns aRedactedResultTypedDict(withRedactedViolationitems), both exported fromwardcat, so the safe-logging payload has a precise, IDE-visible shape. Languageconstants: a newLanguageenum (Language.EN,DE,FR,ES,IT,NL,PT,TR) for documented, typo-proof NER language selection —Wardcat(language=Language.DE)or a list for multilingual NER. Plain ISO codes are still accepted.Backendconstants: a newBackendenum (Backend.OLLAMA,Backend.OPENAI_COMPATIBLE,Backend.TRANSFORMERS) for typo-proof LLM backend selection —Wardcat(llm_backend=Backend.OPENAI_COMPATIBLE). Plain strings still work;_VALID_BACKENDSis now derived from the enum.- Fluent layer builders
with_ner()/with_llm(): a chainable alternative to the wide constructor that keeps each layer's settings in one place and makes NER/LLM symmetric —Wardcat(salt="s").with_ner(language=Language.TR).with_llm(backend=Backend.OLLAMA, model="llama3.2"). Both returnselfand can be chained back-to-back. The constructorllm_*/spacy_*arguments still work.
Fixed¶
- Static-analysis pass (bandit / radon / pip-audit): flag the LLM cache-key MD5 as
usedforsecurity=False(non-cryptographic, was bandit's only High); split the high-complexityvalidate_config(cyclomatic 32 → 1, via per-section helpers); bump vulnerable dev/test dependencies (idna, urllib3, requests, pytest, pygments) —pip-auditnow reports no known vulnerabilities. Coverage 93%, no dead code. - Broken
examples/batch_and_async.py: it scanned without enabling any entity, so after the opt-in change it silently reported everything as clean. Examples now enable entities, and a new smoke test (tests/test_examples.py) runs the offline examples in CI and asserts they actually detect PII — so examples can't rot silently again. - Default-action warning noise: when
add_entity()/add_entities()default a missing action tohash, the warning is now logged once per guard instead of on every call — it stays visible without spamming logs when many entities are added. - Misleading install hint: the LLM backends' missing-
httpxerror pointed at a non-existentwardcat[llm]extra;httpxis a core dependency, so the message now says to reinstall wardcat. - Multiple explicit models:
spacy_model=now accepts a list, e.g.spacy_model=["en_core_web_sm", "de_core_news_sm"]. - Salt: when no salt is set and a
hashaction is in play, wardcat logs a clear warning (rainbow-table risk) and proceeds with unsalted hashes; setsalt=...orWARDCAT_SALT. EntityandActionare first-class, type-hinted arguments (entity_type: str | Entity,action: str | Action). Static type checkers flag an invalid action or entity at edit time instead of at runtime.
Changed¶
KNOWN_ENTITY_TYPESis now derived from theEntityenum (excluding theEntity.ALLsentinel) — the enum is the single source of truth, so the two can no longer drift apart.add_entity()/add_entities()normalizeEntity/Actionenum arguments to their canonical string form before storing them in the config.- Error handling: the entity-management API (
add/remove/change/get) now raisesConfigErrorfor a non-str/Entityentity argument, an invalid/wrong-typed action, an unknown layer, a malformedadd_entities()argument (bare string or non-iterable), orget_entity_action(Entity.ALL). Unknown entity names still warn (not raise) so custom entity types keep working.
0.3.0 — 2026-06-20¶
Includes a breaking change (removal of the shipped ASGI/FastAPI middleware).
Added¶
- Layer-aware filter selection:
configure_entity(entity, layers=[...])targets a specific detector layer ("regex","ner","llm"); when omitted, every layer that supports the entity is used. Lets you keep semantic-only entities (e.g.SPECIAL_CATEGORY) off the regex/NER path. - Batch filter configuration:
configure_entities()enables many entity types in one call (single rebuild). Accepts a list, a{name: action}mapping, or a{name: {action, layers, enabled}}mapping, and pairs with the predefined entity groups (turkish_entities(),european_entities(), …). - NER model selection by language:
LLMGuard(language="de", spacy_size="md")resolves the SpaCy model from the catalog by language code and size tier (sm/md/lg/trf); supported languages areen,de,fr,es,it,nl,pt,tr. Selecting a language implies auto-download of the model if it is missing (disable withspacy_auto_download=False). - Multilingual NER:
LLMGuard(language=["en", "de", "fr"])loads one NER detector per language; the engine merges their spans. Each model loads independently (one failure skips only that model). Explicit by design — wardcat does not auto-detect the input language. - Reusable SpaCy installer: new
wardcat.ner.downloadermodule (ensure_model,download_model) shared by the CLI and the auto-download path;wardcat.ner.spacy_catalog.resolve_model()resolves a language + size to a catalog model. - CLI language flags:
--lang,--spacy-size, and--spacy-auto-downloadonwardcat scan/wardcat batch. - Ensemble adjudication: opt-in
LLMGuard(llm_adjudicate=True)/llm_detector.adjudicate— the LLM verifies, relabels, drops, and supplements regex/NER candidates in a single call. Deterministic regex matches are always kept; LLM-only mode is unaffected. - GDPR Article 9 detection: new
SPECIAL_CATEGORYentity (health, religion, ethnicity, political opinion, sexual orientation, trade-union, genetic/biometric). LLM-only and off by default (semantic, subjective); enable underllm_detector.entities. VAT_NUMBERentity: EU-prefixed VAT IDs (DE/FR/GB/IT/ES/AT/NL) and the Turkish Vergi No keyword form.- Multilingual filters:
DATE_OF_BIRTH(DE/FR month names and keywords),PHONE(French national, German mobile), and LLM prompt + few-shot examples extended to EN/DE/FR/TR. - Expanded
CUSTOM_SECRET: Stripe (sk_live_,rk_live_), Anthropic (sk-ant-), Google (AIza), GitLab (glpat-), SendGrid (SG.), Twilio (SK/AC), npm, Slack webhook URLs, and PEM private-key blocks. - Connection-string credential detection: passwords in
scheme://user:pass@hostURIs are flagged asCUSTOM_SECRET(and the spurious EMAIL match they used to trigger is suppressed). - Tooling:
ruff(lint + format) andmypyconfigured inpyproject.toml, with a "Lint & type-check" CI job that the test job depends on. - Live LLM integration tests:
tests/integration/test_llm_live.pyruns against a real Ollama model (@pytest.mark.slow, auto-skips when Ollama is unavailable). - Docs & examples:
CONTRIBUTING.md;examples/(asgi_middleware.py,batch_and_async.py,llm_hybrid.py,demo.py).
Changed¶
CREDIT_CARDvalidation: now Luhn-checked via a table-driven_VALIDATORSregistry (alongside TC_ID and IBAN checksums).requires-python: aligned to>=3.11.
Removed¶
- BREAKING — shipped ASGI/FastAPI middleware: the
wardcat.integrationspackage was removed; the core is now a pure detection library (deps staypyyaml+httpxonly). A self-contained, copy-paste ASGI middleware now lives inexamples/asgi_middleware.py.
Fixed¶
US_ZIP_CODEZIP+4 leak: a labeled branch grabbed only the first 5 digits of a ZIP+4; fixed with a negative lookahead.FINANCIAL_AMOUNT: wired into the guard's regex entity set (previously dead code); remains opt-in/off by default.
0.2.0b1 - 2026-03-20¶
Added¶
- New entity types:
UK_POSTAL_CODE(British postcodes),US_ZIP_CODE(ZIP+4 format), andEU_NATIONAL_ID(Spanish DNI and NIE) via regex detection. - Multilingual ADDRESS patterns: French (Rue, Allée, Boulevard…), Spanish (Calle, Avenida, Plaza…), Italian (Piazza, Corso, Via…), Dutch (straat, gracht, laan…), and German (Straße, Weg, Platz…) street-type keywords added to the address regex.
PASSPORTentity: LLM-based contextual detection for passport numbers of any country. Requiresuse_llm=True.CUSTOM_SECRETentity: Regex detection for known API token prefixes —sk-,ghp_,AKIA,ya29.,xoxb-,xoxp-. LLM layer extends this to contextual secrets (password=VALUE,api_key=VALUE).ScanResult.redacted(): Returns a PII-free dict suitable for safe logging and API responses without exposing raw PII fromoriginal_textorviolations[].original.- SpaCy model auto-fallback: When the requested SpaCy model is not installed, wardcat automatically falls back to any installed model of the same language and emits a warning.
- DoS protection: Inputs exceeding 500 KB now raise a
ValueError. Previously this was a warning only. - HTTP warning on all LLM backends: A security warning is logged for HTTP connections to any LLM backend, including localhost. Use HTTPS via a reverse proxy in production.
- CLI salt warning: The
wardcat scanandwardcat batchcommands warn when thehashaction is used without a salt, indicating rainbow table vulnerability. - GitHub Actions CI: Matrix testing across Python 3.11, 3.12, and 3.13; enforces ≥80% test coverage; includes wheel build verification.
Changed¶
- Hash digest length: Upgraded from 8 hex characters (32-bit entropy) to 16 hex characters (64-bit entropy). Replacement tokens now appear as
[TYPE:ea782818c5a992a8]. - TC_ID validation: Now validated with the official Nüfus İdaresi checksum algorithm, eliminating false positives from random 11-digit sequences.
- IBAN validation: Now validated with the ISO 13616 mod-97 checksum algorithm before flagging.
- ADDRESS regex: Tightened pattern to reduce false positives on common non-address text.
- IPv6 validator: Replaced previous implementation with a proper RFC 5952 alternation regex covering full and compressed forms.
- Version sourcing: Package version is now read from
importlib.metadataat runtime (single source of truth frompyproject.toml).
Fixed¶
- Transformers backend: Chat template availability check moved to the correct location in the inference pipeline.
- SpaCy NER fallback: Warning message wording made consistent across all fallback code paths.
0.2.0 — 2026-03-19¶
Added¶
- 6 new entity types —
UUID,SSN(US),MAC_ADDRESS,JWT,IPv6,NIN(UK) with regex detection - International phone support — E.164 format (
+1,+44, etc.) added alongside Turkish phone patterns - International address support — English street address patterns (Street, Avenue, Road, etc.) alongside Turkish patterns
- HuggingFace Transformers backend — on-prem GPU/CPU inference via
transformerspipeline; supports Llama 3.1/3.2 (1B, 3B, 8B, 70B), 8-bit/4-bit quantization,device_map="auto"for multi-GPU build_messages()— chat-format message builder for backends with native chat supportBaseLLMBackend.complete_messages()— all backends now support chat message format with default fallback- Structural validators for new entity types — hallucination filtering for UUID, SSN, MAC_ADDRESS, JWT, IPv6, NIN
- LLM detector covers all entity types —
llm_detector.entitiesconfig now includes all 16 entity types including POSTAL_CODE
Changed¶
LLMDetectornow usescomplete_messages()instead ofcomplete()— better prompt formatting for chat-capable modelsModelInfonow has abackendfield ("ollama"or"transformers")- Model catalog expanded with HuggingFace Llama model IDs
0.1.0 — 2026-03-19¶
Initial release.
Added¶
- Hybrid detection engine — regex + SpaCy NER + on-prem LLM (Ollama / OpenAI-compatible)
- Regex detectors —
CREDIT_CARD,EMAIL,PHONE,IBAN,TC_ID,IP_ADDRESS,ADDRESS,POSTAL_CODE - NER detector —
PERSON,ORG,ADDRESSvia SpaCy (Englishen_core_web_smand Turkishtr_core_news_*models) - LLM detector —
CUSTOM_SECRETand any entity type via Ollama or OpenAI-compatible backends - Two actions —
warn(report, keep text) andhash(replace with[TYPE:8hex]using salted SHA-256) - Python API —
LLMGuard,ScanResult,Violation,Action; method-chaining configuration - YAML API — declarative policy files with per-entity enable/action settings
scan_batch()— fault-isolated batch scanning (one failure doesn't abort the batch)- CLI —
wardcat scan,wardcat batch,wardcat models list/setup/pull - Environment variable overrides —
LLMGUARD_SALT,LLMGUARD_LLM_URL,LLMGUARD_LLM_MODEL,LLMGUARD_LLM_API_KEY,LLMGUARD_LLM_TIMEOUT,LLMGUARD_SPACY_MODEL - Config validation —
validate_config()checks actions, backend names, timeout values - SpaCy singleton cache — thread-safe model cache prevents reloading 300-500 MB models per instance
- Hallucination filtering — structural validators per entity type (e.g. PERSON requires ≥2 words, TC_ID must be exactly 11 digits)
- Overlap resolution — longer span wins when two detectors produce overlapping matches
- PEP 561 compliance —
py.typedmarker for typed library consumers - Optional extras —
[ner]for SpaCy,[transformers]for HuggingFace,[all]for everything - ReDoS protection — adversarial input tests with 30-second timeout guardrails
- Thread-safety — concurrent scan tests validating shared-state safety