saniprompt
v0.3.0
Published
A multilingual NSFW prompt sanitizer for Node.js and browsers, with tiered detection and obfuscation resistance.
Maintainers
Readme
SaniPrompt
A multilingual NSFW prompt sanitizer that survives the obfuscation people actually use.
3,417 terms · 21 languages · linear-time matching · no network calls
import { inspect } from "saniprompt";
await inspect("Create an explicit porn image");
// { isSafe: false, risk: "high", sanitizedText: "Create an image" }Catches what filters usually miss:
await inspect("Make p.o.r.n art"); // separator insertion
await inspect("Make p0rn art"); // leetspeak
await inspect("Make pоrn art"); // Cyrillic homoglyph
await inspect("Make porn art"); // zero-width spaces
await inspect("Make poooorn art"); // repeated letters
await inspect("pornografía" /* NFD */); // decomposed Unicode
// all flaggedWithout breaking what filters usually break:
await inspect("Type p or n to continue"); // prose is not obfuscation
await inspect("view it with the naked eye");
await inspect("grilled chicken breast");
await inspect("Nude Descending a Staircase");
await inspect("bok choy salad");
// all cleanWhy this one
| | |
|---|---|
| Obfuscation-resistant | Zero-width characters, decomposed Unicode, leetspeak, homoglyphs, separator insertion and repeated letters all fold to one skeleton before matching. |
| Precision-first | Ambiguous words are omitted, idioms are carved out, and words that collide across languages are gated. Measured at 100% on a 145-entry benign corpus, and on all 234k words of the system dictionary. |
| Graded, not binary | Four tiers for NSFW and four for profanity, so you can block hardcore content while allowing artistic nudity, or allow swearing while catching slurs. |
| Measured, not guessed | npm run eval reports recall, precision and evasion resistance. CI fails on regression, so vocabulary changes cannot quietly trade one for the other. |
| Fast | Every term compiles into a single automaton. Cost is linear in input length and independent of lexicon size. |
[!IMPORTANT] Dictionary matching catches words, not intent.
"two people in a bedroom, no clothes, photorealistic"contains nothing to match. Treat the local engine as a fast first pass and pair it with a contextual moderation provider for anything high-stakes.
Install
npm install sanipromptQuick start
import { inspect } from "saniprompt";
const result = await inspect("Create an explicit porn image");
result.isSafe; // false
result.risk; // "high"
result.sanitizedText; // "Create an image"The local engine never touches the network, so there is a synchronous API too:
import { sanitizeSync } from "saniprompt";
sanitizeSync("Create an explicit porn image"); // "Create an image"Gate, don't only scrub
[!WARNING] Deleting a word does not make a prompt safe. Removing
pornfrom "a photorealistic porn scene of two people in a bedroom" leaves a prompt that still produces the same image.
For anything that matters, gate on isSafe and reject rather than passing scrubbed text downstream:
const { isSafe, risk } = await inspect(prompt, { strategy: "flag" });
if (!isSafe) return reject(risk);strategy: "flag" returns the text byte-for-byte unchanged and reports only.
Contents
Tiers · Strategies · Obfuscation · False positives · Inspection · Categories · Languages · Custom rules · Providers · Options · Quality gates · Limitations
Tiers
NSFW and profanity each carry a tier, so you decide how aggressive to be.
threshold sets the lowest tier acted on. Both default to the bottom of their scale — "contextual" for NSFW, "mild" for profanity — so the package is strict out of the box and you raise the floor to relax it. The two scales are independent, so a single tier adjusts only its own.
// Block hardcore content, allow artistic nudity, swimwear and lingerie.
await inspect(prompt, { threshold: "explicit" });
// Let everyday swearing through, catch only slurs.
await inspect(prompt, { categories: ["profanity"], threshold: "slur" });
// Set both at once.
await inspect(prompt, { threshold: { nsfw: "explicit", profanity: "mild" } });A contextual term is promoted to suggestive when a supporting word sits nearby, so "generate adult content" is flagged while "adult education classes" is not.
[!NOTE] Strict by default. Both thresholds start at the bottom of their scale, so ambiguous words (
adult,exposed,intimate) and mild profanity (damn,hell,idiot) are acted on. Raisethresholdto relax.
[!NOTE] Swimwear is filtered unconditionally.
bikini,swimsuit,swimwearandbathing suitsit atsuggestivewith no carve-outs, soBikini Atoll,Bikini Bottom,bikini wax,racing swimsuitandswimwear retailerall flag. That is deliberate. Setthreshold: "explicit"to allow swimwear through.
[!NOTE]
minorfindings ignorethresholdentirely. They cannot be tuned away and are alwayscritical. Categories without tiers (hate, violence, self-harm, illegal activity, prompt injection, PII, secrets) are unaffected bythreshold.
Strategies
await sanitize(prompt, { strategy: "remove" }); // default
await sanitize(prompt, { strategy: "mask" });
await sanitize(prompt, { strategy: "placeholder" });
await sanitize(prompt, { strategy: "flag" });Given "beautiful woman, nude, oil painting":
| Strategy | Result |
|---|---|
| remove | beautiful woman, oil painting |
| mask | beautiful woman, ****, oil painting |
| placeholder | beautiful woman, [redacted], oil painting |
| flag | beautiful woman, nude, oil painting |
remove repairs only the seam it leaves, a doubled comma or a doubled space. Text outside a finding is never reformatted, so code indentation, blank lines and markdown structure survive intact. Customise the rest with maskCharacter and placeholder (a string, or a function of the finding).
Obfuscation handling
Detection runs on a folded skeleton of the input while edits apply to the original string.
| Evasion | Example |
|---|---|
| Separator insertion | p.o.r.n · p o r n · p-o-r-n |
| Zero-width and format characters | zero-width spaces · soft hyphens · bidi marks |
| Decomposed Unicode (NFD) | pornografía with a combining acute |
| Compatibility forms | porn · 🅾ral |
| Leetspeak | p0rn · sh1t · a$$hole |
| Cyrillic and Greek homoglyphs | pоrn with a Cyrillic о |
| Elongation | nuuude · pooorn (three or more repeats) |
| Compound words | Pornoseite · pornofilm · pornocu (German, Dutch, Turkish) |
| Regular inflections | swimsuits · orgies · masturbated · wanking · sexier (generated from the base form) |
Crucially it does not treat ordinary prose as obfuscation. "Type p or n to continue" spells porn only if you accept inconsistent separators; real obfuscation uses a consistent one, prose does not.
For the same reason a single doubled letter is not elongation. Accepting it would collapse rapping onto raping, Shiite onto shite, assess onto asses and pollack onto polack. Three or more repeats is deliberate; two is usually just a word.
Homoglyph folding is confined to mixed-script words, so genuine Russian and Greek text is untouched. Disable it with foldConfusables: false.
False positives
The Scunthorpe problem is solved three ways, none of which weaken word boundaries.
1. Genuinely ambiguous words are left out. hoe, knob, bloody, queer, cracker, dwarf and nip are absent by design. Their innocent uses are too common to be worth the recall.
2. Idioms are carved out with an exception list matched using the same folding and boundary rules, so the underlying term keeps its recall:
naked eye · naked truth · nude color · Nude Descending a Staircase · life drawing
breast cancer · chicken breast · breaststroke · sex education · sexual harassment
adult education · content moderation · underage drinking · child protection
chink in the armor · Maine Coon · homo sapiens · flame retardant · Van Dyke
prick your finger · bastard file · Dick Tracy · Hell's Kitchen · Wankel engine
magna cum laude · Moby Dick · pussy willow · missionary work · strip mall3. Words that collide across languages are gated behind an explicit language. French con, flûte, mince and viol; Polish cholera; Portuguese piranha and burro; Turkish bok; Malay sial; German mist; Dutch lul; English negro; and French négro (which folds to the Spanish word for black) all need their language named:
await inspect("bok choy salad", { categories: ["profanity"] }); // clean
await inspect("bu bok", { categories: ["profanity"], languages: ["tr"] }); // flagged
await inspect("vestido negro elegante", { categories: "all" }); // cleanTiers stay consistent across languages: a generic insult is mild whether written idiot, imbécile or Trottel, so a threshold behaves the same regardless of the input language.
Inspection
inspect() returns the sanitized text plus metadata. Findings deliberately omit the matched value, so logs do not make extra copies of what you were containing.
const result = await inspect(prompt, { categories: "all" });
result.sanitizedText;
result.isSafe; // boolean
result.risk; // "none" | "low" | "medium" | "high" | "critical"
result.findings; // category, start, end, severity, confidence, language, source, ruleId, tierOffsets are UTF-16 indices into the original string. Overlapping findings within a category collapse to the most severe, so a social security number is not also reported as a phone number.
Categories
| Category | Covers |
|---|---|
| nsfw | The primary category. Sexual acts, anatomy, nudity, pornography, fetish, and minor-safety terms |
| profanity | Profanity graded from mild to slurs, kept separate from NSFW |
| hate | Supremacist, ethnic-cleansing and dehumanizing phrases |
| violence | Graphic violence, torture and execution phrases |
| selfHarm | Self-harm, suicide-method and suicidal-intent phrases |
| illegalActivity | Criminal, fraud, phishing, malware and drug phrases |
| promptInjection | Instruction overrides, prompt exfiltration and jailbreaks |
| pii | Email, phone, Luhn-valid cards, IBANs, US SSNs, IP addresses |
| secrets | JWTs, private keys, bearer tokens, credential-bearing URLs, vendor API keys |
await inspect(prompt, { categories: ["nsfw", "promptInjection"] });
await inspect(prompt, { categories: "all" });Languages
Arabic · Bengali · Chinese · Dutch · English · French · German · Hindi (and Roman Hindi) · Indonesian · Italian · Japanese · Korean · Malay · Persian · Polish · Portuguese · Russian · Spanish · Turkish · Ukrainian · Urdu (and Roman Urdu)
All are checked by default. Naming languages narrows matching and enables the language-specific short terms:
await inspect(prompt, { languages: ["en", "ur"] });Custom rules
await inspect(prompt, {
categories: ["secrets"],
customRules: [
{ id: "company-codename", category: "secrets", kind: "literal", pattern: "blue banana", severity: "high" },
{ id: "internal-ticket", category: "secrets", kind: "regex", pattern: /TICKET-\d+/i }
]
});Literal rules get the same folding and obfuscation handling as bundled terms, so b.l.u.e b.a.n.a.n.a matches too.
Regular expressions run against the original text with their own flags preserved, including v. A bad rule throws InvalidRuleError naming the offending id.
createSanitizer compiles custom rules once instead of on every call. Prefer it on a hot path.
import { createSanitizer } from "saniprompt";
const sanitizer = createSanitizer({ categories: "all", threshold: "explicit" });
sanitizer.inspectSync(prompt);
sanitizer.inspectSync(prompt, { threshold: "suggestive" }); // per-call overridesIt exposes inspect, sanitize, inspectSync and sanitizeSync.
Moderation providers
Providers add the contextual judgement no dictionary can supply. This is the recommended path for high-stakes filtering.
import type { ModerationProvider } from "saniprompt";
const provider: ModerationProvider = {
name: "my-moderator",
async analyze(text, context) {
const response = await callYourService(text, context.categories);
return response.findings;
}
};
await inspect(prompt, { categories: "all", provider });Invalid offsets and findings for disabled categories are ignored. Provider errors fall back to local results; set providerFailure: "throw" to propagate them. inspectSync throws rather than silently skipping a configured provider.
Options
| Option | Default | Purpose |
|---|---|---|
| categories | ["nsfw"] | Which checks to run, or "all" |
| languages | "auto" | Which dictionaries to run |
| threshold | nsfw "contextual", profanity "mild" | Lowest tier acted on; a tier or a per-category object |
| strategy | "remove" | remove · mask · placeholder · flag |
| minConfidence | 0 | Drop findings below this confidence |
| maxInputLength | 100000 | Throws InputTooLargeError beyond this |
| foldConfusables | true | Fold Cyrillic and Greek look-alikes |
| maskCharacter | "*" | Fill character for mask |
| placeholder | "[redacted]" | Replacement for placeholder |
| customRules | [] | Additional literal or regex rules |
| provider | — | Contextual moderation provider |
| providerFailure | "fallback" | fallback or throw |
All errors extend SanipromptError, so one catch covers the package:
import { SanipromptError, InputTooLargeError, InvalidRuleError } from "saniprompt";
try {
await inspect(prompt, options);
} catch (error) {
if (error instanceof InputTooLargeError) return tooLong(error.length, error.limit);
if (error instanceof InvalidRuleError) return badConfig(error.ruleId);
if (error instanceof SanipromptError) return unexpected(error);
throw error;
}InvalidRuleError carries the offending ruleId; InputTooLargeError carries length and limit.
import { CATEGORIES, lexiconSize } from "saniprompt";
CATEGORIES; // readonly tuple of the nine category names
lexiconSize(); // number of bundled dictionary terms, for diagnosticsTypes exported: Category, CustomRule, Finding, InspectionResult, LanguageCode, ModerationProvider, NsfwTier, ProfanityTier, ProviderContext, ProviderFinding, Risk, Sanitizer, SanitizeOptions, Severity, Strategy, Tier, TierFamily.
NsfwTier and ProfanityTier are the two halves of Tier, useful when writing a typed threshold:
const threshold: { nsfw: NsfwTier; profanity: ProfanityTier } = {
nsfw: "explicit",
profanity: "slur"
};Quality gates
Lexicon changes are measured, not guessed. npm run eval runs the corpora in eval/ and CI fails on regression.
nsfw recall 171/171 100.0%
minor-safety recall 46/46 100.0% all critical
nsfw evasion resistance 110/110 100.0%
profanity recall 126/126 100.0%
slur recall 64/64 100.0% all critical
profanity evasion resistance 88/88 100.0%
mild caught at default 55/55 100.0%
precision (default thresholds) 145/145 100.0%
precision (raised thresholds) 145/145 100.0%
lexicon terms 3417Precision is measured twice, at both ends of the threshold range, and separately against all 234k words of the system dictionary. Contributing vocabulary means adding to a corpus as well as to the lexicon — see CONTRIBUTING.md.
Performance
Every term compiles into a single Aho-Corasick automaton, so cost is linear in input length and independent of lexicon size.
| Input | All categories | |---|---| | Typical image prompt | well under 1 ms | | 90 KB document | ~110 ms |
Limitations and privacy
[!CAUTION] This is a dictionary-based filter, not a complete safety system.
- Dictionaries cannot understand intent, quotation, education, reclamation or paraphrase. False positives and false negatives are both possible.
- Removing a phrase does not make the remaining prompt semantically safe. Gate on
isSafe. - Semantic evasion ("no clothes", "wearing nothing") and reversed text are explicit non-goals for the local engine. Use a provider.
- The local engine performs no network requests. Passing a provider sends data according to that provider's implementation and privacy policy.
- The package does not store prompts. Applications remain responsible for logs, telemetry, retention, consent and human escalation.
AI-generated code
[!NOTE] The 0.2.0 detection engine, lexicons and test suite were generated with AI assistance and reviewed by the maintainer.
What that means in practice:
- Behaviour is measured, not asserted. Every claim in this README is backed by the corpora in
eval/, andnpm run evalreproduces the numbers above. CI fails on regression. - The lexicon is the part to scrutinise. Vocabulary judgements — which terms belong in which tier, which words are too ambiguous to include — are opinions encoded as data. The English sets have had the most attention; the other 20 languages would benefit from native-speaker review, and corrections are welcome.
- Tier assignments are product decisions. Whether
eroticissuggestiverather thanexplicit, oridiotismildrather thanmoderate, is a judgement call you may want to revisit for your use case.thresholdandcustomRulesexist so you do not have to fork to disagree.
If you find a miss or a false positive, the most useful bug report is a line added to the relevant corpus in eval/. See CONTRIBUTING.md.
Development
npm install
npm run check # typecheck, lint, test, build
npm run eval # recall / precision / evasion report
npm run bench # timing report
npm run coverageReleasing
Publishing uses npm trusted publishing through .github/workflows/publish.yml; no long-lived npm token is stored in GitHub.
For a brand-new npm package, publish the first version once from a maintainer machine with a current npm OTP and --provenance=false. Then configure the package's npm trusted publisher. This bootstrap is necessary because the trusted-publisher settings page exists only after the package does.
- Update
versioninpackage.jsonandpackage-lock.json, updateCHANGELOG.md, and merge the tested release commit intomain. - Create a non-prerelease GitHub release whose tag exactly matches the package version, such as
v0.2.0. - GitHub Actions verifies the package and evaluation suite, then publishes it to npm with provenance.
The npm trusted publisher must match user mujtabachang, repository saniprompt, and workflow filename publish.yml.
License
MIT © Ahmed Mujtaba Chang
