Metadata-Version: 2.4
Name: mloda-pii
Version: 0.7.0
Summary: Claude Code hook and mloda FeatureGroup that pseudonymize PII before the model sees it
Author-email: Tom Kaltofen <info@mloda.ai>
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://mloda.ai
Keywords: mloda,mloda-plugin,pii,privacy,redaction,anonymization,claude-code,hook
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: <3.15,>=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mloda[pyarrow]<0.18.0,>=0.17.0
Requires-Dist: tomli>=1.0.0; python_version < "3.11"
Provides-Extra: hook
Requires-Dist: mloda-enterprise[anonymizer]>=0.11.0; extra == "hook"
Provides-Extra: dev
Requires-Dist: mloda-testing[binary-model]>=0.11.0; extra == "dev"
Requires-Dist: mloda-enterprise>=0.11.0; extra == "dev"
Requires-Dist: tox; extra == "dev"
Requires-Dist: tox-uv; extra == "dev"
Requires-Dist: uv>=0.13.4; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: pyrefly; extra == "dev"
Requires-Dist: bandit; extra == "dev"
Provides-Extra: presidio
Requires-Dist: presidio-analyzer>=2.2.364; extra == "presidio"
Dynamic: license-file

[![PyPI](https://img.shields.io/pypi/v/mloda-pii.svg)](https://pypi.org/project/mloda-pii/)
[![Python](https://img.shields.io/badge/python-3.10--3.14-blue.svg)](https://www.python.org/)
![License](https://img.shields.io/badge/license-proprietary-lightgrey.svg)
[![mloda](https://img.shields.io/badge/built%20with-mloda-blue.svg)](https://github.com/mloda-ai/mloda)

# mloda-pii

mloda-pii is a Claude Code hook that replaces
identifiers (emails, phone numbers, IBANs, IP addresses, card numbers, US SSNs, crypto wallets, your own names and
ID formats) in tool results with tokens such as `PII_EMAIL_ADDRESS_...` before the model sees them. Detection runs on
Microsoft Presidio; spaCy models add names and places. Your local tools and Claude's replies on your screen keep the
real values. Every token is a keyed HMAC-SHA256, computed through [mloda](https://github.com/mloda-ai/mloda). It guards
against accidental exposure and does not see everything the model gets; see Limits below.

The hook's steps ship as mloda FeatureGroups, so a pipeline can detect, tokenize and restore text columns the same way.

## Setup

```bash
uv tool install "mloda-pii[hook,presidio]"   # or pip install it into a virtualenv that stays in place
cd your-project
mloda-pii init                      # paste your invite when asked
```

The hook runs on Linux and macOS, and on Windows inside WSL; `init` refuses native Windows. It needs Claude Code
2.1.152 or later; `init` and `status` warn when the `claude` on `PATH` is older. `init`, `status` and each session
start warn when the license expires within 7 days.

Then restart Claude Code. On a terminal `init` asks for the invite with hidden input; scripts pass the invite file, `-`
for stdin, or the token itself. `init` creates the master key, stores and verifies the license, runs the hook once on a
test value, and only then registers it in `.claude/settings.local.json`. It also creates `.mloda-pii/config.toml`, kept
out of git: names (`[deny]`) and ID formats (`[patterns]`) to detect, and values to keep in clear (`[allow]`, loopback
addresses by default; also the presets `documentation` and `private_networks`). Unknown entries are rejected. Running
`init` again is safe, and needed after an upgrade to register new events. The first event of a session starts a warm
server for the project and the session end stops it; an event the server does not answer is blocked.

| Option | Effect |
| --- | --- |
| `--user` | Register in `~/.claude/settings.json`, for every project. |
| `--project` | Register in the committed `.claude/settings.json`; every teammate installs mloda-pii and runs `init --project`. |
| `--mode local` | No license key, see below. |
| `--key-file <path>` | Import an existing master key instead of creating one. |

Ask for an invite at info@mloda.ai or via [mloda.ai](https://mloda.ai).

## Modes

| Mode | License key | Needs |
| --- | --- | --- |
| `licensed` (default) | Required. Without a valid license every gated event blocks: tool results are withheld, tool calls denied, prompts blocked. | An invite. |
| `local` | None. | An anonymizer-rs `anonymizer` binary on `PATH` or in `ANONYMIZER_BINARY`; ask at info@mloda.ai. |

Both modes give the same tokens from the same master key; there is no fallback between them.

## Back up your master key

The master key lives in `~/.config/mloda-pii/master.key`. Back it up: every token depends on it, and a lost key
changes all of them. Keep a copy of the file somewhere private; `mloda-pii init --key-file <copy>` restores it. `init`
and `status` remind you, and `init` never overwrites an existing key.

## Everyday commands

- `mloda-pii status`: the Claude Code version, the key, the license and its expiry, whether the project config loads,
  where the hook is registered, the last events. Exits 1 when something is missing (a `[spacy_models]` model too) or
  the config has an error.
- `mloda-pii uninstall`: removes the hook entries (same `--user` / `--project` flags). `--purge` also deletes the
  master key, the stored license and the vaults in the state directory, after asking (`--yes` skips it).
- `mloda-pii license install | status | remove`: manage the license alone.
- `mloda-pii stats`: per-day counts from the audit log, which never holds values.

A prompt that contains raw identifiers is blocked, and mloda-pii sends Claude a pseudonymized copy instead, so work goes
on with one Enter. Claude Code keeps the blocked original in the input box (it was not sent): clear it (press Esc
twice). If a sent copy never reaches Claude, auto-send pauses for 24 hours and the copy is shown to resubmit instead,
also on the clipboard when one is reachable (`MLODA_PII_CLIPBOARD=off` disables that). Auto-send needs Claude Code
2.1.312 or later in a terminal; `claude -p`, `--project` setups and `MLODA_PII_AUTOSEND=off` keep the copy to resubmit.

At the end of a session the hook sends anonymous redaction counts per group and label to PostHog EU, never values.
`MLODA_PII_TELEMETRY=off` switches it off; `mloda-pii stats send --dry-run` shows the exact payload. Nothing is sent
until a release ships the PostHog project key.

| Variable | Effect |
| --- | --- |
| `MLODA_PII_KEY_FILE` | Master key file (default `~/.config/mloda-pii/master.key`). |
| `MLODA_PII_CONFIG` | The project config (default `.mloda-pii/config.toml`). |
| `MLODA_PII_ENTITIES` | Presidio labels to detect (default `EMAIL_ADDRESS,PHONE_NUMBER,IBAN_CODE,IP_ADDRESS,CREDIT_CARD,US_SSN,CRYPTO`). |
| `MLODA_PII_WRAP_BASH` | On by default: most failed Bash commands exit 0, with a "the command failed" marker instead of the exit code, so their output is pseudonymized too. `0` turns it off; failed output then reaches the model raw. |
| `MLODA_PII_TOKEN_STYLE` | `hex` (default, `PII_EMAIL_ADDRESS_<32 hex>`) or `readable` (`PII_EMAIL_ADDRESS_1`, numbered per project). |
| `MLODA_PII_FAIL_CLOSED` | On by default. `0` lets an action pass when the hook itself fails, logged as `failed_open`; a license block always blocks. |
| `MLODA_PII_REVERSE_MCP` | `1` also puts real values back into MCP tool input; a remote MCP server then receives them. |
| `MLODA_PII_VAULT_RETENTION_DAYS` | Delete vault entries not seen for this many days when a hook server worker starts (default `off`). |
| `MLODA_PII_WORKERS` | Hook server processes that run events in parallel, about 150 MB each, at least two once an event ran (default: CPUs minus one, at most 3). |
| `MLODA_PII_AUDIT_LOG` | Audit log path (default `~/.local/state/mloda-pii/audit.jsonl`); `off` disables it. |
| `MLODA_PII_TELEMETRY` | On by default; `off` (or `DO_NOT_TRACK=1`) stops the anonymous redaction counts. |
| `MLODA_LICENSE_FILE`, `MLODA_LICENSE_KEY` | Take precedence over the stored license; `init` checks the one in use and self-tests with it. |

Command options: `mloda-pii <command> --help`.

## The FeatureGroups

```python
from mloda.user import Feature, Options

feature = Feature("email__pii_anonymized", Options(context={"pii_key_file_env": "KEY_FILE"}))

envs = {"pii_key_file_env": "KEY_FILE", "pii_vault_path_env": "VAULT_PATH"}
text = Feature("notes__pii_marked_hash_pseudonymized", Options(context=envs, cascade_context_keys=frozenset(envs)))
```

`<column>__pii_anonymized` returns the 64-hex keyed HMAC-SHA256 token of a string column (or of each element of a
list<string> column). `pii_key_file_env` names the environment variable that holds the path to the master key file. It
needs the anonymizer-rs binary, like the local mode.

`<column>__pii_<marked_hash|counter>_pseudonymized` replaces the identifiers in a text column with tokens, as the hook
does; `<column>__pii_<strategy>_restored` maps them back from the vault named by `pii_vault_path_env`.
`<column>__pii_spans` returns the detected spans. Detection needs the `presidio` extra. `pii_config_env` names the
variable that holds the path of a `config.toml` (the hook's project config: allow list, deny words, patterns, spaCy
models); without it the defaults apply. A text column's name counts as written before each of its texts, so an
`ip_address` column keeps `10.0.0.1` as an address; `pii_field_column` names a column with one field name per row
instead, cascaded like the variables above.

## Limits

- The hook guards against accidental exposure, not a model that tries to recover values: a Bash command gets the real
  value and can return it disguised, which detection misses.
- Hooks never see `@file` mentions, CLAUDE.md and memory files, the client's own reminders and context (such as its
  git status), images or compaction summaries; those reach the model unchanged, as do results of Claude Code's own
  account memory MCP tools (the client drops their rewrites).
- Untested: whether tools that `claude mcp serve` runs for another MCP client go through the hooks, so do not serve a
  project that holds personal data that way; and whether `/resume` or compaction sends the original structured MCP
  result the local transcript keeps next to the rewrite.
- Detection finds emails, phone numbers, IBANs, IP addresses, card numbers (Luhn valid), US SSNs written
  `NNN-NN-NNNN` and checksum-valid Bitcoin addresses, and names and places once the project config names a
  spaCy model per language (`[spacy_models]` with `de` and `en`, installed from the release wheels at
  https://github.com/explosion/spacy-models/releases, as "Names and places" in the hook's README shows); without them
  names are found only under `[deny]`. Postal
  addresses and your ID formats are found only through `[deny]` or `[patterns]`; anything else passes in clear.
  `MLODA_PII_ENTITIES` replaces the default labels, so list them too when you add a Presidio pattern label such as
  `UK_NHS` (`URL` and `DATE_TIME` also match file names and dates). A `[patterns]` regex with nested repetition
  can run long enough to block every event it scans. A path that holds a detected value (an email in a file name)
  reaches the model as a token, and local tools get the real path back; a data file extension such as `.json`
  stays outside the token when the domain keeps a dot (`a@b.com.json`, not `a@host.json`).
- Known false positives and misses: anything shaped `name@x.y` reads as an email (`icon@2x.png`, `react@18.3.1`,
  `git@github.com`); a bare 10-digit number valid in the US or Canadian plan reads as a phone, so some IDs become
  phone tokens; outside the US and Canada an unlabeled bare national number (`9876543210`) is missed unless a
  `[patterns]` entry covers it; a 4-part version with a part of 32 or more (`1.0.0.123`) becomes an IP token; a 13
  to 17 digit ID or decimal-comma fraction that passes the Luhn check and starts like a card becomes a card token,
  and in a file name or other value the hook does not rewrite it makes the verifier end the turn. Cards starting with
  1 (UATP) or 56 to 59 (some Maestro) are missed, as are SSNs written without dashes; pipelines using the presidio
  engine with `en` among the languages detect the same.
  `[allow]` keeps exact values in clear.
- An IPv4 address with every part below 32 (`10.0.0.1`) is read as a version and stays in clear unless a port, CIDR
  suffix, URL or a word like `ip` or `host` comes with it, in the hook and in pipelines alike; the nearest MCP field
  name or the pipeline column name (`client_ip`, `ips`) counts too, an outer key or a `{"name": ..., "value": ...}`
  pair does not. An address tokenized once is tokenized bare in every field or row of the same run, and an IPv4
  address the vault holds in later events, a version string equal to it too. A bare copy shown before the address was
  tokenized stays in the conversation, and a small IPv6 address is not carried to later events, so there the model can
  still pair the token with the address. An IPv6 address of at most two decimal groups (`2001::1`) or two names of up
  to three characters (`a::b`) needs the same sign, since it reads like a Python slice or a scope; in a subscript, two
  groups that are decimal or up to three characters (`a[1::2]`, `a[b::2]`), and a bare `::`, always stay in clear.
- A tool result reaches the model as it is when the hook process itself crashes or hangs past the 60 s timeout `init`
  sets, and so does a failed tool other than Bash or a failed Bash command the wrapper cannot reach (`exec`, an
  `exit` after the command sets its own EXIT trap, most syntax errors, and outside `bypassPermissions` a sourced file
  that exits, a trailing `\` or a failing open heredoc without an `exit`), or a timed-out one that Claude Code kills
  instead of moving it to the background (one that starts with `sleep`, or any with
  `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`). The verifier then ends the turn, but the result stays in the
  conversation and the model reads it on the next turn.
- There is no size cap: a tool result is scanned whole, so a large one takes longer. The hook blocks an event its
  server does not answer within about 25 s (waiting behind other sessions' and subagents' events included), or whose
  server needs more than 20 s to start, so a slow machine may block every event: result withheld, call denied,
  prompt blocked. `MLODA_PII_FAIL_CLOSED=0` lets it pass instead.
- A Claude Code below 2.1.152 may skip every hook (untested), and one below 2.1.121 ignores the rewritten output of
  built-in tools in any case. `init` and `status` warn only about the `claude` on `PATH`, not an IDE or desktop
  client. A Claude Code release that changes the hook interface can let values through unnoticed until mloda-pii
  follows. Supported platforms are listed under Setup.
- Claude Code checks an Edit's `old_string` before any hook runs, so an Edit whose `old_string` holds a token fails.
  The hook tells the model to use a Bash command or Write instead; a Bash command may ask for permission, and a Write
  from a partial Read cuts the file.
- The license gate is a speed bump: the local mode makes the same tokens without one, by design.
- Tokens are still personal data under GDPR; whoever holds the key can recompute them.
- The local vault under `~/.local/state/mloda-pii/` (or `$XDG_STATE_HOME`) maps tokens back to raw values. It keeps them
  until `MLODA_PII_VAULT_RETENTION_DAYS` expires them or `uninstall --purge` deletes it; an expired token no longer
  maps back.

## License

mloda-pii is proprietary and source-available, under the mloda-pii License in the `LICENSE` file of the package. Use
in your daily work is permitted with a license key from mloda, also when the key is free, or in the local mode with an
anonymizer binary mloda provided. The license text is binding over this summary.

## Related

- [mloda](https://github.com/mloda-ai/mloda): declarative data and feature access, the framework mloda-pii runs on.
- [mloda-registry](https://github.com/mloda-ai/mloda-registry): plugins and guides for mloda.
