Local detect()
Prompt injection detection for user messages, documents, and tool results. Normalization, hidden-payload decoding, patterns, and a built-in classifier, in-process with no model download.
This page describes detect, detectAsync, and detectConversation from @zeroleaks/shield/local. The root detect export is asynchronous and calls the hosted API; see the Shield API quickstart.
Checks text for prompt injection. Returns detected, risk, matches, and the classifier's score.
Use it on everything that reaches the model's context: user messages, retrieved documents, web pages, emails, and tool results. The provider wrappers do this for user messages and tool results automatically.
API
function detect(input: string, options?: DetectOptions): DetectResult
function detectAsync(input: string, options?: DetectOptions): Promise<DetectResult>
function detectConversation(messages: ConversationMessage[], options?: ConversationDetectOptions): ConversationDetectResultdetect() ignores secondaryDetector. Use detectAsync() when you want the verifier to run.
DetectResult
| Field | Type | Description |
|---|---|---|
detected | boolean | Whether anything at or above the threshold was found |
risk | "none" | "low" | "medium" | "high" | "critical" | Highest risk found, "none" when nothing was |
matches | Array<{ category, pattern, confidence }> | One entry per category found. For patterns, pattern is the start of the regex source, prefixed with the encoding when it matched a decoded payload (base64:...). For the classifier, category is "classifier", pattern is model:<version>, and confidence is its probability. |
score | number | The classifier's injection probability, 0 to 1: the highest score of any part of the input or of any payload decoded from it. 0 when the classifier is off. |
Options
| Option | Type | Default | Description |
|---|---|---|---|
threshold | "low" | "medium" | "high" | "critical" | "medium" | Findings below this risk are not reported |
classifier | false | { threshold?, highThreshold? } | on | The built-in classifier. A probability at or above threshold is a medium-risk detection, and at or above highThreshold a high-risk one. false leaves only patterns and decoding. |
normalization | DetectNormalizationOptions | false | all steps on | See Normalization. |
customPatterns | Array<{ category, regex, risk }> | none | Extra patterns. They run against the lowercased, normalized views of the input and against the input as written, so case-sensitive patterns work. |
excludeCategories | string[] | [] | Categories to skip, built-in or custom. "classifier" hides classifier findings. |
allowPhrases | string[] | [] | Phrases removed from the input before scanning, so they can't trigger a finding. The rest of the input is still scanned. |
secondaryDetector | (input, result) => Promise<DetectResult | null> | none | Called by detectAsync() when something was detected. Return a replacement result, for example after an LLM check, or null to keep the original. |
escalate | { minScore?, detector } | none | Called by detectAsync() when nothing was detected but the classifier's score is at least minScore (default 0.15): pass input the classifier is unsure about to a slower detector, such as a transformer model or an LLM call. Return a result to use instead, or null to keep Shield's. With classifier: false and minScore: 0, every input the patterns don't flag goes to the detector, which is what model.options() sets. The provider wrappers can run it at the same time as the model call with parallelDetection. |
maxInputLength | number | 1048576 (1MB) | Input longer than this is cut |
How it works
Every input goes through four layers. The whole input is scanned, in overlapping 8KB windows, not just a prefix.
- Normalization. Unicode is folded (NFKC, accents, look-alike letters inside words that mix scripts, small capitals), invisible characters are removed, and a second view undoes common obfuscation: spaced-out letters, separators inside words, leetspeak, and misspellings. Views that keep digits and symbols are matched too, so an address like
169.254.169.254or$(whoami)survives normalization. - Hidden payloads. Base64, hex, binary, decimal character codes, URL encoding, HTML entities, escape sequences, ROT13, Morse, Braille, reversed and upside-down text, and text smuggled in Unicode tag characters or variation selectors are decoded. The decoded text is scanned like the input. A payload that decodes to an injection adds a high-risk
encoding_attackfinding. - Patterns. A pattern library for well-known phrasings. Each pattern only runs when a literal it needs is in the text, so most never run on benign input.
- Classifier. A logistic-regression model over hashed character and word n-grams, trained on public labeled datasets and shipped inside the package as 4-bit weights. Long text is scored in overlapping windows of about 500 characters and the highest window counts, so an instruction buried in a long document isn't averaged away.
A short chat message takes about 40µs and an 8KB document about 2ms on a server CPU.
To use a locally configured transformer model instead of the built-in classifier, call detectAsync(text, model.options()). See local model detection. Accuracy and latency depend on the artifacts and hardware.
HTML
For input with eight or more HTML elements, such as a web page or an HTML email, Shield also extracts the text an agent would read: text, comments, and descriptive attributes such as alt and title, without markup, scripts, or styles. Patterns run on both the markup and the extracted text; the classifier scores the text. Text inside hidden elements (display: none, visibility: hidden, zero size or opacity, the hidden attribute) is scanned separately. An injection found there produces a high-risk indirect_injection finding with pattern hidden_html_text. A hidden element alone isn't a finding: ordinary pages have many.
Slower detectors
The escalate option in detectAsync() sends input to a detector you provide. Shield includes three detectors for this option:
createModelDetector()from@zeroleaks/shield/modelruns Shield's own model, or another transformer classifier, in your process. Itsoptions()method returns the options that use it in place of the built-in classifier.createLlmDetector()asks an LLM, over any OpenAI-compatible Chat Completions endpoint (OpenAI, Azure AI Foundry, OpenRouter, or a local server), whether the text is an injection. It usesfetchand adds no dependency.anyOf(...detectors)runs several detectors at once and reports a detection as soon as one of them detects.
import { anyOf, createLlmDetector } from "@zeroleaks/shield";
import { detectAsync } from "@zeroleaks/shield/local";
import { createModelDetector } from "@zeroleaks/shield/model";
const judge = createLlmDetector({
url: "https://api.openai.com/v1/chat/completions",
model: "gpt-5.6-luna",
apiKey: process.env.OPENAI_API_KEY,
});
const result = await detectAsync(text, {
escalate: { minScore: 0, detector: anyOf(createModelDetector(), judge) },
});createLlmDetector() options:
| Option | Default | Description |
|---|---|---|
url | required | The Chat Completions URL |
model | required | Model or deployment name |
apiKey | none | Sent as Authorization: Bearer. For Azure keys, pass headers: { "api-key": key } |
headers | none | Extra request headers |
maxChars | 6000 | Characters of input sent to the model |
timeoutMs | 20000 | Milliseconds before the call is abandoned |
onError | "allow" | What a failed call or an unreadable reply means: "allow" treats the input as clean, "block" as an injection, "throw" rejects with a ShieldError (code LLM_DETECTOR_FAILED) |
contentFilterIsInjection | true | Treat a content-filter rejection of the input (HTTP 400 mentioning content_filter) as an injection |
prompt | Shield's prompt, LLM_DETECTOR_PROMPT | Replaces the instructions; the reply must still contain "injection": true or false |
body | none | Extra request fields, such as { temperature: 0 } |
fetch | global fetch | A fetch implementation |
An LLM call takes about a second or more, so escalating every input to one adds that much to each request. The provider wrappers can hide it behind the model call with parallelDetection. The text is sent to the LLM's provider, so choose one you would trust with your users' messages and tool results.
Pattern categories
| Category | Risk | Examples |
|---|---|---|
instruction_override | critical | "ignore all previous instructions", "new instructions:" |
authority_exploit | critical | "[SYSTEM]", "ADMIN OVERRIDE", "clearance code" |
tool_hijacking | critical | curl posting command output or credentials, wget ... | bash, /dev/tcp/, 169.254.169.254, /etc/passwd |
protocol_exploit | critical | "[MCP Context Update]", ".cursorrules file says" |
role_hijack | high | "you are now DAN", "developer mode enabled" |
prompt_extraction | high | "repeat your system prompt", "what were your original instructions" |
indirect_injection | high | "[HIDDEN TEXT]", "AI ASSISTANT INSTRUCTION:", <!-- SYSTEM:, an injection in a web page's hidden text (a bare display:none is low risk) |
encoding_attack | medium to high | A decoded payload that contains an injection (high), hidden Unicode text (high), invisible characters splitting words (medium) |
context_manipulation | medium | "the real instructions are", "end of system prompt" |
output_control | low | "start every response with", "respond only in JSON" |
social_engineering | low | "for research purposes only", "I am your creator" |
classifier | medium or high | The classifier's probability crossed threshold or highThreshold |
Low-risk categories only run when threshold is "low" and the input shows another sign of an attack, such as a keyword like "system" or "ignore", two or more suspicious words, or bracketed markers like [SYSTEM].
Conversations
detectConversation() scans every user and tool message on its own, and also the latest user messages joined together, which catches an instruction split across turns.
import { detectConversation } from "@zeroleaks/shield/local";
const result = detectConversation(messages, { window: 4 });
if (result.detected) {
console.warn("flagged messages:", result.flagged.map((f) => f.index));
console.warn("split across turns:", result.splitAcrossTurns);
}| Option | Default | Description |
|---|---|---|
roles | ["user", "tool", "function"] | Roles whose messages are scanned |
window | 4 | How many of the latest user messages are also scanned joined together. 0 or 1 turns it off. |
It takes every detect() option as well.
False positives
The classifier can flag benign text despite training on examples that use the same words as attacks (instructions, passwords, "act as" prompts, formatting requests). Short imperative requests to the model, such as "from now on, always answer in French", are the most likely to be flagged.
Since the wrappers block by default, try your real traffic with onDetection: "warn" first. Then raise classifier.threshold, use excludeCategories or allowPhrases, or add a secondaryDetector for the cases you see.
Normalization
All steps are on by default. Pass an object to turn individual steps off, or false to turn them all off.
| Option | What it does |
|---|---|
foldHomoglyphs | Maps look-alike letters in words that mix Latin with Cyrillic, Greek, or Armenian, plus small capitals and similar styled letters, to plain Latin |
stripInvisible | Removes zero-width, bidirectional, tag, and variation-selector characters, and recovers any text hidden in them |
collapseWhitespace | Collapses runs of whitespace |
joinSeparatedLetters | Joins spaced-out and punctuated words, so "i g n o r e" and "ig.no.re" become "ignore" |
normalizeCase | Kept for compatibility. Matching always uses lowercased text. |
decodeLeetspeak | Decodes leetspeak in words that mix letters and digits, so "1gn0r3" becomes "ignore". Plain numbers are left alone. |
repairTypos | Fixes known misspellings such as "ingnore" and "instrucions" |
repairPhonetics | Fixes phonetic spellings such as "promt" and "rulz" |
decodePayloads | Decodes hidden payloads (base64, hex, and the rest) and scans them |
detect(input, { normalization: { decodeLeetspeak: false } });
detect(input, { normalization: false });Example
import { detect, detectAsync } from "@zeroleaks/shield/local";
const result = detect(userInput);
if (result.detected) {
console.log(`Injection detected: ${result.risk} risk, score ${result.score?.toFixed(2)}`);
for (const m of result.matches) {
console.log(` - ${m.category}: ${m.pattern}`);
}
}
// A retrieved document or tool result
const doc = detect(pageText);
// Only report high and critical findings
const strict = detect(userInput, { threshold: "high" });
// Stricter classifier
const fewerFlags = detect(userInput, { classifier: { threshold: 0.8 } });
// Add a custom pattern
const withCustom = detect(userInput, {
customPatterns: [{ category: "internal_marker", regex: /\[internal:\s*.+\]/i, risk: "critical" }],
});
// Let a second check overrule the result
const verified = await detectAsync(userInput, {
secondaryDetector: async (input) => {
const isInjection = await classifyWithLLM(input); // your own check
return isInjection ? null : { detected: false, risk: "none", matches: [] };
},
});harden()
Add security rules to system prompts. Persona anchoring, rules for agents that call tools, anti-extraction directives, canary tokens, and spotlighting for untrusted content.
Customizing local detection
How to change what Shield detects and how strictly, from one sensitivity switch to your own phrases, patterns, categories, models, and LLM checks, per message source and per wrapper.