Skip to content

Wardcat

The main interface. Everything below is generated from the source docstrings.

wardcat.Wardcat

Wardcat(config_path=None, salt='')

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
def __init__(
    self,
    config_path: str | Path | None = None,
    salt: str = "",
) -> None:
    self._config = load_config(config_path)

    # Constructor arguments override YAML
    if salt:
        self._config["salt"] = salt

    # Detection layers are configured with the fluent builders — with_ner()
    # and with_llm() — or a YAML config_path, never constructor arguments.
    # Ensure the LLM sub-config exists for the YAML/builder path.
    self._config.setdefault("llm_detector", {})

    # A YAML config may still switch NER on; it must then name a model, since
    # wardcat ships no default. (The builder path always sets one.)
    if self._config.get("use_ner") and not (
        self._config.get("spacy_models") or self._config.get("spacy_model")
    ):
        raise ConfigError(
            "use_ner is on but no SpaCy model was given. Set spacy_model in the "
            "YAML config, or configure NER with with_ner(language=...) / "
            "with_ner(spacy_model=...); wardcat ships no default model."
        )

    # Entities whose enabled layer is missing — warned about once, lazily, at
    # scan time (see _warn_orphan_entities). Chains configure layers and
    # entities in any order, so an init/rebuild-time check would false-fire.
    self._orphan_warned: set[str] = set()

    # Entity types this caller configured by hand (add_entity / add_entities /
    # change_entity_action). The LLM layer ships its own default entity policy,
    # so this is the only way to tell "the user asked for PERSON" apart from
    # "with_llm() switched PERSON on" — see _warn_implicit_llm_entities.
    self._explicit_entities: set[str] = set()
    # A caller who passed a policy file chose every entity in it; nothing about
    # that configuration is implicit, so the warning is skipped for them.
    self._policy_from_file = config_path is not None
    self._implicit_llm_warned = False
    # Place names and group names moved out of ADDRESS/ORG into their own
    # types. A configuration written before that silently stops covering
    # them — see _warn_ner_type_split.
    self._ner_split_warned = False

    # Warn at most once when a hash action is active without a salt. Checked
    # in _rebuild() too, since entities are opt-in and usually added after init.
    self._salt_warned = False
    self._default_action_warned = False
    self._rebuild()

scan

scan(text)

Scan text and return a ScanResult.

Source code in src/wardcat/guard.py
def scan(self, text: str) -> ScanResult:
    """Scan text and return a ScanResult."""
    self._warn_orphan_entities()
    self._warn_implicit_llm_entities()
    self._warn_ner_type_split()
    return self._engine.scan(text)

scan_async async

scan_async(text)

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
async def scan_async(self, text: str) -> ScanResult:
    """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.
    """
    self._warn_orphan_entities()
    self._warn_implicit_llm_entities()
    self._warn_ner_type_split()
    return await self._engine.scan_async(text)

is_sensitive

is_sensitive(text)

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.

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, 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
def is_sensitive(self, text: str) -> bool:
    """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.

    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, so a guardrail
    never silently treats sensitive text as safe.

    :raises ConfigError: if the LLM layer is not configured.
    """
    backend, timeout = self._require_llm("is_sensitive")
    self._check_text_size(text)
    if not text.strip():
        return False
    language = self._config.get("llm_detector", {}).get("language")
    # Long inputs are chunked; a single sensitive chunk makes the whole
    # text sensitive, and we short-circuit on the first hit.
    for chunk, _ in chunk_by_paragraph(text, _SENSITIVITY_CHUNK_CHARS):
        if not chunk.strip():
            continue
        reply = backend.complete_messages(
            build_sensitivity_messages(chunk, language), timeout=timeout
        )
        if parse_sensitivity(reply):
            return True
    return False

is_sensitive_async async

is_sensitive_async(text)

Async variant of :meth:is_sensitive (native async LLM I/O when available).

