Configuration & policy¶
Configuration is explicit — pass constructor arguments or a YAML
config_path. The library never reads environment variables; read any secrets in
your application and hand them to the constructor.
Entity policy (write API)¶
| Method | Effect |
|---|---|
add_entity(entity, action, layers=None) |
Enable one entity (or Entity.ALL) |
add_entities(mapping_or_list, ...) |
Enable many in one rebuild |
remove_entity(entity) / remove_entities([...]) |
Disable |
change_entity_action(entity, action) |
Retarget an already-enabled entity's action |
Read API: enabled_entities(), get_entity_action(entity), entity_policy(),
and the static Wardcat.supported_entities(layer=None).
change_entity_action(Entity.ALL, Action.REDACT) retargets every enabled entity
at once. change_entity_action() never silently re-enables an entity: changing the action
of a removed or never-added entity raises ConfigError — add it first with
add_entity(). Removing an entity that was never enabled is a no-op (an unknown
name logs a warning, to catch typos). Passing a bare string to add_entities()
/ remove_entities() instead of a list raises ConfigError — use the singular
add_entity() / remove_entity() for one entity. Entity.All is a deprecated
alias of Entity.ALL.
Actions¶
warn (keep text, report only) · hash ([TYPE:16hex], salted SHA-256) ·
redact ([TYPE]) · mask (entity-aware partial masking) · tokenize
([TYPE_1], reversible) · surrogate (a realistic stand-in
of the same shape, reversible).
When action is omitted it defaults to hash (with a one-time warning). Actions are
pluggable.
The LLM layer's own entity policy¶
with_ner() enables no entity by itself — you opt in with add_entity. with_llm()
does not work that way: the LLM layer carries its own default policy of 33 entity
types, 27 of them switched on (ORG, LOCATION, NRP, SPECIAL_CATEGORY,
DATE_OF_BIRTH and FINANCIAL_AMOUNT ship off), each with its own action, so
guard = Wardcat(salt="s").with_llm(...).add_entity(Entity.EMAIL, Action.TOKENIZE)
guard.enabled_entities() # 27 types, not 1
guard.get_entity_action(Entity.PERSON) # 'hash' — nobody asked for this
detects and anonymizes much more than the one type named, under the policy's actions rather than the one just configured. The first scan logs a one-time warning listing what came along. To take control:
guard.add_entity(Entity.PERSON, Action.REDACT, layers=["llm"]) # override one
guard.remove_entity(Entity.PERSON) # drop one
guard.remove_entity(Entity.ALL) # start from nothing
Wardcat(config_path="policy.yaml") # replace it wholesale
Phone regions¶
PHONE is matched by a precision-first pattern covering TR/FR/DE national formats
plus E.164, and by the label beside a number (Phone:, call me at …) in any
format. For national formats elsewhere, name the regions you serve and
detection moves to libphonenumber:
guard.with_phone_regions("GB", "ES", "US") # CLDR codes
guard.with_phone_regions() # back to the built-in pattern
Needs pip install "wardcat[phone]"; without it the pattern is used and a warning
is logged. Matches report 0.90 confidence rather than 0.97 — a numbering-plan
check is weaker than a checksum, and each extra region widens what counts as a
number, so add the regions you serve rather than all of them.
Confidence floor¶
Every detection carries a confidence, tiered by how strong the evidence is:
| Tier | Value | What earns it |
|---|---|---|
| checksum | 1.00 |
a card passing Luhn, an IBAN passing mod-97, a Bitcoin address |
| structural | 0.97 |
a distinctive format — email, JWT, IPv4 |
| fuzzy | 0.90 |
a keyword heuristic — a street address, a cued password or handle |
| model | 0.85 |
a span from the NER or LLM layer |
| uncued | 0.70 |
a bare digit run passing a checksum whose own odds are weak |
min_confidence is the floor below which a span is dropped before any action is
applied. It defaults to 0.8, which sits above that last tier and below every
other one, so those uncued matches are found but left alone:
guard.with_min_confidence(0.6) # act on uncued matches too
guard.with_min_confidence(0.95) # checksummed and structural only
guard.add_entity(Entity.BANK_ROUTING, Action.HASH, min_confidence=0.6) # this one alone
An entity's own floor (also entities.X.min_confidence in YAML) overrides the
global one for that entity and survives a later add_entity that does not
mention it; entity_policy(detailed=True) shows it.
The floor is applied after overlap resolution, so a stronger span still wins
its overlap first: a phone number that also satisfies the NHS checksum resolves
as PHONE, rather than being dropped as a weak NHS match.
Value propagation¶
Model-based layers sometimes report a repeated value only once. with_propagation()
anonymizes every whole-token occurrence once any layer detects a value:
Off by default (it can over-redact); only exact, token-bounded matches at least
min_length chars (default 3) propagate, and deterministic regex spans still win
overlaps — a propagated match never displaces a checksum-validated one.
Structural PII (email, phone, IBAN…) is already caught exhaustively by the regex
layer, so propagation mainly helps names and other model-only entities.
Allowlist / denylist¶
guard.add_allowlist(["no-reply@example.com"]) # never flag
guard.add_denylist([{"value": "ProjectX", "entity_type": "CUSTOM_SECRET"}]) # always flag
Literal denylist values are matched in a single pass however many there are, so
a customer list of ten thousand names costs one scan of the text, not ten
thousand. Regex entries ({"pattern": ...}) each run on their own. A value
listed under two entity types keeps the first, with a warning.
Degraded scans¶
If a layer cannot run (most commonly the LLM backend being unreachable), the scan still returns the other layers' results and records the failure:
Strict mode: refuse a degraded scan¶
A pipeline that stores what it scans — an indexer, an ETL job — wants the
opposite of a partial result. with_strict() (YAML: strict: true) makes any
such condition raise DegradedScanError instead: at build time for what is
known then (a SpaCy model that did not load, a missing phonenumbers), at scan
time for the rest (an LLM backend that went down), and from scan_batch too,
which otherwise files errors under scan_error.
from wardcat import DegradedScanError
guard = Wardcat(salt="s").with_ner(language="tr").with_strict()
try:
result = guard.scan(chunk)
except DegradedScanError as exc:
exc.warnings # what did not run
exc.result # the partial ScanResult, or None when the guard refused to build
The LLM circuit breaker¶
An unreachable backend would otherwise cost every scan the full timeout
before the layer is skipped. After circuit_failures consecutive backend
failures (default 3) the layer is skipped without a call for circuit_cooldown
seconds (default 30), then one call is tried. Each skipped scan carries a
warning naming the open circuit — it is a degraded scan like any other, and
strict mode raises on it — and is_sensitive() raises CircuitOpen rather
than answering. circuit_failures=0 turns the breaker off.
max_concurrency (default 4) bounds how many requests are in flight at the
backend at once, across scan_batch threads and async chunk fan-out; a local
model server queues what it cannot run, so more only adds latency.
YAML reference¶
salt: "" # read it from the environment in your application
use_ner: false # NER is off by default; set true AND name a model below
# spacy_model: "en_core_web_sm" # one model
# spacy_models: ["en_core_web_sm", "de_core_news_sm"] # or several (multilingual)
strict: false # true: a degraded scan raises DegradedScanError
# preset: kvkk # start from a preset: kvkk | gdpr | pci_dss | hipaa_lite | secrets_only
locale: en # names and formats for the surrogate action
min_confidence: 0.8 # drop spans scoring below this
phone_regions: [] # CLDR codes for libphonenumber-backed PHONE
propagate_matches: false
propagate_min_length: 3
entities:
CREDIT_CARD: { enabled: true, action: hash }
EMAIL: { enabled: true, action: warn }
BANK_ROUTING: { enabled: true, action: hash, min_confidence: 0.6 }
llm_detector:
enabled: false
backend: ollama
model: llama3.2
adjudicate: false
circuit_failures: 3
circuit_cooldown: 30
max_concurrency: 4
wardcat check-config policy.yaml runs a file through every validation the
library has without building a guard.