Skip to content
← All posts

Sensitive data out of the prompt: a TypeScript library written in 39 minutes from its specification

sanitype redacts, masks or drops sensitive data inside the process before a payload leaves for an LLM, a log sink or a third party. How it is implemented in TypeScript.

Illustration of sanitype: a payload crosses a boundary and comes out with its sensitive fields masked, plus a report of what was touched

Every time a backend concatenates a user message into a prompt, writes a request body to a log or forwards a payload to a helpdesk, it is moving personal data out of the process and into a system that was never designed to hold it. sanitype is a TypeScript library that puts one call between that payload and its destination: it takes the object and returns a copy with the same shape and the sensitive fields redacted, masked, hashed or dropped, plus a report of everything it touched. It runs entirely in-process, with no network calls and no runtime dependencies. It was also built in an unusual way: the idea was dictated by voice to an agent, the agent wrote 563 lines of specification, and Claude Code implemented version 0.1.0 against them in a single goal, 39 minutes later.

TL;DR
  • It combines two strategies: field-path rules for what you already know is sensitive, and detectors over free text for the email someone pasted into a notes field.
  • By default an object goes in and an object with the same structure comes out. Every call returns a report of what was touched, where and by which detector, and that report never contains the original value.
  • The repository was written the other way round: first SPEC, ARCHITECTURE, ROADMAP and COMPARISON, then the code. 39 minutes passed between the specification commit and the implementation commit.

In this article:

sanitype in twenty seconds:

your backend


sanitize(payload)
    ├─ field rules   what you already know is sensitive
    ├─ detectors     what shows up inside free text
    └─ action        redact · mask · hash · drop · tokenize


LLM · logs · analytics · third-party APIs
    +  report: what was touched, where and by which detector

What sensitive data leaks out of a backend

The problem is not that somebody wants to leak data. It is that there are four common exits through which a payload leaves the process whole, and none of the four feels like a decision about privacy when you write it:

user payload
  ├──► LLM prompt        OpenAI, Anthropic, a local model
  ├──► logs and tracing  Sentry, Datadog, the request logger
  ├──► analytics         Segment, PostHog, internal events
  └──► third-party APIs  helpdesk, webhooks, partner integrations

The first one is the newest and the quietest. A support ticket carrying the customer’s phone number and national ID gets concatenated into the prompt as it is, and the prompt travels to an external vendor that may retain it. The second is the oldest: somebody added logger.info({ body: req.body }) to debug one case, and that log has been collecting emails and card numbers for two years. The third and fourth are variants of the same carelessness: the whole object goes out because separating the fields the destination actually needs takes work.

What these four have in common is that they happen at the outbound edge of the process. That is where something has to sit, and that something has to be cheap to call, because if it costs a network round trip nobody is going to put it in the logger.

Why nothing that already exists fit

Before writing a line I went through the landscape, and I wrote it down in a COMPARISON.md inside the repository, because “why not just use X” is a question that shows up in the first issue.

OptionWhat it does wellWhy it did not fit
DLP as a service (Google Cloud DLP, Purview, Macie)Broad entity coverage, model-based detection, compliance tooling around itA network call per scan: latency, cost per request and one more vendor dependency
Self-hosted Microsoft PresidioMature, strong entity recognition, no per-call costIt is a Python project: from a Node backend it means deploying and monitoring another service
Regex npm packagesLightweight, dependency-free, easy to readThey work on loose text: they do not know that user.ssn in your type is a national ID, and they return no report
Browser extensionsThey catch what a user pastes into a UIThey see nothing of server-to-server traffic, which is exactly the case here

The gap is specific: a native TypeScript library that runs in-process, understands the shape of your objects and not just the text, and returns an auditable record of what it did. That is the position sanitype occupies, and the price it pays for it is in the comparison: there is no entity-recognition model behind it, and free-text detection is bounded by the patterns it ships.

How it was built: from a dictated idea to a published library

I dictated the idea by voice to my Hermes agent and asked it for design documents, not code. Then I handed the repository to Claude Code with a single goal: implement version 0.1.0 against those documents.

