Metadata-Version: 2.5
Name: maskflare
Version: 0.2.0
Summary: Official Python SDK for Maskflare - mask sensitive data before it reaches an AI model, unmask it when you get a result back.
Project-URL: Homepage, https://maskflare.com
Project-URL: Documentation, https://maskflare.com/developers
Project-URL: Integrations, https://maskflare.com/integrations
Author: Maskflare
License-Expression: MIT
License-File: LICENSE
Keywords: ai,data masking,llm,maskflare,pii,redaction,tokenization
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Provides-Extra: edm
Requires-Dist: pyyaml>=6; extra == 'edm'
Provides-Extra: litellm
Requires-Dist: litellm<2,>=1.104; (python_version >= '3.10') and extra == 'litellm'
Description-Content-Type: text/markdown

# maskflare

Official Python SDK for [Maskflare](https://maskflare.com) - mask sensitive data before it
reaches an AI model, unmask it when you get a result back.

Built on [httpx](https://www.python-httpx.org/), its only runtime dependency.

Links: [maskflare.com](https://maskflare.com) · [Developer guide](https://maskflare.com/developers) · [AI provider integrations](https://maskflare.com/integrations) · [Book a demo](https://maskflare.com/contact)

## Install

```bash
pip install maskflare
```

## Quickstart

Generate a **Redaction**-purpose API key from your Maskflare dashboard (Configure → API keys →
Create key → Purpose: Redaction) and you're ready to go:

```python
import os
from maskflare import MaskFlare

client = MaskFlare(
    api_key=os.environ["MASKFLARE_API_KEY"],
    # base_url defaults to https://api.maskflare.com - set it to the gateway URL
    # from your Maskflare workspace (or http://localhost:8000 for local development).
)

result = client.mask("Contact ada@example.com for details")
# result.output: "Contact mf_mask_170b8521c1b944b3beb9ca02b328efbe for details"
# result.tokens: [MaskedToken(token="170b8521c1b944b3beb9ca02b328efbe", rule_key="email")]

# ... send result.output to your LLM, store it, log it - the real value never leaves your key's reach ...

original = client.unmask([t.token for t in result.tokens])
# original: {"170b8521c1b944b3beb9ca02b328efbe": "ada@example.com"}
```

`MaskFlare` also works as a context manager, which releases its connection pool on exit:

```python
with MaskFlare(api_key=os.environ["MASKFLARE_API_KEY"]) as client:
    result = client.mask("Contact ada@example.com for details")
```

A **Redaction** key can only mask/unmask - it's rejected if used against Maskflare's AI-gateway
forwarding routes, so a leaked SDK key can never be used to spend your AI-provider budget. Masking
rules (what counts as sensitive, and whether request/response text is checked) are configured per
environment on the dashboard's Rules page - the SDK just applies whatever your key's environment
has configured.

Tokens resolve back to their original value for 30 days from creation, then expire.

## API

### `MaskFlare(api_key, *, base_url=..., timeout=..., max_retries=..., http_client=...)`

| Parameter     | Type            | Default                     | Description                                                             |
| ------------- | --------------- | ---------------------------- | ------------------------------------------------------------------------ |
| `api_key`     | `str`           | _required_                   | A Redaction-purpose API key.                                             |
| `base_url`    | `str`           | `https://api.maskflare.com`  | Override for a self-hosted deployment or local development.              |
| `timeout`     | `float`         | `10.0`                       | Per-request timeout, in seconds.                                        |
| `max_retries` | `int`           | `2`                           | Automatic retries on `429`/`5xx`, honoring `Retry-After`. `0` disables.  |
| `http_client` | `httpx.Client`  | a new, internally-owned one  | Bring your own `httpx.Client` (e.g. to share a connection pool or add custom transport/proxy settings). When supplied, `MaskFlare` never closes it for you. |

### `client.mask(input: str, *, deterministic: bool = False, subject_tenant: str | None = None) -> MaskResult`

```python
@dataclass(frozen=True)
class MaskResult:
    output: str  # input, with each match replaced by an opaque token
    tokens: tuple[MaskedToken, ...]


@dataclass(frozen=True)
class MaskedToken:
    token: str
    rule_key: str
```

- `deterministic` (default `False`): when true, matches are replaced with stable, typed
  stand-ins (e.g. `EMAIL_kovapeluzi`) instead of random `mf_mask_...` tokens - the same value in
  the same scope always becomes the same stand-in, so you can mask text before embedding it and
  still get consistent matches at query time. **Deterministic stand-ins are pseudonymous data,
  not anonymous**: the original value is only one vault lookup away for anyone who can call
  `unmask`/`unmask_text` in that scope, so treat masked-with-`deterministic=True` text and any
  index built from it with the same care as the data it was derived from. Omitted entirely from
  the request when `False` (the default, unchanged behaviour).
- `subject_tenant` (default `None`): scopes deterministic masking to one of your end customers,
  so the same value under different tenants produces different stand-ins and can only be unmasked
  within that tenant's scope. Sent as the `X-MaskFlare-Subject-Tenant` header, only when set. Must
  be 1-128 characters of `[A-Za-z0-9._:-]`; anything else raises `TypeError` before any request is
  made.

### `client.unmask(tokens: Sequence[str], *, subject_tenant: str | None = None) -> dict[str, str]`

Returns a map of token → original value, for every token that was found and belonged to the
calling key. A token you don't own, or one that's expired, is simply omitted - check
`token in result` for the ones you need. Also resolves deterministic stand-ins from `mask(...,
deterministic=True)`.

`subject_tenant` must match the value used when the stand-in was created (see `mask` above); same
validation and header as `mask`.

### `client.unmask_text(text: str, *, subject_tenant: str | None = None) -> str`

Finds every `mf_mask_<id>` token and deterministic stand-in (e.g. `EMAIL_kovapeluzi`) in `text`
- typically an LLM response or a document retrieved from a RAG index built on masked text - and
replaces each one with its original value, resolving them from the API in batches of up to 100.
Anything that can't be resolved, including an ordinary identifier that merely happens to look like
a stand-in, is left untouched. Makes no request when `text` contains no candidate token.

```python
readable = client.unmask_text("Contact EMAIL_kovapeluzi about mf_mask_170b8521c1b944b3beb9ca02b328efbe")
# readable: "Contact jane.doe@acme.com about ada@example.com"
```

### Errors

- `MaskFlareError` - the API rejected the request. Has `.status`, `.code`, `.detail`, and (for a
  `429`) `.retry_after_seconds`.
- `MaskFlareConnectionError` - the request never reached the API (network failure or timeout).
- A plain `TypeError` for invalid input caught client-side before any request is made (e.g. an
  empty string to `mask`).

```python
from maskflare import MaskFlare, MaskFlareError

try:
    client.mask(user_input)
except MaskFlareError as error:
    if error.code == "rate_limited":
        ...  # back off - or just let the SDK's built-in retry handle it (see max_retries)
    raise
```

### `AsyncMaskFlare`

The asyncio client: the same constructor (with an optional `httpx.AsyncClient`), methods and
errors as `MaskFlare`, awaited, and closed with `await client.aclose()` or `async with`:

```python
from maskflare import AsyncMaskFlare

async with AsyncMaskFlare(api_key=os.environ["MASKFLARE_API_KEY"]) as client:
    result = await client.mask("Contact ada@example.com for details")
    readable = await client.unmask_text(result.output)
```

## Keeping values out of your own logs: `Sensitive`

Maskflare keeps sensitive data away from AI providers. `Sensitive` keeps it out of your own logs,
tracebacks and serialized output. Wrap a value once and pass the wrapper around. Every way Python
turns an object into text shows a placeholder instead of the value: `print`, `repr`/`str`,
f-strings, `%s`/`%r`, `logging`, exception messages and containers. `json.dumps` refuses the
wrapper. The value comes back only through `.reveal()`:

```python
import logging
from maskflare import Sensitive

email = Sensitive("ada@example.com")
print(email)  # Sensitive(***)
logging.info("signed up: %s", email)  # signed up: Sensitive(***)
print(Sensitive("ada@example.com", partial=True))  # Sensitive(ad***om)
send_welcome_mail(to=email.reveal())  # the one place the value is used
```

`partial=True` keeps the first and last two characters of a string of 8 or more; shorter strings
and other types stay fully hidden. Two wrappers compare equal when their values do (in constant
time for strings and bytes). Copies and pickles keep the value.

With Pydantic v2 installed (not a dependency), `Sensitive[str]` works as a model field. Input is
validated as `str` and wrapped, and `model_dump()` / `model_dump_json()` write the placeholder:

```python
from pydantic import BaseModel


class User(BaseModel):
    email: Sensitive[str]


user = User(email="ada@example.com")
user.model_dump()  # {'email': '***'}
user.email.reveal()  # 'ada@example.com'
```

For tests and local debugging only, `MASKFLARE_DISABLE_REDACTION=1` shows values as they are. It
is read once, when `maskflare` is imported, so nothing can switch redaction off later at runtime.
`Sensitive` guards against accidents, not against code that calls `.reveal()` on purpose.

## LiteLLM guardrail

Running [LiteLLM](https://docs.litellm.ai/) as your AI gateway? `MaskflareGuardrail` masks what
LiteLLM sends a model and restores the tokens in what comes back, so the model never sees the
values and your clients still get them:

```bash
pip install "maskflare[litellm]" "litellm[proxy]"   # LiteLLM needs Python 3.10+
```

```yaml
guardrails:
  - guardrail_name: maskflare
    litellm_params:
      guardrail: maskflare.integrations.litellm.MaskflareGuardrail
      mode: [pre_call, post_call]
      default_on: true
      api_key: os.environ/MASKFLARE_API_KEY   # a Redaction-purpose key
      fail_policy: closed
```

A complete config is in [`examples/litellm/config.yaml`](examples/litellm/config.yaml).

- **Before the call**, every message text and tool-call argument string is masked through
  `/v1/mask` (up to `max_concurrency`, default 8, at once).
- **After the call**, `mf_mask_` tokens in the answer's text and tool-call arguments are restored
  through `/v1/unmask` - streamed or not. A streamed token split across chunks is held back until
  it is whole (at most 39 characters, only while the text ends like the start of a token).
  Streams are restored for chat completions; other streamed shapes pass through as sent.
- **Only tokens minted for the same call are restored.** LiteLLM shares one Maskflare key across
  all of its users, so a token copied from another conversation into a prompt comes back as a
  token, never as another user's value.
- Outputs of non-reversible rule actions (redact, partial, hash) and format-preserving
  encryption are left as the model returned them.
- **`fail_policy`**: `closed` (default) refuses the call with a 503 when Maskflare can't mask it
  or restore the answer; `open` forwards the request unmasked (or returns the answer with its
  tokens) and logs a warning. A match of a `block` rule refuses the call with a 403 whatever the
  policy.
- Errors and log lines name a category (`blocked`, `connection`, `unavailable`, `rate_limited`,
  `auth`, `rejected`, `internal`) and the API's error code - never text, values, tokens or keys.

Other options: `api_base` (default `MASKFLARE_BASE_URL`, else the public API), `timeout`,
`max_retries`.

## Exact Data Match (EDM) uploads

Exact Data Match masks the values of *your own* customer list - names, emails, account numbers -
wherever they show up in AI traffic. The `maskflare` command hashes the list on your machine and
uploads only keyed one-way hashes: no value from the file ever touches the network or the disk
(the work dir holds hashes only), and the organization's EDM key lives in memory only.

```bash
pip install 'maskflare[edm]'   # adds PyYAML for schema.yaml; hashing needs CPython 3.13
export MASKFLARE_API_KEY=mf_...   # an EDM-purpose API key (dashboard: Configure -> API keys)

maskflare edm upload customers.csv --schema schema.yaml --dry-run   # validate and report only
maskflare edm upload customers.csv --schema schema.yaml
maskflare edm status [--dataset customers] [--json]
maskflare edm delete --dataset customers [--yes]
maskflare edm delete --all [--shred-key] --yes   # --shred-key also destroys the EDM key
```

`schema.yaml` names the columns to upload and how each one matches:

```yaml
version: 1
dataset: customers              # ^[a-z][a-z0-9_]{0,31}$
name: Customer master list
csv: {delimiter: ",", header: true, encoding: utf-8-sig}
fields:
  - column: full_name
    type: text                  # text | email | identifier
    label: Customer name
    rule_key: customer_name     # default edm_<dataset>_<column>; always put under edm_
    min_tokens: 2               # text only: the fewest words a value may have (1-4)
    apply_to: [request, response]   # default: request only for text, both otherwise
  - {column: email, type: email, label: Customer email}
  - {column: account_no, type: identifier, label: Account number}   # min_length defaults to 6
corroborating: []               # reserved; must be empty
```

At most 8 fields per dataset (the server allows 10 datasets per organization). With
`header: false`, `column` is a 1-based column number.

- **Upload options:** `--memory-mb 128` bounds the memory used to sort hashes (sorted runs go to
  `--work-dir`, a temporary directory by default, removed afterwards unless `--keep-work-dir`);
  `--json` prints the result as JSON on stdout; `--quiet` silences the progress on stderr.
- **Files:** any text encoding (`csv.encoding`; `utf-16` with `delimiter: "	"` reads Excel's
  "Unicode Text" export) and CR, LF or CRLF line endings; blank lines are skipped.
- **Empty fields:** committing a version replaces the live one, so an upload in which a field
  ends with no uploadable value (a wrong column or encoding, a file of placeholders) is refused
  unless you pass `--allow-empty-field`; one with no value in any field is always refused (use
  `edm delete` to remove a dataset). A commit that comes back `superseded` (a newer version was
  already active) warns and exits non-zero.
- **Auth:** `MASKFLARE_API_KEY` or `--api-key-file PATH`; `--api-key` works but warns (shell
  history). The base URL comes from `MASKFLARE_BASE_URL` or `--base-url`, and must be `https://`
  (plain `http://` only to a loopback host): the EDM key travels in a response body.
- **What is not uploaded:** cells that can't be matched as their type (`empty`, `too_few_tokens`,
  `too_many_tokens`, `too_long`, `too_short`, `bad_shape`, `unsupported_script` - Han, Thai and
  other scripts written without spaces aren't supported in text fields) and placeholder cells such
  as `N/A`, `test`, `unknown` or `noreply@...` (`junk`). They are counted per field and listed
  by row number and reason - the report never prints a value. Run `--dry-run` first to see them;
  it makes no network call.
- **Resume:** an upload is named by the hash of its schema, key version and hash streams, so
  re-running the same command after an interruption uploads only the chunks still missing, and
  re-running a completed upload changes nothing. Transient failures (429, 5xx, network) are
  retried with backoff. A 503 `edm_unavailable` from the key request is not: it means EDM is not
  configured on that Maskflare, so the command stops at once and says so. A changed file becomes a new version that replaces the active one when it
  commits.
- **Python:** `import maskflare` keeps working on Python 3.9+, and `edm status`/`edm delete` run
  anywhere; hashing (`edm upload`) needs **CPython 3.13** because it must use exactly the Unicode
  15.1 tables Maskflare's servers canonicalise with, and refuses to run on anything else.

## Development

```bash
uv sync
uv run pytest       # tests
uv run ruff check .  # lint
uv run pyright       # typecheck
```

`src/maskflare/edm/_canon.py`, `_confusables.py` and `tests/fixtures/edm_canon_vectors.json` are
generated copies of maskflare-core's EDM canonicalisation - never edit them here; re-sync from a
core checkout with `uv run python scripts/sync_edm_canon.py --sdk ../maskflare-sdk-python`. Two
cross-repo tests are skipped here unless their repo is importable: `tests/test_edm_core_e2e.py`
(core's `EdmStore` and matcher find what the SDK uploaded; run it with core's virtualenv, see its
docstring) and `tests/test_edm_platform_contract.py` (platform-service's validators accept the
upload). `MASKFLARE_SLOW_TESTS=1` runs the opt-in 5M-row external-sort test.

`tests/test_litellm_proxy.py` starts a real LiteLLM proxy (`litellm[proxy]`, a dev dependency)
from `examples/litellm/config.yaml`, between local fakes of the Maskflare API and an OpenAI
provider (`tests/litellm_fakes.py`), and checks what the model received and what the client got
back.

## License

MIT
