unsmuggle

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 &lt; 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

LayerGuaranteeUse 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

SchemeEncoding
unicode-tagsU+E0000 + ASCII
zero-width-binaryU+200B = 0, U+200C = 1, 8 bits per character
variation-selectorbyte 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('a‍b').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.

ModeGPT-3.5-TurboText-003
baseline (none)~60%~40%
delimit~30%
datamark default3.1%0.0%
encode0.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

ImplementationNeutralizedBenign kept
unsmuggle100%100%
() => '' calibration100%0%
v => v calibration5%100%
strip ​-‍ only calibration20%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.