Source code in src/wardcat/guard.py
async def is_sensitive_async(self, text: str) -> bool:
    """Async variant of :meth:`is_sensitive` (native async LLM I/O when available)."""
    backend, timeout = self._require_llm("is_sensitive_async")
    self._check_text_size(text)
    if not text.strip():
        return False
    language = self._config.get("llm_detector", {}).get("language")
    for chunk, _ in chunk_by_paragraph(text, _SENSITIVITY_CHUNK_CHARS):
        if not chunk.strip():
            continue
        reply = await backend.complete_messages_async(
            build_sensitivity_messages(chunk, language), timeout=timeout
        )
        if parse_sensitivity(reply):
            return True
    return False

scan_batch

scan_batch(texts, *, max_workers=None)

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 scan_batch_workers config value (default: 4).

None

Returns:

Type Description
list[ScanResult]

List of ScanResult in the same order as texts

Source code in src/wardcat/guard.py
def scan_batch(self, texts: list[str], *, max_workers: int | None = None) -> list[ScanResult]:
    """
    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.

    :param texts:       List of texts to scan
    :param max_workers: Number of parallel threads. Defaults to the
                        ``scan_batch_workers`` config value (default: 4).
    :returns:           List of ``ScanResult`` in the same order as ``texts``
    """
    if not texts:
        return []

    workers = max_workers or self._config.get("scan_batch_workers", 4)

    results: list[ScanResult | None] = [None] * len(texts)

    def _scan_one(idx: int, text: str) -> tuple[int, ScanResult]:
        try:
            return idx, self._engine.scan(text)
        except Exception as exc:
            logger.error(
                "scan_batch item %d failed (%s: %s), returning original text.",
                idx,
                type(exc).__name__,
                exc,
            )
            return idx, ScanResult(
                original_text=text,
                sanitized_text=text,
                violations=[],
                scan_error=f"{type(exc).__name__}: {exc}",
            )

    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {executor.submit(_scan_one, i, text): i for i, text in enumerate(texts)}
        for future in as_completed(futures):
            idx, result = future.result()
            results[idx] = result

    return results  # type: ignore[return-value]

scan_batch_async async

scan_batch_async(texts, *, max_workers=None)

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
async def scan_batch_async(
    self, texts: list[str], *, max_workers: int | None = None
) -> list[ScanResult]:
    """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.
    """
    if not texts:
        return []

    async def _one(idx: int, text: str) -> tuple[int, ScanResult]:
        try:
            return idx, await self.scan_async(text)
        except Exception as exc:
            logger.error(
                "scan_batch_async item %d failed (%s: %s), returning original text.",
                idx,
                type(exc).__name__,
                exc,
            )
            return idx, ScanResult(
                original_text=text,
                sanitized_text=text,
                violations=[],
                scan_error=f"{type(exc).__name__}: {exc}",
            )

    pairs = await asyncio.gather(*(_one(i, t) for i, t in enumerate(texts)))
    results: list[ScanResult | None] = [None] * len(texts)
    for idx, result in pairs:
        results[idx] = result
    return results  # type: ignore[return-value]

supported_entities staticmethod

supported_entities(layer=None)

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 | None

None → all known types; or one of "regex", "ner", "llm" for that layer's set.

None

Raises:

Type Description
ConfigError

if layer is not a known layer.

Source code in src/wardcat/guard.py
@staticmethod
def supported_entities(layer: str | None = None) -> frozenset[str]:
    """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

    :param layer: ``None`` → all known types; or one of ``"regex"``,
                  ``"ner"``, ``"llm"`` for that layer's set.
    :raises ConfigError: if ``layer`` is not a known layer.
    """
    if layer is None:
        return frozenset(KNOWN_ENTITY_TYPES)
    if layer not in LAYER_ENTITIES:
        raise ConfigError(f"Unknown layer {layer!r}. Valid layers: {sorted(VALID_LAYERS)}")
    return LAYER_ENTITIES[layer]

with_ner

with_ner(
    *,
    language=None,
    spacy_model=None,
    spacy_size="sm",
    auto_download=True,
)

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 language nor spacy_model is given.

