Wardcat¶
The main interface. Everything below is generated from the source docstrings.
wardcat.Wardcat
¶
Bases: EntityPolicyMixin
The main interface exposed to users.
Programmatic API (method chaining)::
import os
from wardcat import Wardcat, Entity, Action
guard = (
# Read secrets from the environment in YOUR app — the library itself
# never reads env vars; pass everything explicitly.
Wardcat(salt=os.environ["WARDCAT_SALT"])
.add_entity(Entity.EMAIL, action=Action.HASH)
.add_entity(Entity.CREDIT_CARD, action=Action.HASH)
.remove_entity(Entity.ORG)
)
result = guard.scan(text)
Enable everything, then prune::
guard = Wardcat(salt="...").add_entity(Entity.ALL, action="hash")
guard.remove_entity(Entity.ORG)
guard.entity_policy() # inspect: {"CREDIT_CARD": "hash", ...}
Declarative API (YAML)::
guard = Wardcat(config_path="config/my_policy.yaml")
result = guard.scan(text)
Configuration is explicit. The constructor takes only salt and an optional
YAML config_path; every detection layer is configured with a fluent
builder — :meth:with_ner and :meth:with_llm — or in the YAML file. The
library does not read environment variables: read any secrets in your own
application and hand them to the constructor. Builders are chainable and their
order does not matter — the final configuration is what counts::
from wardcat import Wardcat, Language
# LLM layer
guard = Wardcat(salt="s").with_llm(model="llama3.1:8b")
# NER layer — needs an explicit model (wardcat ships no default). Choose
# one via language= (recommended) or spacy_model=:
guard = Wardcat(salt="s").with_ner(language=Language.DE, spacy_size="md")
guard = Wardcat(salt="s").with_ner(language=[Language.DE, Language.FR])
guard = Wardcat(salt="s").with_ner(spacy_model=["en_core_web_sm", "de_core_news_sm"])
# Supported languages: en, de, fr, es, it, nl, pt, tr; sizes sm/md/lg/trf.
# A named-but-missing model is auto-downloaded (auto_download=False to disable).
# For mixed-language text without extra models, use the LLM layer, whose
# prompt is multilingual.
Source code in src/wardcat/guard.py
scan
¶
scan_async
async
¶
Async scan — uses native async I/O for the LLM backend when available.
CPU-bound detectors (regex, SpaCy NER) run in a thread pool;
the LLM detector (if enabled) uses httpx.AsyncClient natively,
so multiple concurrent calls do not block each other.
Source code in src/wardcat/guard.py
is_sensitive
¶
Return whether text contains sensitive information, judged semantically.
A general, holistic LLM decision — not the per-entity detection of
:meth:scan. It asks the configured LLM whether the text as a whole
contains sensitive information (PII, credentials, financial, health, or
confidential business data) and returns a single True/False flag.
Useful as a lightweight guardrail before sending text to an external
service. :meth:classify asks the same question but for the kinds
present; it is measured to be stricter, so for a yes/no gate this is
the method to use.
Requires the LLM layer (:meth:with_llm); no entities need to be enabled
and no regex/NER runs. Empty text is False. Fail-closed: if the LLM
backend is unreachable the underlying error propagates, and an answer
that cannot be read counts as sensitive, so a guardrail never silently
treats sensitive text as safe.
Raises:
| Type | Description |
|---|---|
ConfigError
|
if the LLM layer is not configured. |
Source code in src/wardcat/guard.py
is_sensitive_async
async
¶
Async variant of :meth:is_sensitive (native async LLM I/O when available).
Source code in src/wardcat/guard.py
classify
¶
Decide whether text is sensitive, and of what kind.
Like :meth:is_sensitive, one holistic LLM judgement over the whole
text — but the answer names the categories present (pii,
credentials, financial, health, special_category,
business_confidential) and carries the model's one-line reason, so a
caller can route on the kind: block health data, allow business data
inside the company, log the rest. Long texts are judged in chunks and the
categories merged; the reason is the first sensitive chunk's.
It is a separate call with its own prompt, not the source of
:meth:is_sensitive: asked for structure, the model was measured to be
more precise and less sensitive (on the benchmark, one false alarm in a
hundred texts against eleven, but eight misses against one, mostly
confidential business plans), and several times slower. Use
:meth:is_sensitive as the gate and this for routing what it stops.
An answer that cannot be read is sensitive with the category
"unknown" — the guardrail fails closed. reason may quote the
text and is as sensitive as the input.
Raises:
| Type | Description |
|---|---|
ConfigError
|
if the LLM layer is not configured. |
Source code in src/wardcat/guard.py
classify_async
async
¶
Async variant of :meth:classify.
Source code in src/wardcat/guard.py
scan_batch
¶
Scan multiple texts in parallel using a thread pool.
Each text is scanned independently; an error in a single item does not affect the others — the original text is returned untouched for any item that fails.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
texts
|
list[str]
|
List of texts to scan |
required |
max_workers
|
int | None
|
Number of parallel threads. Defaults to the
|
None
|
Returns:
| Type | Description |
|---|---|
list[ScanResult]
|
List of |
Source code in src/wardcat/guard.py
scan_batch_async
async
¶
Scan multiple texts concurrently using native async.
Each text is scanned independently via :meth:scan_async; all are
run concurrently with asyncio.gather. Errors in individual items
are caught — the original text is returned with scan_error set.
Source code in src/wardcat/guard.py
supported_entities
staticmethod
¶
Return the entity types wardcat can detect (discoverability helper).
::
Wardcat.supported_entities() # every known entity type
Wardcat.supported_entities("regex") # only what the regex layer detects
Wardcat.supported_entities("ner") # PERSON, ORG, ADDRESS
Wardcat.supported_entities("llm") # contextual/semantic types
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
layer
|
str | Layer | None
|
|
None
|
Raises:
| Type | Description |
|---|---|
ConfigError
|
if |
Source code in src/wardcat/guard.py
with_ner
¶
Enable the SpaCy NER layer with an explicit model. Supports chaining.
Mirrors :meth:with_llm. Pass language= (recommended; a list enables
multilingual NER) or spacy_model= (explicit package name(s)).
::
guard = Wardcat(salt="s").with_ner(language=Language.EN)
guard = Wardcat(salt="s").with_ner(spacy_model=["en_core_web_sm", "de_core_news_sm"])
Raises:
| Type | Description |
|---|---|
ConfigError
|
if neither |
Source code in src/wardcat/guard.py
with_llm
¶
with_llm(
*,
backend=OLLAMA,
model="llama3.2",
base_url=None,
api_key="",
timeout=60,
allow_http=False,
adjudicate=False,
auto_pull=False,
device_map="auto",
load_in_8bit=False,
load_in_4bit=False,
dtype=None,
language=None,
circuit_failures=3,
circuit_cooldown=30.0,
max_concurrency=4,
)
Enable the on-prem LLM detector. Supports chaining, like :meth:with_ner.
.. warning::
Unlike with_ner(), this does not leave detection fully opt-in.
The LLM layer carries its own default entity policy, so with_llm()
switches on around fifteen entity types with the actions that policy
names (PERSON → hash, EMAIL → warn, …) — not the action
you pass to :meth:add_entity for something else. They are listed in a
one-time warning at the first scan. Override one with
add_entity(name, action, layers=["llm"]) or drop it with
remove_entity(name); pass a YAML config_path to replace the
policy wholesale.
The fluent way to configure the LLM layer (the constructor takes only
config_path and salt) — keeps the LLM configuration in one place::
guard = (
Wardcat(salt="s")
.with_ner(language=Language.TR)
.with_llm(backend=Backend.OLLAMA, model="llama3.2", adjudicate=True)
)
backend is the backend type (:class:~wardcat.Backend); the
address goes to base_url.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
the backend's address. Leave it unset to use the
backend's own default — |
None
|
dtype
|
str | None
|
weight dtype for the |
None
|
language
|
str | Language | None
|
selects a localized system prompt for :meth: |
None
|
circuit_failures
|
int
|
consecutive backend failures after which the
LLM layer is skipped without being called, so an outage does not make
every scan wait the full |
3
|
circuit_cooldown
|
float
|
seconds the layer stays skipped before one call
is tried again. While skipped, each scan carries a warning naming
the open circuit; :meth: |
30.0
|
max_concurrency
|
int
|
requests in flight at the backend at once,
across :meth: |
4
|
Source code in src/wardcat/guard.py
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 | |
with_phone_regions
¶
Detect national phone formats for regions via libphonenumber.
The built-in PHONE pattern is precision-first and covers Turkish,
French and German national formats plus E.164 — a number written the way
it is written in Manchester or Madrid falls through it. Naming the regions
you actually serve swaps in libphonenumber for those formats::
guard = Wardcat(salt=s).add_entity(Entity.PHONE).with_phone_regions("GB", "ES")
Regions are CLDR two-letter codes. Needs the extra: pip install
'wardcat[phone]' — without it the built-in pattern is used and a warning
is logged. Call with no arguments to go back to the pattern.
Matches are reported at 0.90 confidence, not the 0.97 of the built-in pattern: libphonenumber validates against each region's numbering plan, which is far stronger than a bare digit run but weaker than a checksum, and every extra region widens what counts as a number. Add the regions you serve, not every region there is.
Source code in src/wardcat/guard.py
with_min_confidence
¶
Set the confidence floor: spans scoring below minimum are dropped.
Every detection carries a confidence, tiered by how strong the evidence is — a checksum-verified card is 1.0, a distinctive format such as an email is 0.97, a model layer is 0.85, a keyword-heuristic address is 0.90, and a checksum whose own odds are weak (the ABA mod-10, the NHS mod-11, the IMEI Luhn) with no supporting keyword nearby is 0.70.
The default floor is 0.8, which sits between that last tier and
everything else: those uncued matches are found but not acted on. Lower
it to trade precision for recall::
guard.with_min_confidence(0.6) # act on uncued checksum matches too
Set it to 0 to act on everything a layer reports.
Raises:
| Type | Description |
|---|---|
ConfigError
|
if minimum is not a number between 0 and 1. |
Source code in src/wardcat/guard.py
set_salt
¶
add_allowlist
¶
Add exact values that should never be flagged as PII.
Supports method chaining::
guard.add_allowlist(["no-reply@company.com", "192.168.1.1"])
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
values
|
list[str]
|
List of exact string values to exempt from detection. |
required |
Source code in src/wardcat/guard.py
add_denylist
¶
Add values that should always be flagged as PII.
Each entry has an entity_type and either an exact value or a regex
pattern. The action applied is taken from the entity's config (same as
regular detections). Supports method chaining::
guard.add_denylist([
{"value": "John Smith", "entity_type": "PERSON"},
{"pattern": r"\bPRJ-\d{4}\b", "entity_type": "CUSTOM_SECRET"},
])
Entries are validated exactly as a YAML denylist is, and all of them
before any is added: a pattern that is not valid regex, or that backtracks
catastrophically, is refused — a match cannot be interrupted once a scan
has started it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
entries
|
list[dict[str, str]]
|
List of dicts with |
required |
Raises:
| Type | Description |
|---|---|
ConfigError
|
if any entry is invalid. |
Source code in src/wardcat/guard.py
supported_presets
staticmethod
¶
with_preset
¶
Enable the entities of a starting policy, with the actions it names.
A preset is an entity → action mapping modelled on a data-protection
regime ("kvkk", "gdpr", "pci_dss", "hipaa_lite",
"secrets_only"). It switches no layer on: names need
:meth:with_ner or :meth:with_llm, special-category data needs
:meth:with_llm, and an entity left without its layer is reported as
uncovered at the first scan, as always. Adjust afterwards with
:meth:change_entity_action and :meth:remove_entity. YAML: preset: kvkk,
with the file's own entities taking precedence.
See the presets guide for what each one enables and, as importantly, what it does not cover.
Raises:
| Type | Description |
|---|---|
ConfigError
|
for an unknown preset name. |
Source code in src/wardcat/guard.py
with_locale
¶
Choose the language the surrogate action draws its stand-ins from.
"en", "tr", "de" or "fr"; the default is "en". It is
deliberately not inferred from the NER or LLM language — a multilingual
guard has no single one. YAML: locale: tr.
Raises:
| Type | Description |
|---|---|
ConfigError
|
for a locale without name pools. |
Source code in src/wardcat/guard.py
with_strict
¶
Refuse any scan that covers less than was configured.
By default a layer that cannot run — a SpaCy model that failed to load,
an LLM backend that is down — is reported on :attr:ScanResult.warnings
and the partial result is returned, which is right for a chat guard
that must answer. A pipeline that indexes documents wants the opposite:
better no result than a document stored with names in it. With strict
on, such a condition raises :class:~wardcat.DegradedScanError — at
build time for problems known then, at scan time for the rest, and
from :meth:scan_batch too, which otherwise turns errors into
scan_error entries.
YAML: strict: true.
Source code in src/wardcat/guard.py
with_propagation
¶
Redact every occurrence of a value once any layer detects it.
Model-based layers (NER/LLM) often report a repeated value only once, which would leave the other occurrences unredacted. With propagation on, a value detected anywhere is anonymized at every whole-token occurrence in the text — using that value's entity type and action. Deterministic regex spans still win overlaps, so a propagated match never displaces a checksum-validated one. Chainable::
guard = Wardcat(salt="s").with_ner().add_entity("PERSON").with_propagation()
It can over-redact (e.g. a short common name), so it is off by default
and only exact, token-bounded matches at least min_length chars long
are propagated.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
enabled
|
bool
|
turn propagation on (default) or off. |
True
|
min_length
|
int
|
skip values shorter than this many characters. |
3
|