Skip to content

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:

guard = Wardcat(salt="s").with_ner(language="en").add_entity("PERSON").with_propagation()

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:

res = guard.scan(text)
if res.warnings:
    logger.warning("PII scan degraded: %s", res.warnings)

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.

guard.with_llm(model="qwen3:14b", circuit_failures=3, circuit_cooldown=30)

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.