Sensored
PII redaction that doesn't suck (mostly)
Leaked credentials in Cursor autocomplete and Claude threads aren’t rare anymore. Models even nag you to rotate keys. I wanted a TypeScript redactor with a familiar config, real evals, and fewer dumb false positives — so I built Sensored. I’m not biased here, I genuinely think the stack is solid 😉. You can skim features on the site; here I want to talk about why I built it, the approach, and a deeper dive on a few interesting technical bits.
Inspiration
A while back I became a core collaborator on openredaction. I would be remiss if I didn’t mention it here. The author of the project put in an incredible amount of thought into the project; framing it not only as a library, but as a platform you can plug into popular frameworks like Express, Fastify, Elysia, and more. Openredaction works by loading a bunch of regexes in memory when the library loads. It uses heuristics (i.e. pattern matching) to detect, scrub, and trace sensitive data.
While contributing was interesting, I couldn’t shake my platform mindset. I kept framing openredaction as a library in my head while the author wanted to push it more as a platform. So I thought about this problem a lot. I integrated openredaction at my day job and we saw a lot of false positives with PII redaction. I kept thinking about how I would make something different. Something with a familiar config. Something where users could easily add their own detectors. Something that had extensive eval testing so we know what works, and what absolutely does not.
Sensored
And so, sensored was born. For the entire project I used a combination of GLM 5.2/5.3/5.3-Flash and
opencode with mostly cursor, but sometimes cmux. I
wrote a sensible AGENTS.md file and
heavily relied on skills to keep my agents on the straight
and narrow.
The start
Once I had the scaffolding setup, I hand-wrote a Product Requirements Document (PRD), a wishlist of how I want this library to work.
Literally, I started with bullet points like:
- General purpose redaction library
- Needs to redact names
- Needs to redact phone numbers (US, Mexico, Canada, UK, EU, etc.)
- Needs to redact email addresses (ASCII/Unicode)
- Needs to redact credit card numbers
- Needs to redact social security numbers/national ids
and I iterated using the
grill-with-docs
skills until I had a solid v0. At this point there was no comparison to openredaction or other
open-source redaction libraries. I wanted a clean slate.
Once I nailed down a solid PRD, I used the
to-tickets skill
to create issues en-masse in Linear. From there, I prompted GLM to review all the tickets and write
a plan that leverages parallel subagents where appropriate (using the
implement
skill). After about an hour, I had a decent V0! Names, emails, phones, credit cards, and other kinds
of PII were getting redacted 🚀. Success.
Evals
V0 was great, but I needed proof that it works across a variety of use cases. You might have heard about evals when it comes to LLM training. Perhaps LLM-as-a-judge or human-in-the-loop. Since we’re dealing with deterministic results, I opted to use code-based matching. Easy peasy. So, I laid the groundwork for my agent to build out a very extensive eval suite.
- ASCII
- UTF-8/16
- Different Languages
- Mixing of languages
- and most importantly, non-trivial text (i.e. a realistic user prompt and LLM outputs)
so a corpus landed with on the order of ~48k tests. That gives a solid baseline for what actually works and what absolutely does not — representative coverage, not a peer-reviewed benchmark.
A better devX
One of my biggest beefs with a lot of FOSS libraries out there is their configs suck. As a tried and true JS/TS developer, I have these configs burned into my brain:
webpackrollupeslintbiomevite- somehow
gulpandgrunt - and probably more
You know who has a really nice devX for configuring? eslint v9 (don’t hate) and vite. eslint
has a nice preset/rule structure. vite has defineConfig which is type-safe and allows users
to define plugins in the config, via import, and so on. You could even stringify a vite config and
save in a blob store/DB if you had a more complicated config. Sensored takes the middle ground by
exporting a createRedactor for type-safe configuration with support for inline custom redactors as
well as eslint rule/preset style configuration.
interface RedactorConfig { presets?: readonly string[]; customPresets?: Readonly<Record<string, Readonly<Record<string, RuleSetting | "off">>>>; limits?: { maxInputLength?: number }; rules: Readonly<Record<string, RuleSetting | "off">>; detectors?: readonly DetectorDefinition[]; restore?: boolean; allowlist?: readonly string[]; semantic?: SemanticConfig;}No mega class. The main drawback of this approach is you can’t really override the config at the call site which certainly has benefits. Perhaps in the future version of sensored I’ll add that.
LLM Steering
PII shows up in a variety of ways and it’s not always as “write a regex”. Consider a US/Canada phone number. It is defined as:
- An optional country code
- An area code
- A 3-digit exchange code
- A 4-digit subscriber number
phoneNumber = [countryCode] [areaCode] [exchangeCode] [subscriberNumber]+1 (206) 555-0100206-555-0100206.555.01002065550100They’re all valid. Now imagine we’re calling the anthropic SDK using a hypothetical model id like
claude-haiku-4-5-2025100111. If we use a regex that matches 10 digits in a variety of formats,
we’re done for. claude-haiku-4-5-2025100111 becomes claude-haiku-4-5-[PHONE_NUMBER] and our LLM
call fails 🫠. What would help in a situation like this? Stable anchors we can use to indicate
that we have a phone number. Even if a LLM is processing a document, if we redact on the LLM output
we can let the LLM classify for us. This is the premise behind LLM steering.
With sensored, you can let a LLM do the heavy lifting:
import { createRedactor } from "sensored";
const redactor = createRedactor({ presets: ["pii"], rules: {}, // presets: ["pii"] already enables the built-ins});
const hints = redactor .describe() .filter((d) => d.contextHint) .map((d) => ({ id: d.id, labels: d.contextHint!.labels, position: d.contextHint!.position, instructions: d.contextHint!.instructions, }));
const systemPrompt = `You are a helpful assistant. When writing sensitive data,include one of these labels nearby so the redaction engine can detect it:
${JSON.stringify(hints, null, 2)}`;This next bit is intentionally technical. If you just want the stack punchline, skip to AI confirmation.
Detectors
This leads us to detectors. Sensored has five (5) primitives:
| Kind | Class | Job | Examples |
|---|---|---|---|
| Pattern-only | Detector | Regex (+ light filters). Shape alone enough. | email, aws_access_key, iban |
| Context-gated | ContextDetector | Regex candidate needs nearby label (“SSN”, “NHS”, …). Biggest bucket. | us_ssn, uk_nhs, passport |
| Checksum | ChecksumDetector | Regex → adjacency → mod/Luhn/validate(). No label required. | payment_card, ca_sin, vin |
| Keyword | KeywordDetector | Regex + keyword in ±40 chars (token-ish). | discord_bot_token, datadog_api_key |
| Custom | RegexDetector | User DetectorDefinition via registerDetector() |
The main one I want to discuss here is ContextDetector. Remember how we discussed LLM steering
above? ContextDetector is exactly why LLM steering is important. For a significant number of
detectors we literally cannot rely on a well-defined format; especially for alphanumeric strings.
That is the tradeoff. Context gating kills a lot of false positives — 12345678 alone is not an
Argentine DNI, a Kenyan ID, or a medical record number. But once you accept labels as the signal,
you inherit label collisions.
Each ContextDetector runs on its own: regex hit + nearby label → detection. There is no
cross-detector mutex. So a shared label plus a shared digit shape means multiple detectors claim the
same span:
DNI: 12345678→ ar_dni + pe_dni
National ID: 12345678→ ke_id, fj_id, ma_id, … (and friends)
Tax ID: 912701234→ us_ein + us_itinSensored groups overlapping spans and picks a single replacement so the output does not turn into a
stack of competing placeholders. Still: if you are steering an LLM to emit labels for safer
redaction, prefer specific anchors (Kenya, SSN, ITIN) over generic ones (National ID,
tax id, DNI). The engine can resolve collisions; your prompts should try not to create them.
Name detection
There are two unique detectors that are worth discussing:
PersonNameDetectorPersonNameLiteDetector
PersonNameDetector
PersonNameDetector extends Detector and leans on lightweight NLP / named-entity tagging via
compromise — think dictionary + POS tagging for
people, not a BERT-scale model. Tradeoff is performance. Using PersonNameDetector absolutely
crushes streaming or anything high-throughput. While certainly more robust, in some applications
it’s not worth the performance hit.
PersonNameLiteDetector
That is where PersonNameLiteDetector comes in. Under the hood the lite detector uses a
bloom filter to detect names. Cost savings using this
approach:
Microbench on my machine — shape of the numbers matters more than the exact ns:
For ~99k names here’s the comparison to a standard Set:
| Bloom | Full name Set | |
|---|---|---|
| Bundle source | ~232 KB | ~984 KB (~4.2×) |
| Runtime payload | ~174 KB bits | ≫984 KB (strings + Set overhead) |
| Gzip | ~175 KB | ~335 KB (~1.9×) |
Wild, eh? Of course, there’s no free lunch. We get dinged with a tiny hit to runtime 🤪:
| Set.has | Bloom.has | |
|---|---|---|
| Mixed | ~7 ns | ~155 ns (~20×) |
| Hits | ~22 ns | ~273 ns |
| Misses | ~12 ns | ~53 ns (early exit) |
Yes, you read that right. ns (nanoseconds).
AI confirmation
Okay, so here’s the awkward truth: libraries like Sensored are not going to be right 100% of the time. False positives happen. I guarantee it. The bloom filter is fast and tiny, but it will still happily light up on stuff that looks like a name and isn’t. That’s the deal:
- it is free
- it works for a lot of use cases
- it’s ridiculously fast
- it’s easy to extend
- and if you want to get spicy about precision, you can plug in something like Jev
Enter Jev from TypeSafe’s System One. I didn’t want to turn sensored
into “yet another LLM wrapper that redacts for you.” I wanted a second opinion on candidates we
already found. Opt-in. Async only. redactAsync() with a semantic block.
How it works in practice: run the normal detectors on the original text, grab the ones that opted
into semanticConfirm (today that’s basically person_name_lite), ship the raw candidate plus a
bit of surrounding context to Jev, drop the ones that look like false positives, then redact what’s
left. That’s it.
What it is not: scrub the whole string first and ask an AI “what’s still sitting there?” Jev is
not inventing new spans. It’s answering yes/no on stuff the detectors already proposed. Which also
means — yeah — you’re sending candidate PII to TypeSafe. Same vibe as the LLM steering note earlier:
context matters. Sync redact() and stream() never touch this path.
import { createRedactor } from "sensored";
const redactor = createRedactor({ rules: { person_name_lite: { action: "redact" } }, semantic: { provider: "jev", apiKey: process.env.TYPESAFE_API_KEY!, },});
const result = await redactor.redactAsync("Contact John Smith today");// result.text: "Contact [PERSON_NAME] today"// result.detections[0].semanticConfirmed: trueCustom detectors can opt in with their own semanticConfirm if you want. And if Jev is down?
Sensored fails open — keeps the detections, sticks a warning on the result, still gives you redacted
text. I’d rather over-redact than silently leak because an API hiccuped.
On the name subset of the eval corpus, person_name_lite + Jev hit 100% precision and recall in my
runs. Wild, and also the point: kill dumb false positives without throwing away real names.
Representative runs, not a peer-reviewed benchmark.
So the stack: regex when the shape is enough. Labels when it isn’t. Bloom when you want names without dragging NER into the hot path. Jev when you want a semantic “are you sure?” on candidates you already have.
The hard part was never “can I write another regex.” It was knowing when shape lies — and having a way back from over-redacting model IDs into silently leaking names. That’s what Sensored is for. Features and docs: atomicpages.github.io/sensored.