ZeroLeaksDocs
Shield SDK

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): ConversationDetectResult

detect() ignores secondaryDetector. Use detectAsync() when you want the verifier to run.

DetectResult

FieldTypeDescription
detectedbooleanWhether anything at or above the threshold was found
risk"none" | "low" | "medium" | "high" | "critical"Highest risk found, "none" when nothing was
matchesArray<{ 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.
scorenumberThe 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

OptionTypeDefaultDescription
threshold"low" | "medium" | "high" | "critical""medium"Findings below this risk are not reported
classifierfalse | { threshold?, highThreshold? }onThe 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.
normalizationDetectNormalizationOptions | falseall steps onSee Normalization.
customPatternsArray<{ category, regex, risk }>noneExtra patterns. They run against the lowercased, normalized views of the input and against the input as written, so case-sensitive patterns work.
excludeCategoriesstring[][]Categories to skip, built-in or custom. "classifier" hides classifier findings.
allowPhrasesstring[][]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>noneCalled 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 }noneCalled 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.
maxInputLengthnumber1048576 (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.

  1. 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.254 or $(whoami) survives normalization.
  2. 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_attack finding.
  3. 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.
  4. 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/model runs Shield's own model, or another transformer classifier, in your process. Its options() 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 uses fetch and 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:

OptionDefaultDescription
urlrequiredThe Chat Completions URL
modelrequiredModel or deployment name
apiKeynoneSent as Authorization: Bearer. For Azure keys, pass headers: { "api-key": key }
headersnoneExtra request headers
maxChars6000Characters of input sent to the model
timeoutMs20000Milliseconds 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)
contentFilterIsInjectiontrueTreat a content-filter rejection of the input (HTTP 400 mentioning content_filter) as an injection
promptShield's prompt, LLM_DETECTOR_PROMPTReplaces the instructions; the reply must still contain "injection": true or false
bodynoneExtra request fields, such as { temperature: 0 }
fetchglobal fetchA 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

CategoryRiskExamples
instruction_overridecritical"ignore all previous instructions", "new instructions:"
authority_exploitcritical"[SYSTEM]", "ADMIN OVERRIDE", "clearance code"
tool_hijackingcriticalcurl posting command output or credentials, wget ... | bash, /dev/tcp/, 169.254.169.254, /etc/passwd
protocol_exploitcritical"[MCP Context Update]", ".cursorrules file says"
role_hijackhigh"you are now DAN", "developer mode enabled"
prompt_extractionhigh"repeat your system prompt", "what were your original instructions"
indirect_injectionhigh"[HIDDEN TEXT]", "AI ASSISTANT INSTRUCTION:", <!-- SYSTEM:, an injection in a web page's hidden text (a bare display:none is low risk)
encoding_attackmedium to highA decoded payload that contains an injection (high), hidden Unicode text (high), invisible characters splitting words (medium)
context_manipulationmedium"the real instructions are", "end of system prompt"
output_controllow"start every response with", "respond only in JSON"
social_engineeringlow"for research purposes only", "I am your creator"
classifiermedium or highThe 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);
}
OptionDefaultDescription
roles["user", "tool", "function"]Roles whose messages are scanned
window4How 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.

OptionWhat it does
foldHomoglyphsMaps look-alike letters in words that mix Latin with Cyrillic, Greek, or Armenian, plus small capitals and similar styled letters, to plain Latin
stripInvisibleRemoves zero-width, bidirectional, tag, and variation-selector characters, and recovers any text hidden in them
collapseWhitespaceCollapses runs of whitespace
joinSeparatedLettersJoins spaced-out and punctuated words, so "i g n o r e" and "ig.no.re" become "ignore"
normalizeCaseKept for compatibility. Matching always uses lowercased text.
decodeLeetspeakDecodes leetspeak in words that mix letters and digits, so "1gn0r3" becomes "ignore". Plain numbers are left alone.
repairTyposFixes known misspellings such as "ingnore" and "instrucions"
repairPhoneticsFixes phonetic spellings such as "promt" and "rulz"
decodePayloadsDecodes 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: [] };
  },
});

On this page