Source code in src/wardcat/guard.py
def with_ner(
    self,
    *,
    language: str | Language | list[str | Language] | None = None,
    spacy_model: str | list[str] | None = None,
    spacy_size: str = "sm",
    auto_download: bool = True,
) -> Wardcat:
    """
    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 ConfigError: if neither ``language`` nor ``spacy_model`` is given.
    """
    if language is None and spacy_model is None:
        raise ConfigError(
            "with_ner() requires a model — pass language=... (e.g. Language.EN) "
            "or spacy_model=...; wardcat ships no default model."
        )
    if language is not None:
        models = self._resolve_language_models(language, spacy_size)
    else:
        models = [spacy_model] if isinstance(spacy_model, str) else list(spacy_model)  # type: ignore[arg-type]
        models = list(dict.fromkeys(models))
        if not models:
            raise ConfigError("spacy_model is empty — pass at least one model name.")
    self._config["spacy_models"] = models
    self._config["spacy_model"] = models[0]
    self._config["use_ner"] = True
    if auto_download:
        self._config["spacy_auto_download"] = True
    self._rebuild()
    return self

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,
)

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 (PERSONhash, EMAILwarn, …) — 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 — http://localhost:11434 for ollama and openai_compatible, http://localhost:8000/v1 for vllm. Only pass it to point at a non-default host/port; passing it here would otherwise override the backend-specific default (so selecting vllm without a base_url must still reach vLLM, not Ollama).

None
dtype str | None

weight dtype for the transformers backend, as a torch dtype name ("float16", "bfloat16", "float32"); an unknown name raises. Left unset the default is bfloat16, except on a pre-Ampere CUDA card, which has no bf16 support and gets float16. On Apple Silicon bfloat16 is emulated and fp16 ought to be faster, but loading as float16 with device_map="auto" on MPS segfaults on the supported torch/transformers versions — hence the argument rather than a different default. Ignored by the other backends, which do not load weights themselves.

None
language str | Language | None

selects a localized system prompt for :meth:is_sensitive (tr/de/fr; anything else uses the English, multilingual-aware prompt). It does not change the entity-detection prompt used by :meth:scan, which is multilingual by design.

None
Source code in src/wardcat/guard.py
def with_llm(
    self,
    *,
    backend: str | Backend = Backend.OLLAMA,
    model: str = "llama3.2",
    base_url: str | None = None,
    api_key: str = "",
    timeout: int = 60,
    allow_http: bool = False,
    adjudicate: bool = False,
    auto_pull: bool = False,
    device_map: str = "auto",
    load_in_8bit: bool = False,
    load_in_4bit: bool = False,
    dtype: str | None = None,
    language: str | Language | None = None,
) -> Wardcat:
    """
    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``.

    :param base_url: the backend's address. Leave it unset to use the
        backend's own default — ``http://localhost:11434`` for ``ollama``
        and ``openai_compatible``, ``http://localhost:8000/v1`` for
        ``vllm``. Only pass it to point at a non-default host/port; passing
        it here would otherwise override the backend-specific default (so
        selecting ``vllm`` without a ``base_url`` must still reach vLLM,
        not Ollama).
    :param dtype: weight dtype for the ``transformers`` backend, as a torch
        dtype name (``"float16"``, ``"bfloat16"``, ``"float32"``); an
        unknown name raises. Left unset the default is ``bfloat16``, except
        on a pre-Ampere CUDA card, which has no bf16 support and gets
        ``float16``. On Apple Silicon ``bfloat16`` is emulated and fp16
        *ought* to be faster, but loading as ``float16`` with
        ``device_map="auto"`` on MPS segfaults on the supported
        torch/transformers versions — hence the argument rather than a
        different default. Ignored by the other backends, which do not load
        weights themselves.
    :param language: selects a localized system prompt for :meth:`is_sensitive`
        (``tr``/``de``/``fr``; anything else uses the English, multilingual-aware
        prompt). It does not change the entity-detection prompt used by
        :meth:`scan`, which is multilingual by design.
    """
    lang_code = language.value if isinstance(language, Language) else language
    llm_cfg = self._config.setdefault("llm_detector", {})
    llm_cfg.update(
        {
            "enabled": True,
            "backend": backend.value if isinstance(backend, Backend) else backend,
            "model": model,
            "api_key": api_key,
            "timeout": timeout,
            "allow_http": allow_http,
            "adjudicate": adjudicate,
            "auto_pull": auto_pull,
            "device_map": device_map,
            "load_in_8bit": load_in_8bit,
            "load_in_4bit": load_in_4bit,
            "dtype": dtype,
            "language": lang_code,
        }
    )
    # Only pin base_url when the caller gave one; otherwise leave it out so
    # each backend factory applies its own default (Ollama 11434 vs vLLM
    # 8000/v1). A prior with_llm() call's base_url is cleared here too.
    if base_url is not None:
        llm_cfg["base_url"] = base_url
    else:
        llm_cfg.pop("base_url", None)
    self._rebuild()
    return self

