pii-proxy
pii-proxy replaces personal data with stand-ins before a model reads it. A name becomes another name, an email address becomes another email address, and an employee code keeps its shape with new digits. The same person gets the same stand-in every time, and every stand-in can be reversed.
It is MIT licensed and written in TypeScript for Node.js and Bun. The source is on GitHub, and each release is published to npm from there with provenance, so a version on npm can be traced to the commit that built it.
npm install pii-proxy
The map turns a stand-in back into the real value
import { PrivacyProxy } from 'pii-proxy';
const proxy = new PrivacyProxy();
const { text } = await proxy.mask(
'Send the signed contract to [email protected].',
);
// text: 'Send the signed contract to [email protected].'
// The model works on `text`, and its reply names the stand-in:
proxy.unmask('Done. I emailed [email protected].');
// → 'Done. I emailed [email protected].'
Every real value and its stand-in sit in one map that works in both directions, so a stand-in leads back to exactly one value, and no stand-in is ever one of the real values the proxy has seen. Save the map with proxy.getMap().serialize(), load it into the next run with proxy.loadMap(), and a run next month gives the same people the same stand-ins.
Each stand-in has the shape of the value it replaces
| Value | Stand-in |
|---|---|
| Name | Another name. When a title such as Mr or Mrs comes with it, the stand-in follows the title. |
| Email address | An address at example.com, example.net or example.org, domains reserved so that mail to them reaches nobody |
| Phone number | Another phone number |
| National ID, passport or license number | The same pattern of letters and digits, with new ones |
| Employee code | Its letters and, while that year has room, its first two digits, often a hire year; then new digits |
| Date of birth | The same year on a different day, so ages and age bands still come out right |
| Card number, IP address, tracking number | Another value of the same kind |
Packs add a language or a line of work
Without packs, pii-proxy reads English. A pack plugs in with use, and packs combine:
import { PrivacyProxy } from 'pii-proxy';
import { thai } from 'pii-proxy/packs/th';
import { hr } from 'pii-proxy/packs/hr';
const proxy = new PrivacyProxy({ use: [thai(), hr()] });
A locale pack brings the words of a language. pii-proxy/packs/th gives a Thai name a Thai stand-in and a name in Latin script an English one, reads titles such as นาย and นางสาว to pick the stand-in's sex, finds 13-digit national ID numbers by their check digit and makes stand-ins that pass it, and knows column headers such as ชื่อ, นามสกุล and รหัสพนักงาน.
A domain pack brings the rules of a line of work. pii-proxy/packs/hr knows which columns of an HR file name a person: employee codes, manager names, and whoever prepared or approved something. It withholds addresses and bank account numbers instead of inventing new ones, as [address withheld #3], because a plausible fake account number is one more thing someone might act on.
A pack is a function that returns detectors, stand-in generators, column headers and titles. The two in the repository show the shape.
Spreadsheets are masked column by column
pii-proxy/files reads .xlsx workbooks, CSV and text files. Files that belong together go in one call:
import { maskFiles } from 'pii-proxy/files';
const { results } = await maskFiles([{ name: 'staff.csv', bytes }], proxy);
staff.csv:
EmployeeId,Name,Manager Name,Department,BirthDate
EM210417,Dana Whitfield,,Operations,1984-03-12
EM220093,Tomas Rey,Dana Whitfield,Operations,1991-11-02
EM230388,Priya Nair,Dana Whitfield,Operations,1996-07-25
staff (masked).csv:
EmployeeId,Name,Manager Name,Department,BirthDate
EM211640,Mac Hegmann,,Operations,1984-02-03
EM223015,Otho Klein,Mac Hegmann,Operations,1991-08-24
EM234513,Chandler McCullough,Mac Hegmann,Operations,1996-08-01
Dana Whitfield is Mac Hegmann on her own row and on the rows of the two people who report to her, so the reporting line is still there in the masked copy. Department is untouched, like every column that names nobody, so a total worked out from the masked copy matches the original.
A column's type comes from its header: Name, FirstName, Surname, Email, Phone, and whatever headers the packs add (EmployeeId and Manager Name here come from the HR pack). When the header says nothing, the values decide, and a column that is mostly email addresses, phone numbers or national IDs is masked as one. Each result lists the columns it masked by header, so you can see what was touched. A workbook comes back as one CSV per sheet, and a person keeps one stand-in across every file in the call.
Database rows come back in the shape they went in
maskRecords takes the rows a database driver or an API returns and gives them back with the same keys. Personal columns are masked, and every other cell keeps its value and its type: a number stays a number, a date stays a date.
import { maskRecords } from 'pii-proxy/files';
const { records } = await maskRecords([
{ shift: 101, day: '2026-10-19', on_call: 'Dana Whitfield', hours: 8 },
{ shift: 102, day: '2026-10-19', on_call: 'Tomas Rey', hours: 8 },
], proxy);
// [{ shift: 101, day: '2026-10-19', on_call: 'Mac Hegmann', hours: 8 },
// { shift: 102, day: '2026-10-19', on_call: 'Otho Klein', hours: 8 }]
No header rule knows on_call. The names are caught because the map already holds them from the staff file, and they get the stand-ins they got there.
In running text, names are found through the map or a model
Patterns find email addresses, phone numbers, card numbers, IP addresses, and ID numbers written next to a word like passport. A name in a sentence has no pattern, so pii-proxy finds it one of two ways.
The first is the map. maskText looks for the whole names and codes the map already holds, so a name masked in the staff file is caught in a note about the same people:
import { maskText } from 'pii-proxy/files';
const { text } = await maskText(
'Tomas Rey swapped his Monday shift with Priya Nair.',
proxy,
);
// text: 'Otho Klein swapped his Monday shift with Chandler McCullough.'
The second is a model. PrivacyProxy.withLocalLlm() adds a detector that asks a model for names, organizations, places and dates of birth. It calls a model on your own machine through Ollama, or any OpenAI-compatible endpoint you point it at. Any object with a detect(text) method can be a detector too, and where two detectors claim the same text, the one listed first wins.
Daslab masks a scene's files and live queries with one map
Every workflow becomes a scene: its data, its tools, the agent that runs it, and the history of everything it did. A scene can hold a PII masker, and the jobs in that scene get a pii_mask tool. The job passes links to files, the server reads and masks them, and the job gets back links to the masked copies with counts of the people and values masked. The agent is told to hand files that hold personal data to the masker instead of opening them, and to work from the copies.
Rows from a SQL Server connection in the same scene pass through the same masker before the model, the run's trace or a formula sees them, so a person has the same stand-in in a live query as in a masked file. A query that names someone by their stand-in reaches the database with the real name in its place. If masking fails, the query returns no rows.
The map is stored encrypted, and no job, view or API call reads it back, so nothing in Daslab turns a stand-in back into a name. The masker shows how many files it has masked, how many stand-ins it holds and when it last ran. Every masker has its own map, and removing one means files masked afterwards get new stand-ins.
What pii-proxy leaves to you
- Finding every value. Patterns find the shapes they know, and a name in running text needs the map or a model. Look over a masked copy and its list of masked columns before you rely on it.
- Keeping the map safe. Whoever holds the map can turn every stand-in back. pii-proxy does not encrypt it, so store it the way you store the data it came from.
- What the model reads into a stand-in. The model reasons about the stand-in. A stand-in name can suggest a different language or sex, people who share a surname in the text may not share one after masking, and a name the model makes up is not in the map, so unmask leaves it as written.
- PDFs and images.
pii-proxy/filesreads workbooks, CSV and text. Anything else is skipped, with the reason in the result.
Updated 2026-10-17