Skip to content
Threadbare
Open Source··12 min read

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:

  • webpack
  • rollup
  • eslint
  • biome
  • vite
  • somehow gulp and grunt
  • 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-0100
206-555-0100
206.555.0100
2065550100

They’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:

KindClassJobExamples
Pattern-onlyDetectorRegex (+ light filters). Shape alone enough.email, aws_access_key, iban
Context-gatedContextDetectorRegex candidate needs nearby label (“SSN”, “NHS”, …). Biggest bucket.us_ssn, uk_nhs, passport
ChecksumChecksumDetectorRegex → adjacency → mod/Luhn/validate(). No label required.payment_card, ca_sin, vin
KeywordKeywordDetectorRegex + keyword in ±40 chars (token-ish).discord_bot_token, datadog_api_key
CustomRegexDetectorUser 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_itin

Sensored 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:

  1. PersonNameDetector
  2. PersonNameLiteDetector

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:

BloomFull 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.hasBloom.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: true

Custom 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.