idea dictated by voice


  Hermes agent  ──►  SPEC.md · ARCHITECTURE.md · ROADMAP.md · COMPARISON.md
                            │      563 lines · zero TypeScript
                            ▼      one single goal
                      Claude Code  ──►  src/ · test/ · docs/ · examples/
                                        84 files · 12,245 lines · 108 tests

39 minutes between the specification commit and the implementation commit. The four documents cover the problem and the goals, the internal design, the order of the releases and the comparison with what already exists. The code commit that followed brings the core, the adapters, the documentation and eight runnable examples.

What made that time possible was not the agent’s speed, but that the specification had already closed the decisions that normally get resolved halfway through the implementation, in a hurry and without leaving a trace.

I already covered Spec-Driven Development in detail on another project, so I will not repeat it. What I do want to highlight is one section of the SPEC.md. Section 7 is called “open questions” and lists what the document could not decide: which national IDs make it into v1, whether tokenize ships storage or only the interface, whether detection is synchronous or asynchronous. Section 8, written after the implementation, answers each one and states what was chosen. The specification did not end up as a document that ages while contradicting the code: it ended up as the record of why the code is the way it is.

The two ways to say what is sensitive

The core combines two strategies, and the design rests on both of them coexisting.

The first one is field rules: you declare which paths of your payload are sensitive and what to do with each. It is explicit and does not depend on a pattern matching. The second one is detectors: patterns that run over every string left without a rule, to catch what the structure did not anticipate, such as an email written inside a notes field.

import { createSanitizer } from '@devrchancay/sanitype';

const sanitizer = createSanitizer({
  // What I already know is sensitive, by path.
  fields: {
    'user.email': 'mask',            // john.doe@example.com -> j***.d**@e******.com
    'user.ssn': 'redact',            // 123-45-6789 -> [REDACTED_SSN_US]
    'user.phone': 'hash',            // -> deterministic sha256
    password: 'drop',                // the key disappears from the payload
    'items[*].internalNote': 'allow' // never touched, silences a false positive
  },
  // The safety net for everything else.
  detectors: { email: 'mask', phone: 'mask' },
  hash: { salt: process.env.SANITYPE_SALT },
});

const { data, report } = sanitizer.sanitize(payload);

Paths use dot notation with wildcards: users[*].email for any index, *.password for any top-level key, **.password for any depth, $ for the root. When several patterns apply to the same path the most specific one wins, and on a tie the last declared one wins. Field rules always take precedence over detectors: the explicit beats the heuristic.

If you already have Zod schemas, sensitivity can be declared next to the validation without changing how the schema behaves:

import { z } from 'zod';
import { createSanitizer } from '@devrchancay/sanitype';
import { sensitive, fieldsFromSchema } from '@devrchancay/sanitype/zod';

const User = z.object({
  id: z.string().uuid(),
  email: sensitive(z.string().email(), 'mask'),  // the "email" category is inferred
  ssn: sensitive(z.string(), { action: 'redact', category: 'ssn_us' }),
  password: sensitive(z.string(), 'drop'),
  notes: z.string(),                             // free text: detectors run here
});

const sanitizer = createSanitizer({ fields: fieldsFromSchema(User) });

sensitive() does not wrap or modify the schema: it registers the instance in a WeakMap, and fieldsFromSchema() walks the structure to produce the paths. That is why the library works with Zod 3 and Zod 4 without ever importing zod, and why your validation keeps behaving exactly as before.

The full path of one call looks like this:

payload

  ├─ [1] path resolution     does this path have an explicit rule?
  ├─ [2] structural walk     objects, arrays, primitives
  ├─ [3] detector pass       over every string left without a rule
  ├─ [4] action application  redact | mask | hash | drop | tokenize | allow
  └─ [5] report assembly     what, where, by which detector, which action


  { data, report }

Each stage is a pure function of its input and the configuration, with no mutable state shared between calls. A Sanitizer instance compiles its configuration once and can be shared across concurrent requests.

Detectors are the extension seam

A detector finds sensitive substrings inside free text. The design decision with the most consequences was not writing one big regex, but independent named units:

export interface Detector {
  name: string;
  confidence: 'high' | 'heuristic';
  defaultAction: Action;
  priority?: number;
  test(value: string): DetectorMatch[] | null;
  mask?(value: string, options: Required<MaskOptions>): string;
}

With a single pattern three things break at once: you could not disable the detector that is noisy for you without losing the rest, the report could not attribute each scrub to a specific detector—which is the auditability requirement from the specification—and every new pattern would put the working ones at risk.

The built-in ones are split by confidence, and that split decides which are on by default:

DetectorConfidenceDefaultWhat it matches
emailhighonEmail addresses, Unicode-aware
phonehighon7 to 15 digit numbers, with +, parentheses and separators; dates excluded
credit_cardhighon13 to 19 digits validated with Luhn, not just the pattern
ip_addresshighonIPv4 and IPv6, validated with Node’s net.isIPv6
ssn_ushighonUS Social Security Numbers with separators, invalid ranges discarded
cedula_echighonEcuadorian cedula and natural-person RUC, checksum-validated
api_key_secrethighonAWS, GitHub, Stripe, OpenAI, Anthropic, Slack and Twilio keys; JWTs, bearer tokens, PEM blocks and api_key=... assignments
person_nameheuristicoffSequences of capitalised words with titles and particles
physical_addressheuristicoffEnglish and Spanish street addresses

A card detector that only counts digits flags any long numeric identifier; with Luhn, most of those false positives disappear before reaching the report. Same with the cedula check digit. The two heuristic ones, in contrast, are off on purpose: person_name flags product, company and city names, and patterns cannot fix that. They work as a safety net in logs and prompts, not as a compliance control.

Adding your company’s internal format uses exactly the same interface as the built-in detectors:

import { defineDetector, createSanitizer } from '@devrchancay/sanitype';

const customerId = defineDetector({
  name: 'customer_id',
  pattern: /\bCUST-\d{6}\b/,
  action: 'hash',
});

const sanitizer = createSanitizer({ customDetectors: [customerId] });

defineDetector also takes a validate for checksums, a test for when a regex is not enough, a category-specific mask, and a prefilter: a cheap check that discards the string before touching any pattern. The email detector, for instance, ignores any string without @, so most strings in a payload never reach the pattern list.

Six actions and a structural guarantee

Finding the value is half of it. The other half is what to do with it, and there is no single answer: masking is for debugging, hashing is for correlating, dropping is for what the destination should never receive.

ActionResult
redactFixed marker: [REDACTED_EMAIL], [REDACTED_CREDIT_CARD]
maskPartial, shape-preserving mask: j***.d**@e******.com, **** **** **** 1111, 192.***.**.**
hashDeterministic one-way hash, sha256 in hex by default
dropRemoves the key from the object or the element from the array
tokenizeReplaces the value with a reversible token against a caller-supplied TokenStore
allowLeaves the field alone, to silence a false positive on one path without disabling the detector

On top of this there is a guarantee that runs through the whole library: by default, an object goes in and comes out with exactly the same structure. Same keys, same array lengths, same nesting, and the input is never mutated. drop is the only way out of that guarantee, and it only applies where you ask for it by name: it is an explicit opt-out, not an exception hidden behind an absolute promise.

That property is not held up by good intentions either: there is a property-based test that generates payloads and checks that the output structure matches the input structure, no matter which values were scrubbed.

The report: auditable without leaking the value

A privacy control that leaves no record cannot be reviewed. Every call returns a report alongside the data:

const { data, report } = sanitize(payload, { audit: true });

report.entries[0];
// {
//   path: 'user.email',
//   category: 'email',                // detector or field category
//   source: 'detector',               // or 'field'
//   action: 'redact',
//   matches: 1,
//   preview: 'j***.d**@e******.com',  // audit mode only, always masked
// }

report.summary;    // { email: 1, phone: 1, credit_card: 1 }
report.skipped;    // oversized strings, circular refs, unsupported objects
report.modified;   // true when anything changed
report.durationMs;

