@framers/agentos-ext-pii-redaction
Four-tier PII detection and redaction for @framers/agentos. A guardrail scrubs personally identifiable information from agent inputs and outputs, and redactPii() does the same for any string your code is about to put in a prompt, a store or a log.
What it does
The detection pipeline (PiiDetectionPipeline) runs four tiers, each only when it has work to do:
- Regex: openredaction's patterns for emails, phone numbers, SSNs, card numbers, IBANs, API keys, AWS keys, passports, driving licences, government IDs, IP addresses, dates of birth and crypto addresses. Runs when a requested type needs it, with openredaction's false-positive filter off: that filter drops a phone number when a word beginning "on", "after" or "address" sits within 50 characters of it, and an email address near a link's "//". Each pattern's own validator still runs. openredaction's name pattern is not used: it takes a capitalised word and up to three words of any case after it, so it read "Hello from the stub" and "Here is the weather" as names, and Title Case headings such as "Next Steps" too. Names are tiers 2 and 3's. When
entityTypesnamesSTREET_ADDRESSorSOCIAL_HANDLE, the pack's own pattern code for those two types runs in this tier too (below). - NLP pre-filter: compromise proposes names (scored 0.55), places (0.45) and organisations (0.4).
- NER model:
Xenova/bert-base-NERthrough transformers.js confirms them. It loads whenenableNerModelis notfalseand tier 2 proposed a name, place or organisation, or whencompromiseis not installed (tier 2 then proposes nothing, and the model is the only tier left that finds names). How it reads the model's output is below. - LLM judge: re-examines entities scoring between 0.3 and 0.7. It runs only when
llmJudgeis set. Its answers cannot introduce types outsideentityTypes: when the judge confirms an entity under a type the host did not ask for, the entity keeps its own type and takes the judge's confidence. Cached judgements are separated by the requested types.
Overlapping spans are then merged (two spans of one type become one span over both, so a recogniser that found "John" more surely than another found "John Smith" does not leave "Smith" in the text), entities under confidenceThreshold are dropped, and the redaction engine (redactText) writes each span in the chosen redactionStyle.
The NER tier
The model is dslim/bert-base-NER in Xenova's ONNX port: bert-base-cased fine-tuned on CoNLL-2003 English, labelling each WordPiece token O, B-/I-PER, B-/I-LOC, B-/I-ORG or B-/I-MISC (the port's config.json). Its card reports test-set precision 90.7 and recall 91.9, and warns that it "occasionally tags subword tokens as entities". transformers.js 3.x's token-classification pipeline returns each token's label, score and text but no character offsets ("TODO: Add support for start and end" in its source), so the recogniser:
- asks for every token,
Oincluded; - reads a word (a token and the
##pieces after it) by its first piece's label and score, so an entity covers whole words; - finds each word in the text as the tokenizer reads it (its
BertNormalizerdrops control characters such as a zero-width space and puts spaces around CJK ideographs, and neither lowercases nor strips accents), from where the word before it ended, never inside a longer run of letters and digits, and reads that place back in the text's own offsets; - starts an entity only at a word scored 0.7 or more (
NER_MIN_WORD_SCOREinsrc/recognizers/NerModelRecognizer.ts, the score at which the pipeline takes an entity without the LLM judge; the card gives no threshold of its own), and joins anI-word at any score to the entity before it when that entity has its label and only spaces lie between them, so a surname the model is less sure of stays with its first name; - writes each entity as the text's own span, from its first word's start to its last word's end, scored as its most confident word.
| Text | NER entities |
|---|---|
Hello from the stub. | none |
My name is Wolfgang and I live in Berlin. | PERSON "Wolfgang" (11 to 19), LOCATION "Berlin" (34 to 40) |
Ask Sam about Lee. | PERSON "Sam", PERSON "Lee": two names, never one span over "Sam about Lee" |
The libraries of tiers 1 to 3 are optional dependencies. A missing compromise or @huggingface/transformers skips its tier, and with both missing no tier finds names; a missing openredaction makes detection throw whenever a type its patterns serve is asked for. The two contact types need none of the three.
Street addresses and social handles
Two contact types, STREET_ADDRESS and SOCIAL_HANDLE, are off unless entityTypes names them. CONTACT_PII_ENTITY_TYPES lists both. ALL_PII_ENTITY_TYPES keeps its 18 types and stays the default of entityTypes, so a pack built with no options reports neither.
import {
CONTACT_PII_ENTITY_TYPES,
REGEX_TIER_TYPES,
createPiiRedactionGuardrail,
} from '@framers/agentos-ext-pii-redaction';
// A public room: the regex tier's structured kinds and the two contact types. No model loads.
const pack = createPiiRedactionGuardrail({
entityTypes: [...REGEX_TIER_TYPES, ...CONTACT_PII_ENTITY_TYPES],
});
// "I live at 42 Maple Street, Apt 3. Add me on insta: sunny.side"
// reaches the model as "I live at [STREET_ADDRESS]. Add me on insta: [SOCIAL_HANDLE]"
ContactRecognizer (src/recognizers/ContactRecognizer.ts) finds both in the regex tier with pattern code of its own: no model, no network call, no dependency. openredaction's patterns for the two are not used. Its ADDRESS_STREET has no validator and reads "10 more points for first place" as an address, and its SOCIAL_MEDIA_HANDLE counts a handle only near a short list of words that leaves out "insta", "snap", "discord" and "tg".
| Type | What it finds | Score |
|---|---|---|
STREET_ADDRESS | A house number, one to four name words and a street word, with at least one support | 0.9 |
A post-office box: P.O. Box 123, PO Box 123, Post Office Box 123 | 0.9 | |
| The same shape with no support | 0.45 | |
SOCIAL_HANDLE | A link to a profile or an invite | 0.95 |
| A service word and a name | 0.9 | |
A bare @name with no service word | 0.4 |
Entities under confidenceThreshold (0.5 unless set) are dropped. An address shape with no support, such as the heading "10 More Points For First Place", and a room's own @mention are reported only to a host that lowers the threshold.
Street addresses
- The house number is one to six digits with an optional letter or second number (
221B,12-14,4/12). It never starts inside a time, a decimal, a version number, a price, a path or a tag, so$1200 Park Avenueand4.12 Main Streethold no address. - The street words are street, road, avenue, lane, drive, court, boulevard, way, terrace, place, square, close, crescent, highway, parkway and trail, and the short forms
st,rd,ave,av,ln,dr,ct,blvd,ter,tce,pl,sq,cl,cres,hwy,pkwyandtrl. - Way, place, court, drive, close, square and trail are also ordinary words. They count only when capitalised after a capitalised name word or an ordinal ("12 Kings Way"), and so do the short forms
av,ct,ter,tce,pl,sq,cl,cresandtrl. "10 more points for first place" is no address at any threshold. - A name word is letters (including accented letters) or an ordinal ("5th"). A lowercase function word, count or measure is not one, so "5 miles down the road", "a 1 way street" and "5 pm on Main Street" hold no address. Lowercase names count: "i live at 42 maple street apt 3" is an address.
- Support is any of these:
- A unit:
apt,apartment,unit,suite,ste,flator#with its number, after the street ("221B Baker Street, Apt 4") or before the house number ("Flat 2, 14 High Street"). - A postal code within 60 characters after the street: a US ZIP after a state, an Australian postcode after a state or territory, a Canadian postal code, a UK postcode.
- A city and a region after a comma or on the next line. The region is a US state, a Canadian province or territory, an Australian state or territory, or a UK nation, by name or by code. Any other region counts too, as a UK county does, when the city and the region each follow a comma and each is capitalised: "12 Kings Road, Reading, Berkshire". Ordinary words after the commas are not a place: "12 Kings Road, then we walk, ok" has no support.
- An address cue in the 40 characters before it: "live at", "address", "located at", "send it to", and "mail", "ship", "deliver" or "post" in place of "send".
- A unit:
- The entity covers the unit, the city, the region and the postal code found with the street. The cue stays in the text.
- Formats covered: English-language addresses of the United States, the United Kingdom, Canada and Australia. Addresses in other languages and in other countries' formats are not detected.
Social handles
- A link:
instagram.com/<name>(andinstagr.am),t.me/<name>(andtelegram.me, with+andjoinchat/invites),discord.gg/<code>(anddiscord.com/invite/<code>,discordapp.com/invite/<code>),x.com/<name>(andtwitter.com),tiktok.com/@<name>,snapchat.com/add/<name>,wa.me/<digits>andreddit.com/user/<name>(and/u/), with or withouthttps://andwww.. The entity covers the link to its end. A link to one of a service's own pages (instagram.com/p/...,x.com/home,reddit.com/r/...) names no account and is not one. - A service word and a name. The service words are instagram, insta, ig, snapchat, snap, discord, telegram, tg, kik, twitter, tiktok and reddit; Reddit's
u/namecounts with no service word.- After a colon, an equals sign or a spaced dash, the name counts: "insta: name", "ig - name". The words "name", "username", "handle", "id" and "tag" may sit between: "discord tag: name#1234". An ordinary word after the label (one of the words listed as excluded below) counts only where the clause ends or the clause asks to be contacted: "insta: cool" and "add me on insta: cool" give a name, "Reminder on discord: no spoilers" and "twitter - what a mess" do not.
- After a space, the name counts when it carries a mark an ordinary word does not (an
@, a digit, an underscore, a dot, a#1234tag: "tg @name", "discord name#1234"), when the two stand alone on their line or precede a comma or semicolon ("kik name", "snap sunnyside, thanks"), or when the clause asks to be contacted ("add me on snap name"). The clause asks when it holds "hmu", or one of add, dm, pm, msg, message, text, hit, find, follow, reach, contact or ping with "me", "us", "my" or "our" after it, or "add" straight before the service word ("add snap name"). The verb alone does not ask: "I got a message on discord saying I won" and "can't find discord anywhere" hold no handle. - After "is", the name counts with a mark, or after a possessive when the name ends the clause or the clause asks to be contacted: "my insta is name", "my insta is name, add me". "my discord is acting up again" says how the service is doing and holds no handle.
- An explicitly labelled name can be two characters, including a digit ("insta: a1"), or an ordinary word that ends the clause ("insta: cool"). Without a label or
@, names have at least two letters and ordinary service-related words are excluded: "the discord server", "insta story", "tiktok videos", "oh snap that was close", "discord anyone?". - A name ends the clause when nothing but a full stop or an exclamation mark follows it on its line, or a comma or a semicolon comes next.
igis also short for "I guess", so it counts only before a colon, an equals sign or a dash, with a marked name, or as someone's ("my ig is name").- The entity is the name alone, so a string in
allowlist, such as a room's own account name, matches it.
- Code references such as
twitter: process.env.TWITTER_HANDLEare excluded; dotted account names remain valid. - A bare
@name: never the@of an email address, of a scoped package (@scope/name) or of a decorator.
What a host passes. entityTypes (for a public room, REGEX_TIER_TYPES and the two contact types, as above), its confidenceThreshold and its string allowlist. With those types the pack loads no model: tiers 2 and 3 return before loading anything when no type they serve is asked for. Two options change that. Naming PERSON, ORGANIZATION or LOCATION brings in tiers 2 and 3. llmJudge sends every entity scored between 0.3 and 0.7 to the judge's model, which with these types means every bare @name (0.4) and every unsupported address shape (0.45).
Privacy. The guardrail's verdict names types and counts (reason, metadata.entityCount). The matched text exists only in the pipeline's result (PiiEntity.text). An entity's metadata names the pattern that matched and the kinds of support found, and holds nothing from the text. The pack writes no log line.
With LOCATION named too. A street address that passes the host's threshold and allowlist keeps its complete span when NER finds a city or other entity inside it, even at a higher score. An allowed or below-threshold address does not suppress a separate NER detection.
Install
npm install @framers/agentos-ext-pii-redaction
Peer dependency: @framers/agentos 0.12.0 or later.
Redact a string
import { redactPii } from '@framers/agentos-ext-pii-redaction';
const result = await redactPii('Write to [email protected] or call +1 415 555 0100 about the 1919 treaty.');
// result.found: true
// result.types: ['EMAIL', 'PHONE']
// result.text: the same sentence with [EMAIL] and [PHONE] where the address and the number were
With no options, redactPii finds the regex tier's types (REGEX_TIER_TYPES: EMAIL, PHONE, SSN, CREDIT_CARD, IBAN, API_KEY, AWS_KEY, GOV_ID, PASSPORT, DRIVERS_LICENSE, IP_ADDRESS) with the NER model off, so no model loads and names stay as they are. It builds one pipeline per set of options and reuses it. It throws when detection fails, for example when openredaction is not installed, so code that must not pass unredacted text stops there.
| Option | Default | What it sets |
|---|---|---|
entityTypes | REGEX_TIER_TYPES | The kinds to find |
redactionStyle | 'placeholder' | How a found span is written |
enableNerModel | false | Whether tier 3 may load |
pipeline | Any other pack option below, such as confidenceThreshold, allowlist, nerDtype or llmJudge |
The regex tier's library treats addresses at documentation domains such as example.com as placeholders and leaves them in the text, so test with an address it reports, such as one at a domain under .invalid.
PiiDetectionPipeline and redactText are exported as well, for code that wants the detected entities themselves.
The guardrail
import { AgentOS } from '@framers/agentos';
import { createPiiRedactionGuardrail } from '@framers/agentos-ext-pii-redaction';
const agentos = new AgentOS();
await agentos.initialize({
...config, // the rest of your AgentOSConfig
extensionManifest: {
packs: [
{
factory: () =>
createPiiRedactionGuardrail({
entityTypes: ['EMAIL', 'PHONE', 'SSN', 'PERSON', 'CREDIT_CARD'],
redactionStyle: 'placeholder',
}),
enabled: true,
},
],
},
});
The pack holds the guardrail pii-redaction-guardrail and the tools pii_scan and pii_redact. By default, the guardrail answers with a SANITIZE result that carries the redacted text. On output it buffers each stream's text and scans it at sentence boundaries and at the stream's end.
The guardrail fails closed. Its config.failClosed is true unless the options set failClosed: false, so when detection throws, the AgentOS guardrail dispatcher blocks the text instead of skipping the guardrail and passing the text through unredacted. In redaction mode its config also sets canSanitize: true, which runs it among the dispatcher's sanitising guardrails, and evaluateStreamingChunks from the option of that name.
Refusing instead of redacting
Set onDetect: 'refuse' to reject detected personal data and ask the sender to edit it. The guard answers BLOCK with PII_PRESENT, the detected types, their count, and the detection tiers. It never returns SANITIZE, matched text, or rewritten text in this mode.
refuseTypes narrows the types that cause refusal. Omit it to refuse every detected type; an empty list refuses none. Other detected types are left alone. entityTypes decides which types the pipeline detects, so every entry of refuseTypes must be in entityTypes too (or in the default set when entityTypes is left out). A guard whose refuseTypes names a type that is never detected would never refuse it, so building one throws: createPiiRedactionGuardrail({ onDetect: 'refuse', refuseTypes: ['STREET_ADDRESS'] }) fails, because the default set holds no contact type.
With sequential: true, the guard runs before guards that do not sanitize, so a refused message reaches none of them. It sets both config.sequential and config.canSanitize: core 0.15.124 reads the latter to run the guard in its first phase. This ordering claim does not make refuse mode rewrite text. sequential defaults to false and affects only refuse mode.
import { moderateContent, type IGuardrailService } from '@framers/agentos';
import { CONTACT_PII_ENTITY_TYPES, createPiiRedactionGuardrail } from '@framers/agentos-ext-pii-redaction';
const pack = createPiiRedactionGuardrail({
entityTypes: [...CONTACT_PII_ENTITY_TYPES],
onDetect: 'refuse',
sequential: true,
guardrailScope: 'input',
});
const guard = pack.descriptors.find((item) => item.kind === 'guardrail')!.payload as IGuardrailService;
const decision = await moderateContent(
{ text: 'my address is 221 Baker Street, London NW1 6XE' },
{ guards: [guard], context: { userId: 'member', sessionId: 'session' } },
);
// decision.outcome === 'refuse'; decision.deciding.reasonCode === 'PII_PRESENT'
// No sanitizedText is returned.
The example uses the moderation entry point available in core 0.17.1. Refusal through the older guardrail dispatcher remains supported with the existing peer dependency range.
For output, refuse mode ignores streaming deltas and evaluateStreamingChunks, and judges the complete FINAL_RESPONSE text. Hosts must withhold delivery until that final evaluation if they need to prevent output disclosure.
The two contact types alone load no model or optional library. Naming PERSON, ORGANIZATION, or LOCATION enables the NLP and NER tiers and can load the NER model. Setting llmJudge sends ambiguous candidates to the configured judge, including bare @name spans at 0.4 and unsupported address shapes at 0.45, even though the default threshold is 0.5.
Options
createPiiRedactionGuardrail(options) takes PiiRedactionPackOptions (see src/types.ts):
| Option | Default | What it sets |
|---|---|---|
entityTypes | the 18 types of ALL_PII_ENTITY_TYPES | The kinds to detect. STREET_ADDRESS and SOCIAL_HANDLE (CONTACT_PII_ENTITY_TYPES) are detected only when named here |
confidenceThreshold | 0.5 | Entities scoring below it are dropped |
redactionStyle | 'placeholder' | placeholder ([EMAIL]), mask (each word's first character kept, the rest starred), hash ([EMAIL:a3f2c1d4e5], the type and the first 10 hex characters of the text's SHA-256) or category-tag (<PII type="EMAIL">REDACTED</PII>) |
allowlist | none | Found text equal to one of these strings, in any case, is left in place |
denylist | none | Found text equal to one of these strings scores 1.0 |
enableNerModel | true | Whether tier 3 may load; only false turns it off |
nerDtype | 'q8' | The NER model's weights: q8 (onnx/model_quantized.onnx, about 109 MB) or fp32 (onnx/model.onnx, about 431 MB) |
llmJudge | none | { provider, model, apiKey?, baseUrl?, maxConcurrency?, cacheSize? }; turns tier 4 on |
guardrailScope | 'both' | input, output or both |
onDetect | 'redact' | Redact detected spans, or 'refuse' with PII_PRESENT and no rewritten text |
refuseTypes | all detected types | In refuse mode, only these types block; other detections are left alone. Each must be in entityTypes, or the pack throws when built |
sequential | false | In refuse mode, run before guards that do not sanitize, including on older cores |
evaluateStreamingChunks | false | The guardrail config's flag for streaming chunks |
maxStreamingEvaluations | 50 | Sentence-boundary scans per stream |
failClosed | true | Whether a guardrail evaluation that throws blocks the text |
allowlist and denylist accept RegExp entries in their type; detection applies the string entries alone.
Public API
redactPii(text, options?),REGEX_TIER_TYPES,RedactPiiOptions,RedactPiiResult: redaction of one stringPiiDetectionPipeline,redactText(text, entities, style): the detection pipeline and the redaction engineContactRecognizer: the recogniser of the two contact types on its own.recognize(text)looks for both;recognize(text, { entityTypes })for the listed onescreatePiiRedactionGuardrail(options?): factory returning anExtensionPackcreateExtensionPack(context): auto-discoverable factory used by AgentOS extension auto-pickup- The types and constants of
src/types.ts, among themPiiRedactionPackOptions,PiiEntity,PiiEntityType,ALL_PII_ENTITY_TYPES,CONTACT_PII_ENTITY_TYPESandPII_SERVICE_IDS
Examples
test/: redaction fixtures across recognizer types
Lazy loading and optional install
This package is an optional dependency of @framers/agentos-extensions-registry. The registry ships catalog metadata for every extension, but createCuratedManifest() calls import.meta.resolve() per entry and silently skips anything not installed. npm install @framers/agentos-ext-pii-redaction is the gate.
The NER model does not load at activation. Tier 3 asks the SharedServiceRegistry for agentos:nlp:ner-pipeline on the first scan that needs it, and the registry builds the transformers.js pipeline then, at the nerDtype weights: about 109 MB at q8, the default. Later scans, the streaming guardrail and both pii_scan / pii_redact tools share that instance, so the first pack to load it decides its weights for every pack on the registry.
The optional LLM-judge tier runs only when llmJudge is set. Its key is llmJudge.apiKey, or else the guardrail reads the secret <provider>.apiKey, then pii.llm.apiKey, from the extension manager.
For the full DI model and end-to-end walkthrough, see How extensions stay optional and lazy and the auto-loading guide.
License
Apache 2.0. See LICENSE.
Checking an installed release
scripts/published-consumer.mjs exercises the installed npm package through moderateContent(): an address refuses without rewritten text, an ordinary line passes, and the default set remains 18 types. Release QA runs it in an empty project with the exact package version and the current core. It prints published PII pack passes: 3 items when those checks pass.