Ready-to-use privacy blocks for LLM apps, observability pipelines, and AI
tools, powered by
bardsai/eu-pii-anonimization-multilang
— an on-device, 24-language EU-PII NER model. No text ever leaves your machine
to be classified.
The goal is simple: put a privacy block at every point where data crosses a trust boundary. There are two operations and two deployment modes:
| You own the code (in-process) | You don't (network path) | |
|---|---|---|
| Data is stored / observed → redact, one-way | Redactor, Langfuse mask, LangChain callback | OTLP sink scrubbing telemetry |
| Data is in a live LLM loop → pseudonymize, reversible | Vault (anonymize / deanonymize) | Gateway for Claude Code, Codex, Cursor |
A local proxy for Claude Code, Codex, and Cursor — tools you can't add a mask callback to, but that all honor a *_BASE_URL override. It sits on that path, detects PII, and (optionally) replaces values with [TYPE_N] placeholders before they leave your machine, rehydrating the response on the way back.
Docker (gateway + dashboard — only Docker needed on the host; model baked into the image at build time):
make setup
make route # route Claude Code + Codex
make run # dashboard → http://127.0.0.1:3000, gateway → :8787
The compose file mounts your ~/.claude/projects and ~/.codex/sessions read-only into the gateway container (via PII_CLAUDE_ROOT / PII_CODEX_ROOT) so Settings → Import history can see your local session history.
Open http://127.0.0.1:3000. Start a new tool session after routing. For Cursor, also set the chat panel base URL in Settings → Models (privacy-kit setup cursor prints the value). Undo with make route-remove or privacy-kit setup … --remove.
┌─────────────────────────────┐
request │ privacy-kit gateway │ sanitized* request
with ─────▶│ detect PII + audit │─────▶ sent to the
PII │ pseudonymize* │ real LLM API
│ │
response │ │ response with
real ◀─────│ rehydrate* │◀───── placeholders
values └─────────────────────────────┘
* enforce mode only (PII_POLICY=pseudonymize)
Claude Code, Codex, and Cursor send your prompts (and file reads) to cloud LLMs — you can't add a mask callback, but they all honor a *_BASE_URL override. The gateway is a local proxy on that path: it detects PII, writes an audit row, and — in enforce mode (PII_POLICY=pseudonymize) — replaces values with [TYPE_N] placeholders before they leave your machine, rehydrating the response on the way back (streaming included). The default monitor mode only detects and logs; the prompt is forwarded unchanged. Your auth passes through untouched, and every routed tool's interactions land in the same shared audit log.
Start the gateway first (loads the on-device model, listens on 127.0.0.1:8787), then route each tool through it:
privacy-kit serve # start the gateway
privacy-kit report # summarize the audit log
privacy-kit scan secrets.txt # one-off detection; --anonymize to mask
privacy-kit import # import past Claude Code / Codex conversations
Routes via ANTHROPIC_BASE_URL in ~/.claude/settings.json (applies to new sessions). A Claude Max/Pro subscription works with no API key — the gateway forwards your OAuth token and preserves Claude Code's system identifier. (If your version won't send that token to a custom base URL, run claude setup-token once.)
privacy-kit setup claude-code --apply # route persistently (undo: --remove)
privacy-kit serve --route claude-code # only while the gateway runs (auto-restores)
# make route-claude-code / make route-claude-code-remove
setup codex --apply adds a dedicated [model_providers.privacy-kit] table to ~/.codex/config.toml and selects it with model_provider = "privacy-kit". It sets supports_websockets = false so Codex sends plain HTTP the gateway can sanitize (instead of its default WebSocket transport). Works in both auth modes from one setting: a ChatGPT-account login (no API key — experimental) or an API key.
privacy-kit setup codex --apply # route persistently (undo: --remove)
# make route-codex / make route-codex-remove
Cursor's surfaces sit on two backends, so it needs two layers:
http://127.0.0.1:8787/v1 plus your OpenAI key (privacy-kit setup cursor prints these).beforeSubmitPrompt and beforeReadFile (prompt and file-read audit; set PII_CURSOR_BLOCK=1 to deny on PII, fails open if the gateway is down) and afterAgentResponse (records the agent's reply alongside the prompt in the dashboard, observe-only — hooks can't rewrite content, so no redaction is possible here).privacy-kit setup cursor --apply # install hooks in ~/.cursor/hooks.json
privacy-kit setup cursor --apply --scope project # ...in .cursor/hooks.json instead
# undo: --remove · make route-cursor / make route-cursor-remove
make route / make route-remove apply or undo all three at once.
Config changes only apply to new sessions — anything already running keeps talking with the old configuration (i.e. straight past the gateway). Every make route* target therefore ends with a restart check (privacy-kit restart-clients):
claude --continue resumes your last conversation).Restart Cursor now? [y/N]. Answering y closes Cursor cleanly and reopens it; answering n (or running non-interactively, e.g. CI) leaves it untouched and just prints the warning — restart it yourself later.While the gateway runs, the web dashboard — a self-contained JS web UI (make run or make dev, then http://127.0.0.1:3000) — shows the shared audit log for all three tools: overview charts, per-interaction before/after text, filter / sort / search, CSV/JSON export, delete/clear, and an in-memory live PII preview (never stored). No external assets, no CDN.
The proxy only sees traffic from the moment you route a tool through it. privacy-kit import backfills the audit log from the history already on disk: Claude Code session transcripts (~/.claude/projects/*/*.jsonl) and Codex rollout files (~/.codex/sessions/**/rollout-*.jsonl). Every message runs through the same on-device detection pipeline as live traffic — one placeholder vault per conversation, so a value keeps its [TYPE_N] placeholder across turns — and lands in the dashboard with its original timestamps, marked source = claude-code-import / codex-import and policy = imported so the existing filters separate it from live traffic. PII_SAVE_TEXTS is honored: with anonymized, only segments that contained PII are stored.
privacy-kit import # import everything from both tools
privacy-kit import --dry-run # discover + dedupe only, write nothing
privacy-kit import -s claude-code --since 2026-06-01
privacy-kit import -s claude-code --project my-repo
| Option | Default | Meaning |
|---|---|---|
--source, -s | claude-code, codex | History source(s) to import; repeat the flag to pass several. |
--since | — | Only sessions whose file was modified on/after this date. Accepts YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, or "YYYY-MM-DD HH:MM:SS" (local time). |
--until | now | Only sessions whose file was modified on/before this date (same formats; a date-only value covers that whole day). |
--project | — | Claude Code only: substring filter on the project directory name (e.g. my-repo matches ~/.claude/projects/-home-me-my-repo). |
--dry-run | off | Discover sessions and report what would be imported without loading the model or writing anything. |
--db | PII_DB_PATH | Audit database to write into, overriding the configured path. |
Re-running is idempotent: a session's own UUID becomes its conversation_id, and sessions already present (imported earlier or captured live through the proxy) are skipped — so periodic re-runs import only what's new. One limitation: a session that gained new turns after being imported is skipped whole; delete it in the dashboard and re-import to pick up the additions. Skipped on principle, mirroring the proxy's storage policy: subagent sidechains, slash-command wrappers, thinking blocks, tool-call arguments, and injected system/developer instructions.
The same importer is exposed in the dashboard (Settings → Import history): pick sources, filter by date range or Claude Code project, dry-run to see counts without writing, and watch progress live — the new-vs-imported split updates as you change the filters. Show sessions expands an inspectable list of the individual conversations the filters cover — title (the session's first human prompt), project, last-modified date, and a new/imported badge — and unchecking a session skips it on import (everything beyond the list's display cap stays included). Under the hood that's GET /api/v1/import/preview (optional since / until / project query params), GET /api/v1/import/preview/sessions (same params plus sources and limit; newest first, titles honor expose_plaintext), POST /api/v1/import (body {"sources": ["claude-code", "codex"], "since": "2026-06-01", "until": "2026-06-30", "project": "my-repo", "exclude_session_ids": ["<uuid>"], "dry_run": false}, every field optional, runs in the background, 409 if a run is active), and GET /api/v1/import/status. The dashboard always writes to the gateway's configured store — the CLI's --db override has no equivalent there.
Supported wire formats: Anthropic Messages (/v1/messages, /v1/messages/count_tokens), OpenAI Chat Completions, OpenAI Responses. Cursor hooks post to /v1/cursor-hook.
Use these blocks when you own the code path — Langfuse callbacks, LangChain
agents, direct redaction, or a Vault in your own LLM loop.
pip install privacy-kit # library blocks (ONNX, no torch)
pip install 'privacy-kit[gateway]' # + the local gateway proxy & CLI
The default install uses a local ONNX backend:
torchtransformershuggingface-hubIf you already use Langfuse, pass make_mask() to the Langfuse client:
from langfuse import Langfuse
from privacy_kit.integrations.langfuse import make_mask
langfuse = Langfuse(mask=make_mask())
make_mask() recursively scans every string Langfuse sends through the mask
callback, including nested input, output, metadata, messages, tool calls,
reasoning fields, and custom JSON fields.
By default, you do not need to configure field paths. Everything text-like is scanned.
LangChain agents are LangGraph-backed. Install the LangChain extra:
pip install 'privacy-kit[langchain]'
Then create the Langfuse callback with privacy-kit:
from langchain.agents import create_agent
from privacy_kit.integrations.langchain import make_langfuse_callback
langfuse_handler = make_langfuse_callback()
agent = create_agent(
model="groq:llama-3.1-8b-instant",
tools=[],
system_prompt="You are a concise assistant.",
)
agent.invoke(
{
"messages": [
{
"role": "user",
"content": "Jan Kowalski, [email protected], +48 501 222 333",
}
]
},
config={"callbacks": [langfuse_handler]},
)
The callback protects LangChain/LangGraph payloads before they are exported to Langfuse. This includes nested message objects and metadata that LangChain passes through callback events.
Use Redactor directly if you want to redact text or arbitrary JSON-like payloads.
from privacy_kit import Redactor
redactor = Redactor()
redactor.redact_text("Kontakt: [email protected]. Telefon +48 501 222 333.")
# "Kontakt: [REDACTED]. Telefon [REDACTED]."
Structured payloads:
redactor.redact(
{
"input": "Jan Kowalski, [email protected]",
"output": "Wyślemy odpowiedź do Jana Kowalskiego.",
"metadata": {"phone": "+48 501 222 333"},
}
)
# {
# "input": "[REDACTED], [REDACTED]",
# "output": "Wyślemy odpowiedź do [REDACTED].",
# "metadata": {"phone": "[REDACTED]"},
# }
When the consumer of the text still needs referential consistency — and you
need the real values back — use a Vault instead of redacting:
from privacy_kit import Vault, anonymize, deanonymize
clean, vault = anonymize(
"Jan Kowalski ([email protected]) met Jan Kowalski.",
detector, # any Detector, e.g. privacy_kit.core.build_detector()
)
# clean == "[PERSON_NAME_1] ([EMAIL_ADDRESS_1]) met [PERSON_NAME_1]."
# same value -> same placeholder, every time
deanonymize(clean, vault)
# back to the original text
vault.type_counts # {"PERSON_NAME": 1, "EMAIL_ADDRESS": 1} — safe to log
This is the primitive the gateway is built on: an LLM can reason about
[PERSON_NAME_1] consistently across a whole conversation, and the response
is rehydrated before the user sees it.
The default is intentionally broad: scan every string in the payload.
If needed, limit or exclude paths:
from langfuse import Langfuse
from privacy_kit.integrations.langfuse import make_mask
langfuse = Langfuse(
mask=make_mask(
include_paths=[
"input",
"output",
"metadata",
"messages.*.content",
"tool_calls.*.args",
],
exclude_paths=[
"metadata.trace_id",
"metadata.model",
"usage",
],
)
)
exclude_paths wins over include_paths.
Environment variables are also supported:
export PII_INCLUDE_PATHS="input,output,metadata,messages.*.content"
export PII_EXCLUDE_PATHS="metadata.trace_id,usage"
export PII_EXCLUDE_LABELS="ORGANIZATION_NAME"
Everything reads the same PII_* environment variables:
export PII_MODEL_ID=bardsai/eu-pii-anonimization-multilang # default
export PII_MODEL_CACHE_DIR=/path/to/model-cache # optional
export PII_THRESHOLD=0.5 # min confidence for a span to count as PII
# gateway only:
export PII_POLICY=monitor # "monitor" (default): detect + log, forward prompt unchanged
# (real PII reaches the upstream); "pseudonymize": replace
# PII with [TYPE_N] placeholders before forwarding, then rehydrate
export PII_POLICY_OVERRIDES='{"SECRET_*": "block"}'
# per-entity-type actions layered over PII_POLICY (JSON map).
# keys: entity labels or prefix wildcards (SECRET_*);
# actions: keep | redact (one-way [REDACTED]) | pseudonymize |
# block (refuse the request with 403 before anything is sent).
# Overrides apply in both modes — the example above stops
# credentials cold even while everything else is only monitored.
export PII_HOST=127.0.0.1
export PII_PORT=8787
export PII_DB_PATH=~/.privacy-kit/audit.sqlite # default; created 0600, reused across working dirs
export PII_SAVE_TEXTS=all # save request texts: "all" (default) or "anonymized"
export PII_ANTHROPIC_UPSTREAM=https://api.anthropic.com
export PII_OPENAI_UPSTREAM=https://api.openai.com
export PII_CHATGPT_UPSTREAM=https://chatgpt.com/backend-api # Codex subscription mode upstream
export PII_OTEL_DOWNSTREAM= # optional collector for scrubbed telemetry
export PII_CLAUDE_ROOT= # history import: Claude Code sessions dir
# (default $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects)
export PII_CODEX_ROOT= # history import: Codex sessions dir
# (default $CODEX_HOME/sessions, else ~/.codex/sessions)
The model is not bundled in the package. It is downloaded on first use and reused from cache after that.
| Target | Status | API |
|---|---|---|
| Langfuse SDK | Ready | Langfuse(mask=make_mask()) |
| LangChain / LangGraph | Ready | make_langfuse_callback() with config={"callbacks": [...]} |
| Claude Code | Ready | gateway: privacy-kit setup claude-code |
| Codex (API key) | Ready | gateway: privacy-kit setup codex --apply |
| Codex (ChatGPT subscription) | Experimental | gateway: privacy-kit setup codex --apply |
| Cursor (chat panel) | Ready | gateway base URL: privacy-kit setup cursor (pseudonymizes) |
| Cursor (Composer / agent) | Ready | hooks: privacy-kit setup cursor --apply (audit; block via PII_CURSOR_BLOCK=1) |
| OpenTelemetry logs | Ready | gateway OTLP sink (OTEL_EXPORTER_OTLP_PROTOCOL=http/json) |
| Pydantic AI | Coming soon | Planned integration example |
| OpenAI SDK | Coming soon | Planned in-process wrapper on Vault |
Runnable examples live in examples/. They are kept in the
repository for copy-paste onboarding and are not installed as runtime package
modules.
privacy-kit runs on your machine; the model runs locally in-process.
For Langfuse/LangChain (observability) use cases:
For the gateway use case, what reaches the provider depends on PII_POLICY. In
the default monitor mode the prompt is forwarded unchanged (raw values
reach the provider) while the detection is logged locally. Under
PII_POLICY=pseudonymize the guarantee is stronger: the LLM provider itself
never sees the raw values — only placeholders. Either way, the local audit store
keeps entity counts plus the request texts selected by PII_SAVE_TEXTS (original
and anonymized, in plaintext on your machine).
privacy-kit also keeps the original PiiModel API for label-preserving
anonymization and direct entity extraction.
Install the optional Transformers backend:
pip install 'privacy-kit[transformers]'
Use PiiModel:
from privacy_kit import PiiModel
model = PiiModel().from_pretrained("bardsai/eu-pii-anonimization-multilang")
text = "Anna Wiśniewska mieszka na ul. Piękna 22, 00-549 Warszawa."
model.anonymize(text)
# "[PERSON_NAME] mieszka na [LOCATION]."
model.anonymize(text, mode="ids")
# {
# "anonymized_text": "[PERSON_NAME:1] mieszka na [LOCATION:1].",
# "entities": {
# "PERSON_NAME:1": "Anna Wiśniewska",
# "LOCATION:1": "ul. Piękna 22, 00-549 Warszawa",
# },
# }
model.extract_pii(text)
# {
# "Anna Wiśniewska": "PERSON_NAME",
# "ul. Piękna 22, 00-549 Warszawa": "LOCATION",
# }
Use this lower-level API when you want entity labels such as [PERSON_NAME] or
extracted PII mappings. The integration APIs use [REDACTED] by default because
observability systems usually should not store the PII type either. For
reversible, indexed placeholders prefer the Vault API above — it is idempotent
and offset-exact.
The underlying model recognizes EU-relevant PII categories including:
PERSON_NAME, EMAIL_ADDRESS, PHONE_NUMBER, LOCATION,
FINANCIAL_AMOUNT, PERSON_IDENTIFIER, ORGANIZATION_IDENTIFIER,
PROPER_NAME, RELIGION_OR_BELIEF, TRADE_UNION_MEMBERSHIP, and more.
An NER model has no label for credentials — and AI coding tools send your
tools' file reads (including .env files, configs, and keys) to cloud LLMs
with no client-side filtering. So alongside the model, privacy-kit always runs
a deterministic layer: regex + checksum detectors that need no model, no
network, and microseconds per call.
SECRET_): AWS/GCP/GitHub/GitLab/OpenAI/
Anthropic/Hugging Face/Slack/Stripe/SendGrid/npm/PyPI keys and tokens, JWTs,
PEM private-key blocks, credentials in connection URLs (only the password is
masked), Authorization: header values, and generic API_KEY=…-style
assignments gated by a Shannon-entropy floor. Patterns are vendored from the
gitleaks ruleset (MIT) as a curated,
tested in-tree table (make sync-secret-rules diffs against upstream).PAYMENT_CARD,
BANK_ACCOUNT_IDENTIFIER; international +… phone numbers →
PHONE_NUMBER; dashed US SSNs → PERSON_IDENTIFIER.Where a deterministic span overlaps a model span, the secret wins — a
credential never survives because a probabilistic span happened to cover it.
Detector backends: local (default — model + deterministic layer), model
(NER only), regex (deterministic only: instant startup, no download), null.
make install # uv sync with the gateway extra
make check # ruff + mypy --strict + pytest — must be green before commit
make test-model # tests that download and run the real model
make serve # run the gateway locally
A privacy invariant is enforced by lint and tests: code under src/ must
never print or log the text it processes (ruff T20 + the log-safety test
suite), and raw text lives only in the audit store's dedicated texts table
(scoped by PII_SAVE_TEXTS) — never in any other table, log, or output.
Apache-2.0
Ready-to-use privacy blocks for LLM apps, observability pipelines, and AI
tools, powered by
bardsai/eu-pii-anonimization-multilang
— an on-device, 24-language EU-PII NER model. No text ever leaves your machine
to be classified.
The goal is simple: put a privacy block at every point where data crosses a trust boundary. There are two operations and two deployment modes:
| You own the code (in-process) | You don't (network path) | |
|---|---|---|
| Data is stored / observed → redact, one-way | Redactor, Langfuse mask, LangChain callback | OTLP sink scrubbing telemetry |
| Data is in a live LLM loop → pseudonymize, reversible | Vault (anonymize / deanonymize) | Gateway for Claude Code, Codex, Cursor |
A local proxy for Claude Code, Codex, and Cursor — tools you can't add a mask callback to, but that all honor a *_BASE_URL override. It sits on that path, detects PII, and (optionally) replaces values with [TYPE_N] placeholders before they leave your machine, rehydrating the response on the way back.
Docker (gateway + dashboard — only Docker needed on the host; model baked into the image at build time):
make setup
make route # route Claude Code + Codex
make run # dashboard → http://127.0.0.1:3000, gateway → :8787
The compose file mounts your ~/.claude/projects and ~/.codex/sessions read-only into the gateway container (via PII_CLAUDE_ROOT / PII_CODEX_ROOT) so Settings → Import history can see your local session history.
Open http://127.0.0.1:3000. Start a new tool session after routing. For Cursor, also set the chat panel base URL in Settings → Models (privacy-kit setup cursor prints the value). Undo with make route-remove or privacy-kit setup … --remove.
┌─────────────────────────────┐
request │ privacy-kit gateway │ sanitized* request
with ─────▶│ detect PII + audit │─────▶ sent to the
PII │ pseudonymize* │ real LLM API
│ │
response │ │ response with
real ◀─────│ rehydrate* │◀───── placeholders
values └─────────────────────────────┘
* enforce mode only (PII_POLICY=pseudonymize)
Claude Code, Codex, and Cursor send your prompts (and file reads) to cloud LLMs — you can't add a mask callback, but they all honor a *_BASE_URL override. The gateway is a local proxy on that path: it detects PII, writes an audit row, and — in enforce mode (PII_POLICY=pseudonymize) — replaces values with [TYPE_N] placeholders before they leave your machine, rehydrating the response on the way back (streaming included). The default monitor mode only detects and logs; the prompt is forwarded unchanged. Your auth passes through untouched, and every routed tool's interactions land in the same shared audit log.
Start the gateway first (loads the on-device model, listens on 127.0.0.1:8787), then route each tool through it:
privacy-kit serve # start the gateway
privacy-kit report # summarize the audit log
privacy-kit scan secrets.txt # one-off detection; --anonymize to mask
privacy-kit import # import past Claude Code / Codex conversations
Routes via ANTHROPIC_BASE_URL in ~/.claude/settings.json (applies to new sessions). A Claude Max/Pro subscription works with no API key — the gateway forwards your OAuth token and preserves Claude Code's system identifier. (If your version won't send that token to a custom base URL, run claude setup-token once.)
privacy-kit setup claude-code --apply # route persistently (undo: --remove)
privacy-kit serve --route claude-code # only while the gateway runs (auto-restores)
# make route-claude-code / make route-claude-code-remove
setup codex --apply adds a dedicated [model_providers.privacy-kit] table to ~/.codex/config.toml and selects it with model_provider = "privacy-kit". It sets supports_websockets = false so Codex sends plain HTTP the gateway can sanitize (instead of its default WebSocket transport). Works in both auth modes from one setting: a ChatGPT-account login (no API key — experimental) or an API key.
privacy-kit setup codex --apply # route persistently (undo: --remove)
# make route-codex / make route-codex-remove
Cursor's surfaces sit on two backends, so it needs two layers:
http://127.0.0.1:8787/v1 plus your OpenAI key (privacy-kit setup cursor prints these).beforeSubmitPrompt and beforeReadFile (prompt and file-read audit; set PII_CURSOR_BLOCK=1 to deny on PII, fails open if the gateway is down) and afterAgentResponse (records the agent's reply alongside the prompt in the dashboard, observe-only — hooks can't rewrite content, so no redaction is possible here).privacy-kit setup cursor --apply # install hooks in ~/.cursor/hooks.json
privacy-kit setup cursor --apply --scope project # ...in .cursor/hooks.json instead
# undo: --remove · make route-cursor / make route-cursor-remove
make route / make route-remove apply or undo all three at once.
Config changes only apply to new sessions — anything already running keeps talking with the old configuration (i.e. straight past the gateway). Every make route* target therefore ends with a restart check (privacy-kit restart-clients):
claude --continue resumes your last conversation).Restart Cursor now? [y/N]. Answering y closes Cursor cleanly and reopens it; answering n (or running non-interactively, e.g. CI) leaves it untouched and just prints the warning — restart it yourself later.While the gateway runs, the web dashboard — a self-contained JS web UI (make run or make dev, then http://127.0.0.1:3000) — shows the shared audit log for all three tools: overview charts, per-interaction before/after text, filter / sort / search, CSV/JSON export, delete/clear, and an in-memory live PII preview (never stored). No external assets, no CDN.
The proxy only sees traffic from the moment you route a tool through it. privacy-kit import backfills the audit log from the history already on disk: Claude Code session transcripts (~/.claude/projects/*/*.jsonl) and Codex rollout files (~/.codex/sessions/**/rollout-*.jsonl). Every message runs through the same on-device detection pipeline as live traffic — one placeholder vault per conversation, so a value keeps its [TYPE_N] placeholder across turns — and lands in the dashboard with its original timestamps, marked source = claude-code-import / codex-import and policy = imported so the existing filters separate it from live traffic. PII_SAVE_TEXTS is honored: with anonymized, only segments that contained PII are stored.
privacy-kit import # import everything from both tools
privacy-kit import --dry-run # discover + dedupe only, write nothing
privacy-kit import -s claude-code --since 2026-06-01
privacy-kit import -s claude-code --project my-repo
| Option | Default | Meaning |
|---|---|---|
--source, -s | claude-code, codex | History source(s) to import; repeat the flag to pass several. |
--since | — | Only sessions whose file was modified on/after this date. Accepts YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, or "YYYY-MM-DD HH:MM:SS" (local time). |
--until | now | Only sessions whose file was modified on/before this date (same formats; a date-only value covers that whole day). |
--project | — | Claude Code only: substring filter on the project directory name (e.g. my-repo matches ~/.claude/projects/-home-me-my-repo). |
--dry-run | off | Discover sessions and report what would be imported without loading the model or writing anything. |
--db | PII_DB_PATH | Audit database to write into, overriding the configured path. |
Re-running is idempotent: a session's own UUID becomes its conversation_id, and sessions already present (imported earlier or captured live through the proxy) are skipped — so periodic re-runs import only what's new. One limitation: a session that gained new turns after being imported is skipped whole; delete it in the dashboard and re-import to pick up the additions. Skipped on principle, mirroring the proxy's storage policy: subagent sidechains, slash-command wrappers, thinking blocks, tool-call arguments, and injected system/developer instructions.
The same importer is exposed in the dashboard (Settings → Import history): pick sources, filter by date range or Claude Code project, dry-run to see counts without writing, and watch progress live — the new-vs-imported split updates as you change the filters. Show sessions expands an inspectable list of the individual conversations the filters cover — title (the session's first human prompt), project, last-modified date, and a new/imported badge — and unchecking a session skips it on import (everything beyond the list's display cap stays included). Under the hood that's GET /api/v1/import/preview (optional since / until / project query params), GET /api/v1/import/preview/sessions (same params plus sources and limit; newest first, titles honor expose_plaintext), POST /api/v1/import (body {"sources": ["claude-code", "codex"], "since": "2026-06-01", "until": "2026-06-30", "project": "my-repo", "exclude_session_ids": ["<uuid>"], "dry_run": false}, every field optional, runs in the background, 409 if a run is active), and GET /api/v1/import/status. The dashboard always writes to the gateway's configured store — the CLI's --db override has no equivalent there.
Supported wire formats: Anthropic Messages (/v1/messages, /v1/messages/count_tokens), OpenAI Chat Completions, OpenAI Responses. Cursor hooks post to /v1/cursor-hook.
Use these blocks when you own the code path — Langfuse callbacks, LangChain
agents, direct redaction, or a Vault in your own LLM loop.
pip install privacy-kit # library blocks (ONNX, no torch)
pip install 'privacy-kit[gateway]' # + the local gateway proxy & CLI
The default install uses a local ONNX backend:
torchtransformershuggingface-hubIf you already use Langfuse, pass make_mask() to the Langfuse client:
from langfuse import Langfuse
from privacy_kit.integrations.langfuse import make_mask
langfuse = Langfuse(mask=make_mask())
make_mask() recursively scans every string Langfuse sends through the mask
callback, including nested input, output, metadata, messages, tool calls,
reasoning fields, and custom JSON fields.
By default, you do not need to configure field paths. Everything text-like is scanned.
LangChain agents are LangGraph-backed. Install the LangChain extra:
pip install 'privacy-kit[langchain]'
Then create the Langfuse callback with privacy-kit:
from langchain.agents import create_agent
from privacy_kit.integrations.langchain import make_langfuse_callback
langfuse_handler = make_langfuse_callback()
agent = create_agent(
model="groq:llama-3.1-8b-instant",
tools=[],
system_prompt="You are a concise assistant.",
)
agent.invoke(
{
"messages": [
{
"role": "user",
"content": "Jan Kowalski, [email protected], +48 501 222 333",
}
]
},
config={"callbacks": [langfuse_handler]},
)
The callback protects LangChain/LangGraph payloads before they are exported to Langfuse. This includes nested message objects and metadata that LangChain passes through callback events.
Use Redactor directly if you want to redact text or arbitrary JSON-like payloads.
from privacy_kit import Redactor
redactor = Redactor()
redactor.redact_text("Kontakt: [email protected]. Telefon +48 501 222 333.")
# "Kontakt: [REDACTED]. Telefon [REDACTED]."
Structured payloads:
redactor.redact(
{
"input": "Jan Kowalski, [email protected]",
"output": "Wyślemy odpowiedź do Jana Kowalskiego.",
"metadata": {"phone": "+48 501 222 333"},
}
)
# {
# "input": "[REDACTED], [REDACTED]",
# "output": "Wyślemy odpowiedź do [REDACTED].",
# "metadata": {"phone": "[REDACTED]"},
# }
When the consumer of the text still needs referential consistency — and you
need the real values back — use a Vault instead of redacting:
from privacy_kit import Vault, anonymize, deanonymize
clean, vault = anonymize(
"Jan Kowalski ([email protected]) met Jan Kowalski.",
detector, # any Detector, e.g. privacy_kit.core.build_detector()
)
# clean == "[PERSON_NAME_1] ([EMAIL_ADDRESS_1]) met [PERSON_NAME_1]."
# same value -> same placeholder, every time
deanonymize(clean, vault)
# back to the original text
vault.type_counts # {"PERSON_NAME": 1, "EMAIL_ADDRESS": 1} — safe to log
This is the primitive the gateway is built on: an LLM can reason about
[PERSON_NAME_1] consistently across a whole conversation, and the response
is rehydrated before the user sees it.
The default is intentionally broad: scan every string in the payload.
If needed, limit or exclude paths:
from langfuse import Langfuse
from privacy_kit.integrations.langfuse import make_mask
langfuse = Langfuse(
mask=make_mask(
include_paths=[
"input",
"output",
"metadata",
"messages.*.content",
"tool_calls.*.args",
],
exclude_paths=[
"metadata.trace_id",
"metadata.model",
"usage",
],
)
)
exclude_paths wins over include_paths.
Environment variables are also supported:
export PII_INCLUDE_PATHS="input,output,metadata,messages.*.content"
export PII_EXCLUDE_PATHS="metadata.trace_id,usage"
export PII_EXCLUDE_LABELS="ORGANIZATION_NAME"
Everything reads the same PII_* environment variables:
export PII_MODEL_ID=bardsai/eu-pii-anonimization-multilang # default
export PII_MODEL_CACHE_DIR=/path/to/model-cache # optional
export PII_THRESHOLD=0.5 # min confidence for a span to count as PII
# gateway only:
export PII_POLICY=monitor # "monitor" (default): detect + log, forward prompt unchanged
# (real PII reaches the upstream); "pseudonymize": replace
# PII with [TYPE_N] placeholders before forwarding, then rehydrate
export PII_POLICY_OVERRIDES='{"SECRET_*": "block"}'
# per-entity-type actions layered over PII_POLICY (JSON map).
# keys: entity labels or prefix wildcards (SECRET_*);
# actions: keep | redact (one-way [REDACTED]) | pseudonymize |
# block (refuse the request with 403 before anything is sent).
# Overrides apply in both modes — the example above stops
# credentials cold even while everything else is only monitored.
export PII_HOST=127.0.0.1
export PII_PORT=8787
export PII_DB_PATH=~/.privacy-kit/audit.sqlite # default; created 0600, reused across working dirs
export PII_SAVE_TEXTS=all # save request texts: "all" (default) or "anonymized"
export PII_ANTHROPIC_UPSTREAM=https://api.anthropic.com
export PII_OPENAI_UPSTREAM=https://api.openai.com
export PII_CHATGPT_UPSTREAM=https://chatgpt.com/backend-api # Codex subscription mode upstream
export PII_OTEL_DOWNSTREAM= # optional collector for scrubbed telemetry
export PII_CLAUDE_ROOT= # history import: Claude Code sessions dir
# (default $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects)
export PII_CODEX_ROOT= # history import: Codex sessions dir
# (default $CODEX_HOME/sessions, else ~/.codex/sessions)
The model is not bundled in the package. It is downloaded on first use and reused from cache after that.
| Target | Status | API |
|---|---|---|
| Langfuse SDK | Ready | Langfuse(mask=make_mask()) |
| LangChain / LangGraph | Ready | make_langfuse_callback() with config={"callbacks": [...]} |
| Claude Code | Ready | gateway: privacy-kit setup claude-code |
| Codex (API key) | Ready | gateway: privacy-kit setup codex --apply |
| Codex (ChatGPT subscription) | Experimental | gateway: privacy-kit setup codex --apply |
| Cursor (chat panel) | Ready | gateway base URL: privacy-kit setup cursor (pseudonymizes) |
| Cursor (Composer / agent) | Ready | hooks: privacy-kit setup cursor --apply (audit; block via PII_CURSOR_BLOCK=1) |
| OpenTelemetry logs | Ready | gateway OTLP sink (OTEL_EXPORTER_OTLP_PROTOCOL=http/json) |
| Pydantic AI | Coming soon | Planned integration example |
| OpenAI SDK | Coming soon | Planned in-process wrapper on Vault |
Runnable examples live in examples/. They are kept in the
repository for copy-paste onboarding and are not installed as runtime package
modules.
privacy-kit runs on your machine; the model runs locally in-process.
For Langfuse/LangChain (observability) use cases:
For the gateway use case, what reaches the provider depends on PII_POLICY. In
the default monitor mode the prompt is forwarded unchanged (raw values
reach the provider) while the detection is logged locally. Under
PII_POLICY=pseudonymize the guarantee is stronger: the LLM provider itself
never sees the raw values — only placeholders. Either way, the local audit store
keeps entity counts plus the request texts selected by PII_SAVE_TEXTS (original
and anonymized, in plaintext on your machine).
privacy-kit also keeps the original PiiModel API for label-preserving
anonymization and direct entity extraction.
Install the optional Transformers backend:
pip install 'privacy-kit[transformers]'
Use PiiModel:
from privacy_kit import PiiModel
model = PiiModel().from_pretrained("bardsai/eu-pii-anonimization-multilang")
text = "Anna Wiśniewska mieszka na ul. Piękna 22, 00-549 Warszawa."
model.anonymize(text)
# "[PERSON_NAME] mieszka na [LOCATION]."
model.anonymize(text, mode="ids")
# {
# "anonymized_text": "[PERSON_NAME:1] mieszka na [LOCATION:1].",
# "entities": {
# "PERSON_NAME:1": "Anna Wiśniewska",
# "LOCATION:1": "ul. Piękna 22, 00-549 Warszawa",
# },
# }
model.extract_pii(text)
# {
# "Anna Wiśniewska": "PERSON_NAME",
# "ul. Piękna 22, 00-549 Warszawa": "LOCATION",
# }
Use this lower-level API when you want entity labels such as [PERSON_NAME] or
extracted PII mappings. The integration APIs use [REDACTED] by default because
observability systems usually should not store the PII type either. For
reversible, indexed placeholders prefer the Vault API above — it is idempotent
and offset-exact.
The underlying model recognizes EU-relevant PII categories including:
PERSON_NAME, EMAIL_ADDRESS, PHONE_NUMBER, LOCATION,
FINANCIAL_AMOUNT, PERSON_IDENTIFIER, ORGANIZATION_IDENTIFIER,
PROPER_NAME, RELIGION_OR_BELIEF, TRADE_UNION_MEMBERSHIP, and more.
An NER model has no label for credentials — and AI coding tools send your
tools' file reads (including .env files, configs, and keys) to cloud LLMs
with no client-side filtering. So alongside the model, privacy-kit always runs
a deterministic layer: regex + checksum detectors that need no model, no
network, and microseconds per call.
SECRET_): AWS/GCP/GitHub/GitLab/OpenAI/
Anthropic/Hugging Face/Slack/Stripe/SendGrid/npm/PyPI keys and tokens, JWTs,
PEM private-key blocks, credentials in connection URLs (only the password is
masked), Authorization: header values, and generic API_KEY=…-style
assignments gated by a Shannon-entropy floor. Patterns are vendored from the
gitleaks ruleset (MIT) as a curated,
tested in-tree table (make sync-secret-rules diffs against upstream).PAYMENT_CARD,
BANK_ACCOUNT_IDENTIFIER; international +… phone numbers →
PHONE_NUMBER; dashed US SSNs → PERSON_IDENTIFIER.Where a deterministic span overlaps a model span, the secret wins — a
credential never survives because a probabilistic span happened to cover it.
Detector backends: local (default — model + deterministic layer), model
(NER only), regex (deterministic only: instant startup, no download), null.
make install # uv sync with the gateway extra
make check # ruff + mypy --strict + pytest — must be green before commit
make test-model # tests that download and run the real model
make serve # run the gateway locally
A privacy invariant is enforced by lint and tests: code under src/ must
never print or log the text it processes (ruff T20 + the log-safety test
suite), and raw text lives only in the audit store's dedicated texts table
(scoped by PII_SAVE_TEXTS) — never in any other table, log, or output.
Apache-2.0