Defensive tooling for LLM input
unsmuggle
Undo ASCII smuggling. Zero dependencies, honest boundaries.
Hidden instructions don't just get removed — they get handed back to you, so you can log what someone tried to smuggle.
There is hidden text in the line below
What a human reviewer sees
Please summarize this document.
What normalize() hands back
result.text; // 'Please summarize this document.'
result.hadHidden; // true
result.revealed; // [{ scheme: 'unicode-tags', text: 'hello' }]
Select the specimen line and you'll find five characters you can't see. They are
U+E0068 U+E0065 U+E006C U+E006C U+E006F — the Unicode Tags block mirrors
printable ASCII at U+E0000 + codepoint. It renders as nothing in
browsers, terminals, editors, chat UIs and code-review tools, while a tokenizer reads it
as ordinary text. That defeats the primary human defense against indirect injection:
looking at the content.
The payload here spells hello on purpose. A page that shipped a live
injection string would be an attack on whatever reads it.
What this library does not do
It does not prevent prompt injection. No filter can.
XSS is solvable because HTML has a formal grammar: < becomes
< and the parser is unambiguous. An LLM prompt has no grammar
separating instructions from data — both are just tokens — so there is no escaping
primitive. Filter-based defenses fall to paraphrase, since there are unlimited ways to
write “ignore previous instructions.”
What does work is architectural rather than textual. Designs like CaMeL give untrusted data no path to influence control flow and carry formal guarantees for a defined threat model, at a measured cost in task completion. unsmuggle is not that, and is not a substitute for it.
What unsmuggle does is narrower and real: it removes an entire channel, and it keeps the confidence level of each layer visible in the API, because conflating them is how security libraries mislead people.
Three layers, three different guarantees
| Layer | Guarantee | Use it as |
|---|---|---|
normalize() |
Deterministic a defined codepoint set is provably absent from the output | A hard control |
spotlight() |
Measured reduction published ASR falls from >50% to <2% | A strong mitigation |
detect() |
Advisory only defeated by paraphrase | Logging and triage, never a gate |
Install
npm install unsmuggle
Zero dependencies. Ships ESM and CommonJS.
Layer 1 — normalize()
Invisible Unicode is the one slice of this problem that is a character-set issue rather than a semantics issue — which is why it can be solved outright.
import { normalize } from 'unsmuggle';
const result = normalize(input);
result.text; // the visible text, hidden codepoints gone
result.hadHidden; // true
result.revealed; // [{ scheme, text }] — what they tried to smuggle
result.removed; // [{ codepoint, label, name, category, index }, ...]
revealed is the high-signal field: it means someone
deliberately smuggled readable instructions, not that stray formatting
characters drifted in.
Three smuggling encodings, all decoded
| Scheme | Encoding |
|---|---|
unicode-tags | U+E0000 + ASCII |
zero-width-binary | U+200B = 0, U+200C = 1, 8 bits per character |
variation-selector | byte 0–15 → U+FE00–FE0F, 16–255 → U+E0100+ |
Covering only one leaves most of the ecosystem exposed. Variation-selector smuggling is the vector guardrails miss most often — the tokenizer strips the selectors before the classifier runs, so the classifier sees clean text while the model receives the whole payload. unsmuggle scans the decoded payload for exactly that reason.
Emoji are not collateral damage
U+200D and U+FE0F are legitimate in emoji — 👨👩👧 is three people joined by ZWJ, and ❤️ is U+2764 U+FE0F. Blanket stripping mangles real user text, so unsmuggle keeps them inside a genuine emoji sequence:
normalize('👨👩👧 ❤️').hadHidden; // false — untouched
normalize('ab').text; // 'ab' — a bare ZWJ between letters is not emoji
Likewise, confusable folding applies only to words that mix scripts (Unicode TR39). Здравствуйте is ordinary Cyrillic prose, not an attack, and is left alone — punishing everyone who writes in a non-Latin script would be a worse bug than the one being fixed.
Layer 2 — spotlight()
Implements Hines et al., Defending Against Indirect Prompt Injection Attacks With Spotlighting.
const { text, systemPrompt } = spotlight(untrustedDocument);
text; // 'Summarize^this^document^please'
You must send the systemPrompt too. Marked text alone does
nothing — the model has to be told the scheme. Returning both is deliberate, because
omitting the explanation is the most common way to deploy spotlighting and get no benefit.
| Mode | GPT-3.5-Turbo | Text-003 |
|---|---|---|
| baseline (none) | ~60% | ~40% |
delimit | ~30% | — |
datamark default | 3.1% | 0.0% |
encode | 0.0% | 0.0% |
Published attack success rates, from the paper. Lower is better.
Layer 3 — detect()
const result = detect(untrusted);
result.score; // 0–1. NOT a probability.
result.signals; // [{ id, description, weight, match }]
result.advisory; // always true
advisory: true is in the type so downstream code can't pretend the
value is authoritative. A low score is not evidence of safety. Use it to
log, sample or route to review — never to gate.
Rules cover instruction override, role reassignment, system-prompt spoofing, exfiltration, secret solicitation, tool coercion, encoded payloads and compliance priming. Detection runs against the normalized text and any decoded hidden payload, since that is where the incriminating content usually lives.
guard() — all three layers
const { text, systemPrompt, normalization, detection } = guard(untrusted);
// Your policy, your call — the library never refuses or throws:
if (detection.score > 0.7) logForReview(detection.signals);
if (normalization.revealed.length) alertSecurity(normalization.revealed);
const messages = [
{ role: 'system', content: `${myInstructions}\n\n${systemPrompt}` },
{ role: 'user', content: text },
];
Benchmark, with its calibration rows left in
| Implementation | Neutralized | Benign kept |
|---|---|---|
| unsmuggle | 100% | 100% |
() => '' calibration | 100% | 0% |
v => v calibration | 5% | 100% |
strip - only calibration | 20% | 93.3% |
20 hidden-instruction payloads · 15 benign documents. The null row is the point: a function that deletes everything “neutralizes” 100%, which is why fidelity sits beside it. The naive row shows why a partial codepoint list is insufficient — it misses the Tags block entirely and breaks emoji.
Deliberately not measured: “% of prompt injection prevented.” That cannot be measured against a fixed corpus, because paraphrase is unbounded.