with_phone_regions

with_phone_regions(*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
def with_phone_regions(self, *regions: str) -> Wardcat:
    """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.
    """
    codes = [r.strip().upper() for r in regions if r and r.strip()]
    self._config["phone_regions"] = codes
    self._rebuild()
    return self

with_min_confidence

with_min_confidence(minimum)

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
def with_min_confidence(self, minimum: float) -> Wardcat:
    """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 ConfigError: if *minimum* is not a number between 0 and 1.
    """
    from wardcat.config.loader import _validate_min_confidence

    _validate_min_confidence(minimum)
    self._config["min_confidence"] = float(minimum)
    self._rebuild()
    return self

set_salt

set_salt(salt)

Update the hash salt.

Source code in src/wardcat/guard.py
def set_salt(self, salt: str) -> Wardcat:
    """Update the hash salt."""
    self._config["salt"] = salt
    self._rebuild()
    return self

add_allowlist

add_allowlist(values)

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
def add_allowlist(self, values: list[str]) -> Wardcat:
    """Add exact values that should never be flagged as PII.

    Supports method chaining::

        guard.add_allowlist(["no-reply@company.com", "192.168.1.1"])

    :param values: List of exact string values to exempt from detection.
    """
    existing: list[str] = self._config.setdefault("allowlist", [])
    for v in values:
        if v not in existing:
            existing.append(v)
    self._rebuild()
    return self

add_denylist

add_denylist(entries)

Add values that should always be flagged as PII.

Each entry must have a value key and an entity_type key. 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"},
    {"value": "ProjectSecret", "entity_type": "CUSTOM_SECRET"},
])

Parameters:

Name Type Description Default
entries list[dict[str, str]]

List of dicts with value and entity_type keys.

required
Source code in src/wardcat/guard.py
def add_denylist(self, entries: list[dict[str, str]]) -> Wardcat:
    """Add values that should always be flagged as PII.

    Each entry must have a ``value`` key and an ``entity_type`` key.
    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"},
            {"value": "ProjectSecret", "entity_type": "CUSTOM_SECRET"},
        ])

    :param entries: List of dicts with ``value`` and ``entity_type`` keys.
    """
    existing: list[dict[str, str]] = self._config.setdefault("denylist", [])
    for entry in entries:
        if not isinstance(entry, dict):
            raise ConfigError(
                f"Each denylist entry must be a dict with a 'value' or 'pattern' key: {entry!r}"
            )
        if "value" not in entry and "pattern" not in entry:
            raise ConfigError(
                f"Each denylist entry must have either a 'value' or a 'pattern' key: {entry!r}"
            )
        existing.append(entry)
    self._rebuild()
    return self

with_propagation

with_propagation(*, enabled=True, min_length=3)

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
Source code in src/wardcat/guard.py
def with_propagation(self, *, enabled: bool = True, min_length: int = 3) -> Wardcat:
    """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.

    :param enabled:    turn propagation on (default) or off.
    :param min_length: skip values shorter than this many characters.
    """
    self._config["propagate_matches"] = enabled
    self._config["propagate_min_length"] = min_length
    self._rebuild()
    return self