The constraint that makes this object useful is that it never contains the original value, not even in audit mode: the preview comes out already masked. Without that, the report would be another copy of the sensitive value travelling into the same log you were pulling it out of. With it, the report can go into the logger without a second thought, and report.summary works as a per-category counter for a dashboard.

The wrappers: scrub before the request leaves

The core knows nothing about HTTP or any SDK, so it can be called from a queue worker or a cron job. The adapters are separate entry points that depend on the core, never the other way round, so anyone using only sanitize() does not drag Express code into their bundle.

On Express it is two middlewares:

import express from 'express';
import { createSanitizer } from '@devrchancay/sanitype';
import { sanitizeRequest, sanitizeResponse } from '@devrchancay/sanitype/express';

const sanitizer = createSanitizer({ fields: { password: 'drop' } });
const app = express();

app.use(express.json());
app.use(sanitizeRequest(sanitizer));  // replaces req.body, report on req.sanitizeReport
app.use(sanitizeResponse(sanitizer)); // scrubs res.json() payloads

app.post('/tickets', (req, res) => {
  logger.info({ body: req.body, scrubbed: req.sanitizeReport.summary }); // safe to log
  res.json({ ok: true });
});

Turning on the headers option also scrubs authorization and cookies before the request log writes them, which is one of the most common and least visible leaks.

For LLMs there are per-SDK wrappers. They patch the client in place and only touch the outbound request: the model’s response comes back exactly as the SDK produced it, which is why streaming keeps working unchanged.

import OpenAI from 'openai';
import { createSanitizer } from '@devrchancay/sanitype';
import { sanitizeOpenAI } from '@devrchancay/sanitype/openai';

const openai = sanitizeOpenAI(new OpenAI(), createSanitizer(), {
  roles: ['user', 'tool'],            // leave the system prompts you wrote alone
  onReport: (report) => audit.log(report),
});

await openai.chat.completions.create({ model: 'gpt-4o-mini', messages });

There is an equivalent one for Anthropic and a generic wrapLLMCall(fn, sanitizer, { keys }) for any other SDK. They are written per SDK shape rather than as one universal reflection-based wrapper: it is more code, but each one can be read and tested against the real request it wraps.

When the model needs to answer about the people whose data you just removed, the tokenize action closes the round trip:

import { createSanitizer, createInMemoryTokenStore } from '@devrchancay/sanitype';

const store = createInMemoryTokenStore();
const sanitizer = createSanitizer({
  detectors: { email: 'tokenize', phone: 'tokenize' },
  tokenStore: store,
});

const { data: prompt } = sanitizer.sanitize(userMessage); // "write to tok_3f9a... about ..."
const answer = await llm(prompt);
const restored = store.restore(answer);                   // tokens become the values again

The in-memory store is for development; for durable pseudonymisation you implement the TokenStore interface on top of your own storage, and if it is asynchronous you use sanitizeAsync(). That separation between the synchronous and the asynchronous path was one of the specification’s open questions, and it was resolved this way so a slow store cannot degrade the fast path.

Zero dependencies and a test that verifies it

In a tool like this the dependency tree is part of the product. The promise is that no data leaves the process, and a promise like that is not held up by a line in the README. test/trust.test.ts turns it into something that breaks the build:

it('imports no networking modules and never calls fetch', () => {
  const forbidden = [
    /from\s+['"](node:)?(http|https|http2|dgram|dns|tls|child_process|worker_threads)['"]/,
    /from\s+['"](undici|axios|node-fetch|got|ky)['"]/,
    /\bfetch\s*\(/,
    /\bXMLHttpRequest\b/,
    /\bWebSocket\b/,
    /\bnet\.(connect|createConnection|createServer)\b/,
  ];
  for (const file of sources) {
    const content = readFileSync(file, 'utf8');
    for (const pattern of forbidden) {
      expect(content, `${file} matches ${pattern}`).not.toMatch(pattern);
    }
  }
});

The same file checks two more things: that the only Node modules imported are node:crypto and node:net, and that package.json declares no runtime dependencies, with zod as the single peer dependency and marked optional. Any contribution that adds an HTTP client fails in CI before it reaches review.

Performance and limits

The measurements come from npm run bench over a ~1.6 KB order payload with 45 fields, on Node 22. They are indicative figures from one specific machine, not a formal benchmark:

ScenarioMean per call
Field rules only, detectors off~8 µs
Default detectors, payload with sensitive data~50 µs
Default detectors, payload without sensitive data~25 µs
All detectors, heuristics included~200 µs
A 1 KB free-text string, default detectors~60 µs

The order of magnitude is what matters: microseconds, not milliseconds. At that cost, putting the call in the logging middleware is not an architectural decision. Patterns are compiled once per instance, every detector has its prefilter, and strings longer than maxStringLength (100,000 by default) are skipped and noted in report.skipped instead of being scanned whole.

The limits deserve the same clarity:

  • It is not a certification. It is a technical control, one among several. It does not make an application GDPR-compliant on its own.
  • Detection is pattern-based. There is no model-based entity recognition, so free-text coverage is bounded by what ships. For known data, field rules are the guarantee; detectors are the net.
  • drop is the opt-out from the structural guarantee, and numbers under a field rule become strings.
  • National ID coverage is deliberately narrow: US Social Security and Ecuadorian cedula. Attempting “every country” in a v1 would have meant a long list of unvalidated patterns. The rest is added with defineDetector.

That last point also came out of the specification: the non-goals section states explicitly what is out of scope for v1, which is why the scope did not move during the implementation.

Frequently asked questions

Does sanitype make my application GDPR-compliant?

No. It is a technical control over data in transit at the application layer, and it has to be combined with policy, legal review and data governance. What it does contribute to an audit is the report: every scrub is recorded with its path, its category, its source and the action applied.

How is it different from Presidio or a cloud DLP?

In where it runs and what it costs to call. A cloud DLP adds a network call and a cost per request; Presidio is Python, so from Node it means deploying another service. sanitype runs in the same process in microseconds. In exchange it has no model-based entity recognition: its free-text detection is weaker than Presidio’s, and field rules are what make up the difference.

Can the original value be recovered after the LLM call?

Yes, with the tokenize action. Values are replaced by tokens before the prompt is sent and store.restore(answer) puts them back in the model’s response. The in-memory store the library ships is for development; for production you implement the TokenStore interface on your own storage.

Does it detect people’s names and addresses?

It has both, off by default and marked as heuristic. They match sequences of capitalised words and English and Spanish street addresses, and they produce false positives on product, company and place names. They work as a safety net in logs and prompts, not as a compliance control.

Do I need Zod to use it?

No. zod is an optional peer dependency and is only needed for the @devrchancay/sanitype/zod entry point. Without Zod you declare the paths by hand in fields, or you use only the detectors.

What happens to values the library cannot walk?

Map, Set, buffers and class instances pass through untouched and are listed in report.skipped, not silently ignored. Circular references are replaced by '[Circular]'. The idea is that after reading the report you know exactly what was not inspected.

Conclusion

The problem sanitype solves is not detecting personal data: it is having a cheap place to sit at the outbound edge of the process. That is why the decisions that weigh most are not the patterns, but that the call costs microseconds, that the output keeps the structure of the input, that every scrub is recorded without copying the value, and that the dependency tree is empty with a test defending it.

If you want to apply it, this is the order that works: start by calling sanitize() with no configuration over a real payload and read the report, which tells you what it is finding and where. Then turn into field rules everything you already knew was sensitive, because a declared path does not depend on a pattern matching. Leave the detectors on as a net for free text, use allow on the paths where they give you false positives, and add your company’s internal formats with defineDetector. Only at the end turn on the heuristic ones, if you need them at all, knowing what they bring.

On the other half of the story: writing the specification first did not speed the project up because the agent writes fast. It sped it up because the questions that normally get answered halfway through the implementation were already answered in writing, and the ones nobody could answer were marked as open questions instead of becoming accidental decisions.

The repository is at github.com/devrchancay/sanitype, published as @devrchancay/sanitype under the MIT licence, with SPEC.md and ARCHITECTURE.md inside so the reasoning can be read, not just the result.

Keep reading