@framers/agentos-ext-pii-redaction

中文文档
0.3.4发布于 2026/11/13Apache-2.0TypeScriptES Module零依赖近 30 天下载 2.3万

Four-tier PII detection and redaction guardrail for AgentOS

原始使用文档
文档目录13 节
  1. What it does
  2. The NER tier
  3. Street addresses and social handles
  4. Install
  5. Redact a string
  6. The guardrail
  7. Refusing instead of redacting
  8. Options
  9. Public API
  10. Examples
  11. Lazy loading and optional install
  12. License
  13. Checking an installed release

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

  1. 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 entityTypes names STREET_ADDRESS or SOCIAL_HANDLE, the pack's own pattern code for those two types runs in this tier too (below).
  2. NLP pre-filter: compromise proposes names (scored 0.55), places (0.45) and organisations (0.4).
  3. NER model: Xenova/bert-base-NER through transformers.js confirms them. It loads when enableNerModel is not false and tier 2 proposed a name, place or organisation, or when compromise is 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.
  4. LLM judge: re-examines entities scoring between 0.3 and 0.7. It runs only when llmJudge is set. Its answers cannot introduce types outside entityTypes: 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, O included;
  • 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 BertNormalizer drops 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_SCORE in src/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 an I- 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.
TextNER 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".

TypeWhat it findsScore
STREET_ADDRESSA house number, one to four name words and a street word, with at least one support0.9
A post-office box: P.O. Box 123, PO Box 123, Post Office Box 1230.9
The same shape with no support0.45
SOCIAL_HANDLEA link to a profile or an invite0.95
A service word and a name0.9
A bare @name with no service word0.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 Avenue and 4.12 Main Street hold 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, pkwy and trl.
  • 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, cres and trl. "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, flat or # 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".
  • 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> (and instagr.am), t.me/<name> (and telegram.me, with + and joinchat/ invites), discord.gg/<code> (and discord.com/invite/<code>, discordapp.com/invite/<code>), x.com/<name> (and twitter.com), tiktok.com/@<name>, snapchat.com/add/<name>, wa.me/<digits> and reddit.com/user/<name> (and /u/), with or without https:// and www.. 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/name counts 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 #1234 tag: "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.
    • ig is 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_HANDLE are 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.

OptionDefaultWhat it sets
entityTypesREGEX_TIER_TYPESThe kinds to find
redactionStyle'placeholder'How a found span is written
enableNerModelfalseWhether tier 3 may load
pipelineAny 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):

OptionDefaultWhat it sets
entityTypesthe 18 types of ALL_PII_ENTITY_TYPESThe kinds to detect. STREET_ADDRESS and SOCIAL_HANDLE (CONTACT_PII_ENTITY_TYPES) are detected only when named here
confidenceThreshold0.5Entities 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>)
allowlistnoneFound text equal to one of these strings, in any case, is left in place
denylistnoneFound text equal to one of these strings scores 1.0
enableNerModeltrueWhether 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)
llmJudgenone{ 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
refuseTypesall detected typesIn refuse mode, only these types block; other detections are left alone. Each must be in entityTypes, or the pack throws when built
sequentialfalseIn refuse mode, run before guards that do not sanitize, including on older cores
evaluateStreamingChunksfalseThe guardrail config's flag for streaming chunks
maxStreamingEvaluations50Sentence-boundary scans per stream
failClosedtrueWhether 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 string
  • PiiDetectionPipeline, redactText(text, entities, style): the detection pipeline and the redaction engine
  • ContactRecognizer: the recogniser of the two contact types on its own. recognize(text) looks for both; recognize(text, { entityTypes }) for the listed ones
  • createPiiRedactionGuardrail(options?): factory returning an ExtensionPack
  • createExtensionPack(context): auto-discoverable factory used by AgentOS extension auto-pickup
  • The types and constants of src/types.ts, among them PiiRedactionPackOptions, PiiEntity, PiiEntityType, ALL_PII_ENTITY_TYPES, CONTACT_PII_ENTITY_TYPES and PII_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.

版本与发布记录

@framers/agentos-ext-pii-redaction 共发布 8 个版本,首次发布于 2026/03/22;过去 12 个月发布了 8 个版本。

版本发布日期距今
0.3.4latest2026/11/132 天前
0.3.32026/11/096 天前
0.3.22026/10/2126 天前
0.3.12026/10/101 个月前
0.3.02026/10/101 个月前
0.2.12026/05/185 个月前

关于 @framers/agentos-ext-pii-redaction 的常见问题

@framers/agentos-ext-pii-redaction 是什么?

Four-tier PII detection and redaction guardrail for AgentOS

如何安装 @framers/agentos-ext-pii-redaction?

在项目目录运行 npm install @framers/agentos-ext-pii-redaction,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@framers/agentos(>=0.12.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@framers/agentos-ext-pii-redaction 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@framers/agentos-ext-pii-redaction 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@framers/agentos-ext-pii-redaction 可以商用吗?

@framers/agentos-ext-pii-redaction 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@framers/agentos-ext-pii-redaction 的下载量有多少?

根据 npm 官方下载统计,@framers/agentos-ext-pii-redaction 在 2026/10/16 – 2026/11/14 这 30 天内被下载 22,709 次,日均约 757 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@framers/agentos-ext-pii-redaction 还在维护吗?

最新版本 0.3.4 发布于 2026/11/13(2 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/03/22。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。