Research LabAlpha
Open Pseudonym
Task-aware pseudonymisation for LLM traffic. Detected entities become consistent, realistic stand-ins; secrets become handles bound to the host they came from; every request can carry an Ed25519-signed, hash-chained egress receipt.
- 19data types
- 10actions
- 11starter profiles
- 88 / 88shared vectors, Python and TypeScript
- ~1 msto mask an item (offline bench)
The masking pipeline.
Masking runs on your side before any byte leaves; restoring runs on your side after the answer arrives. The vault that links the two stays with you.
To the model
- 1Inputtext, chat messages, tool resultsJSON is masked string by string
- 2Detectpatterns.json, known values, MLone regex subset for Python and JS
- 3Mergepriority, length, start, typeoverlaps resolved once
- 4Policyprofile: an action per typetighten() only ever stricter
- 5Stand-insHMAC-SHA256 DRBG, seeded.example domains, 555-01xx, test cards
- 6Egress checkexact bytes, then detect againa leak stops the request
- 7ReceiptJWS EdDSA over JCSprev = digest of the last one
opn-vault-v1. The key never sits next to the file.Back to you
- r1Modelsees stand-ins onlynotes explain each handle
- r2Restoreexact, handles, digits, datesstreaming, window L of 40 or more
- r3Resolvehandles to real valuesbound site or tool, uses, ttl, ask
- r4Youreal values backfilled at the last moment
A real run, five profiles.
One message, five starter profiles. Everything below is the SDK's own output, not a mock-up. Pick a profile.
The message
From: Dana Reyes <dana.reyes@northwind.example> Hi Sam, can we move Thursday's call to March 12, 2026? Call me on (617) 555-0142. The deposit is $1,250.00 on card 4242 4242 4242 4242. Your code is 482913.
What the model sees under draft_reply
From: Rowan Sato <rowan.sato@seaborne.example> Hi Hayden, can we move Thursday's call to November 20, 2025? Call me on (206) 555-0193. The deposit is $1,250.00 on card {{CARD_1}}. Your code is {{OTP_1}}.
| Type | Action | Real value | Stand-in |
|---|---|---|---|
PERSON | fake | Dana Reyes | Rowan Sato |
EMAIL | fake | dana.reyes@northwind.example | rowan.sato@seaborne.example |
PERSON | fake | Sam | Hayden |
DATE | shift | March 12, 2026 | November 20, 2025 |
PHONE | fake | (617) 555-0142 | (206) 555-0193 |
CARD | handle | 4242 4242 4242 4242 | {{CARD_1}} |
OTP | handle | 482913 | {{OTP_1}} |
Notes appended for the model
{{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.{{OTP_1}} is a 6-digit one-time code from northwind.example, valid until 00:10 UTC. Write {{OTP_1}} exactly where the value is needed; the real value is filled in outside the model.
What the model sees under schedule
From: Rowan Sato <rowan.sato@seaborne.example> Hi Hayden, can we move Thursday's call to March 12, 2026? Call me on (206) 555-0193. The deposit is $1,250.00 on card {{CARD_1}}. Your code is {{OTP_1}}.
| Type | Action | Real value | Stand-in |
|---|---|---|---|
PERSON | fake | Dana Reyes | Rowan Sato |
EMAIL | fake | dana.reyes@northwind.example | rowan.sato@seaborne.example |
PERSON | fake | Sam | Hayden |
PHONE | fake | (617) 555-0142 | (206) 555-0193 |
CARD | handle | 4242 4242 4242 4242 | {{CARD_1}} |
OTP | handle | 482913 | {{OTP_1}} |
Notes appended for the model
{{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.{{OTP_1}} is a 6-digit one-time code from northwind.example, valid until 00:10 UTC. Write {{OTP_1}} exactly where the value is needed; the real value is filled in outside the model.
What the model sees under web_research
From: Rowan Sato <[EMAIL removed]> Hi Hayden, can we move Thursday's call to March 12, 2026? Call me on [PHONE removed]. The deposit is $1,250.00 on card [CARD removed]. Your code is [OTP removed].
| Type | Action | Real value | Stand-in |
|---|---|---|---|
PERSON | fake | Dana Reyes | Rowan Sato |
EMAIL | drop | dana.reyes@northwind.example | [EMAIL removed] |
PERSON | fake | Sam | Hayden |
PHONE | drop | (617) 555-0142 | [PHONE removed] |
CARD | drop | 4242 4242 4242 4242 | [CARD removed] |
OTP | drop | 482913 | [OTP removed] |
Notes appended for the model
- No handles under this profile: nothing for the model to be told.
What the model sees under money_math
From: Rowan Sato <rowan.sato@seaborne.example> Hi Hayden, can we move Thursday's call to November 20, 2025? Call me on (206) 555-0193. The deposit is $1,250.00 on card {{CARD_1}}. Your code is [OTP removed].
| Type | Action | Real value | Stand-in |
|---|---|---|---|
PERSON | fake | Dana Reyes | Rowan Sato |
EMAIL | fake | dana.reyes@northwind.example | rowan.sato@seaborne.example |
PERSON | fake | Sam | Hayden |
DATE | shift | March 12, 2026 | November 20, 2025 |
PHONE | fake | (617) 555-0142 | (206) 555-0193 |
CARD | handle | 4242 4242 4242 4242 | {{CARD_1}} |
OTP | drop | 482913 | [OTP removed] |
Notes appended for the model
{{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.
What the model sees under strict
From: Rowan Sato <rowan.sato@seaborne.example> Hi Hayden, can we move Thursday's call to November 20, 2025? Call me on (206) 555-0193. The deposit is $1,250.00 on card {{CARD_1}}. Your code is {{OTP_1}}.
| Type | Action | Real value | Stand-in |
|---|---|---|---|
PERSON | fake | Dana Reyes | Rowan Sato |
EMAIL | fake | dana.reyes@northwind.example | rowan.sato@seaborne.example |
PERSON | fake | Sam | Hayden |
DATE | shift | March 12, 2026 | November 20, 2025 |
PHONE | fake | (617) 555-0142 | (206) 555-0193 |
CARD | handle | 4242 4242 4242 4242 | {{CARD_1}} |
OTP | handle | 482913 | {{OTP_1}} |
Notes appended for the model
{{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.{{OTP_1}} is a 6-digit one-time code from northwind.example, valid until 00:10 UTC. Write {{OTP_1}} exactly where the value is needed; the real value is filled in outside the model.
Back on your side
Sure Hayden, November 20, 2025 works. I'll call at 10.Sure Sam, March 12, 2026 works. I'll call at 10.Real output: open-pseudonym 0.1.1 (Python), vault seed sha256('atlasiw.com research lab example'), clock 1793836800 (2026-11-04 00:00 UTC), source no-reply@northwind.example.
Policy parameters.
Actions and their strength
A policy maps each data type to one action. tighten(a, b) keeps, per type, the stronger rule, so code can only make a policy stricter; nothing takes a policy from model output.
| Action | Strength | Restorable | Stand-in |
|---|---|---|---|
keep | 0 | nothing replaced | the real value |
shift | 1 | yes | the date moved by the session offset (dates only) |
generalize | 1 | no | a coarser description (“someone”, “March 2026”) |
fake | 2 | yes | a realistic value of the same type |
tag | 2 | yes | [TYPE_n] |
token | 2 | yes | type_ plus 8 hex digits of a keyed hash |
fpe | 2 | yes | FF1 ciphertext of the digits (digit types only) |
handle | 3 | only by resolve | {{TYPE_n}} |
drop | 4 | no | [TYPE removed] |
refuse | 5 | no | the whole request is refused |
Data types
Compared by a normalised key per type (digits only, lower case, the calendar day). Secret types, marked, become handles in every starter profile.
PERSONORGLOCATIONADDRESSEMAILPHONEURLIP_ADDRESSDATEMONEYCARDIBANROUTINGBANK_ACCOUNTSSNAPI_KEYPASSWORDOTPID
Handle bindings
Options on a handle rule: uses, ttl_s, ask, tools, sites (*.example.com allows subdomains; a public suffix is an error). A handle with neither a site nor a tool is never filled. resolve(args, sink) refuses with:
unknown handlenot bound to a site or toolexpiredalready usednot allowed for this toolbound to a different siteneeds the person's approvalnot approved
Deterministic stand-ins
Every choice comes from the vault seed (at least 16 bytes), so the Python and TypeScript SDKs produce the same stand-ins for the same seed and clock. Dates shift by whole weeks so weekdays stay true:
block(label) = HMAC-SHA256(seed, utf8(label) || 0x00 || uint64_be(counter[label])); counter[label] += 1
below(label, n): repeat { x = uint64_be(first 8 bytes of block(label)) } until x < floor(2^64 / n) * n; return x mod n
derive(purpose) = HMAC-SHA256(seed, "opn-derive" || 0x00 || utf8(purpose))
w = below("date.offset", 52) - 26; if w >= 0 then w += 1; offset = 7 * w daysProof of what left.
Before a request goes, its exact bytes are checked for every real value the vault knows, then the detectors run again over every key and string. With receipts on, each request that goes gets a signed, chained receipt.
{
"alg": "EdDSA",
"kid": "sha-256:zskEnjox_Y8weyrGjrEXLeYEdh3ecIohpYhPysG3wCU",
"typ": "opn-egress+jws"
}
{
"check": {
"found": 0,
"known_values": 7
},
"dest": "api.example.com",
"detectors": [
"rules@1",
"known@1"
],
"iat": 1793836800,
"opn": "0.1",
"payload": "sha-256:vRvxgmEcY-dwgWeqWbMryYhYerCYO6eRBlwP81unDPs",
"policy": "sha-256:3pyGzM1KRJVM1phUKj79n8eCEabjJFBVFiQs8TOba_8",
"prev": null,
"seq": 1,
"session": "example",
"stand_ins": {
"CARD": 1,
"DATE": 1,
"EMAIL": 1,
"OTP": 1,
"PERSON": 2,
"PHONE": 1
},
"type": "egress"
}Fields (spec 11.2)
kid- sha-256 thumbprint of the Ed25519 public key (RFC 7638)
payload- digest of the exact bytes sent
policy- digest of the canonical policy document
prev- digest of the previous receipt's token; null for the first
seq- counts 1, 2, 3 in one log; a gap fails verification
stand_ins- distinct vault entries whose stand-ins appear in the payload
check- known_values: non-keep vault entries at the time; found must be 0 for a request that was sent
opn verify ~/.local/share/open-pseudonym/receipts/<session> --vault my.vault
# receipts: 14
# payloads re-checked against the vault: 14, known values found: 0
# every receipt is signed, chained and matches its payloadWhat this proves: no value the vault knows, and nothing the detectors find in the outgoing bytes, was in any request with a valid receipt. It cannot prove that a value no detector recognises did not leave, and a log cut short at the end shows only against a count or last receipt you kept (--expect-count, --expect-last).
Offline benchmark.
| Offline benchmark, 120 generated items and 14 hand-written | Result |
|---|---|
| Cards, IBANs, SSNs, emails, phones, dates, amounts, addresses, codes | 100% found on the generated set |
| Names, rules only | 100% on the generated set, 86% on the hand-written set |
| Names the app already knows, in any case | caught again everywhere, including “dana reyes” |
| Planted values that reached the model (strict profile) | 0 of 570 generated, 1 of 25 hand-written |
| Same, with the app's contact list | 0 of 570 and 0 of 25 |
| Restore gives back exactly the original | 134 of 134 |
| Streaming restore equals whole restore | 134 of 134 |
| Speed | about 1 ms to mask and 0.2 ms to restore an item |
The generated set uses templates the name rules were tuned on, so its 100% is optimistic; the hand-written set is the honest one, and it is small. Run it yourself: python bench/run.py offline (no model, costs nothing).
Install Open Pseudonym.
- Python
pip install open-pseudonym - JavaScript / TypeScript
npm install open-pseudonym
Use it in your code
Mask before the model, restore after.
from open_pseudonym import Session, Source
s = Session("draft_reply")
masked = s.mask(email_text, Source(sender="no-reply@acme.com")) # the source binds handles to acme.com
reply = call_your_model(masked.text, masked.notes) # notes explain any {{handles}}
print(s.restore(reply)) # real values back for the personimport { Session, Source, Sink } from "open-pseudonym";
const session = new Session("draft_reply");
// What the model may see. The source binds any secret to the site it came from.
const masked = session.mask(emailText, new Source({ sender: "no-reply@acme.com" }));
const answer = await callYourModel(masked.text, masked.notes); // notes explain any {{handles}}
// What the person reads.
console.log(session.restore(answer));Or change one URL
The local proxy speaks the OpenAI Chat Completions, OpenAI Responses and Anthropic Messages APIs, streamed or not, and passes your API key through untouched. It has no login: one person, one machine, 127.0.0.1.
opn keygen
opn proxy --profile draft_reply --log-dir ~/.local/share/open-pseudonym/receipts
# then use base_url http://127.0.0.1:8787/v1 (OpenAI style) or http://127.0.0.1:8787 (Anthropic style)Agents and MCP
Put any MCP server behind the gateway with no code changes: tool results are masked on the way to the model, tool arguments restored on the way to the tool.
opn mcp-gateway --profile browse_and_fill --source-site example.com \
--tool-site submit_code=https://login.example.com -- npx some-mcp-serverThe specification.
Open Pseudonym v0.1, CC BY 4.0. Pseudonymisation in the GDPR Article 4(5) sense, not anonymisation: what reaches the model is still personal data in law.
- 1What this is, and what it is not (opens in a new tab)
- 3Data types (opens in a new tab)
- 4Detection (opens in a new tab)
- 5Policies (opens in a new tab)
- 6Masking (opens in a new tab)
- 7Making stand-ins (opens in a new tab)
- 8The vault (opens in a new tab)
- 9Restoring (opens in a new tab)
- 10Secret handles (opens in a new tab)
- 11Egress check and receipts (opens in a new tab)
- 12Bindings (opens in a new tab)
- 15Conformance (opens in a new tab)
Known limits (alpha 0.1)
- Names are found by cues (greetings, titles, headers, role words) or because your app lists them; a name with no cue in free text can be missed.
- Non-Latin scripts and lookalike letters are not handled by the rules.
- Binary files (images, PDFs, audio) are passed through untouched; text sent as base64 is decoded, masked and checked.
- The proxy checks the body and the query string, not request headers.
- Ordinary personal data is restored into any tool call; handles are the part that resists prompt injection.
- Model-based quality results are not published yet.
Changelog.
Every release, newest first.
GitHub (opens in a new tab)PyPI (opens in a new tab)npm (opens in a new tab)Specification (opens in a new tab)Test vectors (opens in a new tab)Full changelog (opens in a new tab)
| Version | Date | What changed |
|---|---|---|
| 0.1.1 | Nov 6, 2026 | First release through npm trusted publishing. Install badges and package links in the README. No code changes. |
| 0.1.0 | Nov 6, 2026 | First release: the specification (v0.1), the Python SDK (core, local proxy, MCP middleware and gateway, Playwright handle filler, ML detector adapters, the opn command), the TypeScript SDK (core and Vercel AI SDK middleware), shared test vectors, JSON schemas and an offline benchmark. Hardened after two security reviews. |
Built while making Atlas.
Atlas is the AI workspace these tools come from. Free during the private beta.