Anonymization pipeline for sensitive text. Deterministic, local-first, fast.
Website · Issues · npm · PyPI · Discord
A single Rust core does the PII detection, resolution, and replacement; thin Node.js, browser (WebAssembly), Python, and CLI bindings translate types and call into it. There is no model server and no network dependency: the same document produces the same redaction on every run and in every runtime. It is built for legal documents (contracts, filings, correspondence) across a dozen languages, and it is open source under the Apache-2.0 license.
npm install @stll/anonymizestill resolves to the stable 1.x line until 2.0.0 is promoted to
latest.
import { getDefaultNativePipeline, deanonymise } from "@stll/anonymize";
const anonymize = getDefaultNativePipeline({ language: "en" });
const { redaction } = anonymize.redactText(
"Contact Jan Novák at jan.novak@example.com.",
);
console.log(redaction.redactedText);
// Contact [PERSON_1] at [EMAIL_ADDRESS_1].
console.log(deanonymise(redaction.redactedText, redaction.redactionMap));
// Contact Jan Novák at jan.novak@example.com.Create the pipeline once at startup and reuse it; it caches the prepared
package and search automata. If you know the document language, pass it so the
runtime loads the smaller scoped artifact — scoped packages ship for cs,
de, and en (other supported languages use the full default package;
requesting a scoped package that is not bundled fails at load).
npm install @stll/anonymize-wasmThe wasm build exposes the same SDK surface, backed by WebAssembly. Register
the Vite plugin so the wasm binary, WASI worker, and .stlanonpkg assets
survive dependency pre-bundling and production builds. The packages option
controls which prepared packages ship (the full-dictionary default is ~20 MB,
so most apps restrict it):
// vite.config.ts
import stllAnonymizeWasm from "@stll/anonymize-wasm/vite";
export default {
// Emit only the packages the app loads, e.g. English + Czech.
plugins: [stllAnonymizeWasm({ packages: ["en", "cs"] })],
};import { loadDefaultPipeline } from "@stll/anonymize-wasm";
const pipeline = await loadDefaultPipeline("en");
const { redaction } = pipeline.redactText("A contract signed by Jan Novák.");
console.log(redaction.redactedText);The binding targets wasm32-wasip1-threads (shared memory), so it needs a
cross-origin-isolated (SharedArrayBuffer) context. See
packages/anonymize/wasm/README.md for the
full packages option reference.
Prebuilt wheels (available from the 2.0.0 release) bundle the native pipeline packages, so no monorepo checkout is required.
uv add stella-anonymize-core
# or: pip install stella-anonymize-coreimport stella_anonymize as anonymize
prepared = anonymize.preload_default_native_pipeline(language="en")
result = prepared.redact_text("Contact Jan Novák at jan.novak@example.com.")
print(result.redaction.redacted_text)
# Contact [PERSON_1] at [EMAIL_ADDRESS_1].The Python SDK uses the same Rust core and prepared-package contract as the
Node SDK. See crates/anonymize-py/README.md.
No install needed:
echo "Contact Jan Novák at jan.novak@example.com" | bunx @stll/anonymize-cli
# Contact [PERSON_1] at [EMAIL_ADDRESS_1]Batch a directory in reversible replace mode, then selectively restore one
entity from the redaction key:
# Redact a document, writing the reversible key alongside it.
anonymize -k contract.key.json -o contract.anon.txt contract.txt
# Restore only the person; every other placeholder stays redacted.
anonymize -d contract.key.json --revert "[PERSON_1]" contract.anon.txt
# Recursively anonymize a tree, 8 files in flight, mirroring into out/.
anonymize --recursive --workers 8 -o out/ docs/--revert is repeatable and matches either a placeholder token ([PERSON_1])
or an original value (Jan Novák), case-sensitive and exact. All processing is
local; the CLI makes no network calls. Run anonymize --help for the full
reference, including the --json schema and exit codes.
- 22 default entity labels, plus 3 opt-in network labels. People,
organizations, addresses, countries, and land parcels; email, phone, dates, and
dates of birth; and a family of identifiers: IBAN and bank account numbers, tax
and national identification numbers, identity card, birth, social security,
passport, and registration numbers, credit card numbers, crypto addresses, and
monetary amounts. IP addresses, MAC addresses, and URLs are built in but opt-in.
The versioned machine-readable contract is exported as
CAPABILITY_MANIFESTfrom@stll/anonymize/capabilitiesand printed byanonymize --capabilities; scope detection to a subset with--labels. - 12 languages, multi-script name corpora. Built-in coverage for cs, de, en, es, fr, hu, it, pl, pt-br, ro, sk, and sv, backed by name corpora that reach beyond Latin script (CJK, Arabic, Thai, Korean, and romanized variants).
- Deterministic numbered placeholders with coreference linking. Each entity
gets a stable
[LABEL_N]placeholder; repeated and coreferent mentions (for example a defined term and its later short form) collapse to the same number, so the same input always yields the same output. - Reversible keys and selective revert.
replacemode emits a self-describing redaction key; deanonymisation restores the original text, and the CLI can revert a chosen subset while leaving the rest redacted. - Extensible detection. Layer in your own exact-match deny lists, gazetteer entries, and deterministic custom regexes; caller-owned data is baked into the prepared package.
- Streaming and diagnostics APIs. Beyond
redactText, the SDK exposes JSON, streaming, and per-entity diagnostics variants for pipelines that need spans, scores, and detection provenance. - Offline CLI. Reads files or stdin, processes directories in parallel, and never makes a network call.
@stll/anonymize compared against three open-source PII libraries on a public,
synthetic, legal-domain corpus (en/cs/de: 28 documents, 196 gold entities).
Matching is span-overlap (same label, IoU >= 0.5). Full numbers, per-label
tables, and methodology live in
packages/benchmark; this run is from
packages/benchmark/results/latest.md.
| Library | Version | F1 (overlap, all labels) | Throughput (warm, chars/s) |
|---|---|---|---|
| stella | 2.0.0 | 83.4 | 907,102 |
| presidio | 2.2.360 | 50.9 | 41,244 |
| redact-pii | 3.4.0 | 31.6 | 55,823 |
| scrubadub | 2.0.1 | 26.2 | 1,563,700 |
Per-language overlap F1 (all labels):
| Library | cs | de | en |
|---|---|---|---|
| stella | 77.2 | 81.6 | 90.5 |
| presidio | 35.0 | 54.7 | 60.5 |
| redact-pii | 24.8 | 34.8 | 34.8 |
| scrubadub | 20.3 | 31.7 | 27.6 |
Read these numbers with their caveats:
- This is one fixture set. The corpus is legal-domain and multilingual (en/cs/de), which is exactly what stella is built for and skews the comparison toward it. scrubadub and redact-pii are English-only; their cs/de scores are expected to be low and are reported as-is. The claims here are scoped to this benchmark, not to PII redaction in general.
- Synthetic ground truth. All text is public-safe synthetic legal prose with positionally-authored spans, so absolute numbers may differ from production filings. Every library sees identical inputs.
- Competitors win in places. Presidio leads on organizations (56.3 vs. 51.0 F1) and on phone recall (84.6% vs. 46.2%); scrubadub and redact-pii edge email recall. Every label is reported, including where stella loses.
To try your own documents, packages/benchmark supports an --input mode; see
REPRODUCING.md for the exact toolchain,
library versions, and taxonomy-mapping decisions.
One Rust core (crates/anonymize-core) owns detection, resolution, and config
assembly. The Node.js, browser, and Python bindings are thin: they load a
prepared package, translate types, and call the same core, so they produce
identical structured output. That equivalence is enforced by cross-runtime
parity tests in CI (python-parity.test.ts, the native SDK contract tests, and
Rust adapter parity examples).
Dictionaries and language data are baked into .stlanonpkg prepared packages at
build time, not loaded from the network at runtime. Native Node binaries ship as
per-platform prebuilt sidecars (for example @stll/anonymize-darwin-arm64),
resolved as optional dependencies at install time. Full package graph, runtime
flow, and extension rules are in
packages/anonymize/ARCHITECTURE.md.
- Deterministic output. The same input yields the same redaction on every run; detection is rule-driven with fixed priorities, not sampled.
- Cross-runtime parity is tested. CI asserts the Node and Python SDKs return the same structured result from the same fixtures.
- Data stays local. The CLI makes no network calls (stated in its
--help); the SDKs load prepared packages from disk or bundled assets, and the browser build loads only the packages you bundle. No document text leaves the process. - No text in logs. Diagnostics and summaries carry entity counts, labels, spans, and scores, not detected text; raw input is kept out of logs and snapshots.
Current release line: 2.0.0. In 2.0 the product runtime moved from the in-process TypeScript pipeline to the Rust-native
SDK, and the package root now exports the native API (getDefaultNativePipeline
whose pipelines expose redactText, plus redact_text, deanonymise,
exportRedactionKey, and the prepared-package helpers). The 1.x
TypeScript pipeline has been removed entirely; the Rust core owns detection,
resolution, and configuration assembly across all runtimes. See
packages/anonymize/ARCHITECTURE.md and
packages/anonymize/CHANGELOG.md for the
package-level history.
| Package | Purpose |
|---|---|
@stll/anonymize |
Native runtime for multi-layer PII detection and anonymization |
@stll/anonymize-wasm |
Browser/WASM build of the runtime |
@stll/anonymize-cli |
Command-line anonymization (anonymize binary) |
@stll/anonymize-data |
Published deny-list dictionaries and trigger/config data |
stella-anonymize-core |
Python bindings for the Rust anonymization core |
crates/anonymize-core |
Rust anonymization core |
bun install --frozen-lockfile
bun run lint
bun run typecheck
bun run test
bun run buildLefthook config lives at lefthook.yml and is not
auto-installed. To enable local hooks (format on pre-commit, typecheck + format
check on pre-push):
bun run hooks:install
# bun run hooks:uninstall to remove- Pinned GitHub Actions workflows validate lint, typecheck, tests, and package tarballs before release.
- The data package tarball is checked so every exported dictionary path is present.
- Release publishing is gated behind manual workflow dispatch and provenance-enabled npm publish steps.
Contributions are welcome. Run bun run lint, bun run typecheck, and
bun run test before opening a PR; a CLA check runs on pull requests. Please
keep language data reproducible and out of source code, and do not commit raw
personal data or non-public fixtures.
Apache-2.0. See LICENSE. Third-party runtime attributions for the browser
build are listed in
packages/anonymize/wasm/README.md.
