Crux
GuidesSafety

Security

Input sanitization and prompt injection defense.

All string input values are XML-escaped by default, and a safe tagged template gives you per-value control over escaping, truncation, and wrapping.

Auto-escape (default)

All string input values are XML-escaped before reaching system/prompt functions. No code changes needed:

prompt({
  input: z.object({ instruction: z.string() }),
  system: ({ input }) => `Do: ${input.instruction}`,
  // instruction is auto-escaped — </role> becomes &lt;/role&gt;
})

Declare rawFields for trusted content that shouldn't be escaped:

prompt({
  rawFields: ['indexedHtml'],
  input: z.object({ instruction: z.string(), indexedHtml: z.string() }),
  // instruction: escaped, indexedHtml: passed through
})

Auto-escape only covers string values in the input schema. If you inject untrusted content via rawFields or raw(), you are responsible for sanitizing it.

Escape table

escapeXml() replaces the five characters that can break out of XML tag attributes or content:

CharacterReplacement
&&amp;
<&lt;
>&gt;
"&quot;
'&apos;

safe tag (explicit control)

The safe tagged template auto-escapes every interpolated value via escapeXml(). The template literal strings themselves are not escaped — only the dynamic values are:

prompts.ts
import { safe, raw, limit, wrap } from '@crux/core'

safe`
  Doc: ${raw(trustedHtml)}
  Query: ${limit(userQuery, 500)}
  Instruction: ${wrap(instruction)}
`
HelperReturnsEffect
raw(v)SafeWrapperSkip escaping (trusted content)
limit(v, n)SafeWrapperTruncate + escape
wrap(v)SafeWrapperEscape + wrap in <user-input> delimiters

raw(), limit(), and wrap() return SafeWrapper objects, not strings. They only work inside safe tagged templates. Using them in regular template literals (`...${wrap(v)}...`) produces [object Object]. For regular template literals, use userContent() instead.

Helper functions

truncate(text, maxLength?)

Truncates a string to a maximum length. Default maxLength is 10,000. When the text exceeds the limit, the result is sliced and a … [truncated] suffix is appended:

import { truncate } from '@crux/core'

truncate(longArticle) // → first 10,000 chars + "… [truncated]"
truncate(userQuery, 500) // → first 500 chars + "… [truncated]"

raw(text)

Returns a SafeWrapper that safe will not escape. Use only inside safe templates, for content you have already sanitized or that comes from a trusted source:

safe`
  System-generated HTML: ${raw(trustedHtml)}
  User query: ${userQuery}
`
// trustedHtml is interpolated as-is; userQuery is escaped
raw() bypasses all escaping. Only use it when you are certain the content is safe.

limit(text, maxLength?)

Combines truncate and escapeXml in a single call. Returns a SafeWrapperuse only inside safe templates:

safe`Query: ${limit(userQuery, 500)}`
// userQuery is truncated to 500 chars, then XML-escaped

wrap(text, tag?)

Escapes the text and wraps it in <user-input> delimiters. Returns a SafeWrapperuse only inside safe templates. Delimiters make it clear to the model where user content starts and ends, reducing the chance of instruction following from injected content:

safe`Instruction: ${wrap(instruction)}`
// → Instruction: <user-input>escaped content</user-input>

safe`${wrap(feedback, 'customer-feedback')}`
// → <customer-feedback>escaped content</customer-feedback>

Never use wrap() in regular template literals. It returns an object, not a string — you'll get [object Object] in your prompt. Use userContent() instead for regular templates.

userContent(text, tag?)

Standalone version of wrap() that returns a plain string. Escapes the text and wraps it in <user-input> delimiters. Use this in regular template literals or anywhere outside safe templates:

const prompt = `Instruction: ${userContent(instruction)}`
// → Instruction: <user-input>escaped content</user-input>

const prompt = `Feedback: ${userContent(feedback, 'customer-feedback')}`
// → Feedback: <customer-feedback>escaped content</customer-feedback>

Dev warnings

securityWarnings

Security warnings are enabled by default in development (NODE_ENV !== 'production') and disabled in production. When enabled, Crux runs detectSuspiciousPatterns on all input values and logs warnings to the console. Override explicitly if needed:

config({ generation: { securityWarnings: false } }) // disable in dev
config({ generation: { securityWarnings: true } }) // enable in prod

detectSuspiciousPatterns(text)

A dev-time heuristic that returns an array of warning strings. It checks for three categories of suspicious input:

CategoryExamples detected
Role injection</system>, </assistant>, <|im_start|> — attempts to close or open model-level message delimiters
Instruction overrideignore previous, disregard, new instructions — attempts to override system-level instructions
Delimiter manipulation---, ===, *** — attempts to inject visual separators that could confuse prompt structure
validation.ts
import { detectSuspiciousPatterns } from '@crux/core'

const warnings = detectSuspiciousPatterns(userInput)
if (warnings.length > 0) {
  console.warn('Suspicious input:', warnings)
}

detectSuspiciousPatterns is a heuristic, not a guarantee. It catches common patterns but is not foolproof — treat it as an extra layer of defense, not the only one.

Audit trail

Combine middleware with detectSuspiciousPatterns() to log potential injection attempts to your monitoring system:

crux.config.ts
import { detectSuspiciousPatterns } from '@crux/core'

config({
  generation: {
    middleware: async (args, next) => {
      const input = args.preparedArgs?.input
      if (input && typeof input === 'object') {
        for (const [key, value] of Object.entries(input)) {
          if (typeof value === 'string') {
            const patterns = detectSuspiciousPatterns(value)
            if (patterns.length > 0) {
              myLogger.send({
                type: 'security.warning',
                promptId: args.promptId,
                field: key,
                patterns,
                timestamp: new Date().toISOString(),
              })
            }
          }
        }
      }
      return next(args)
    },
  },
})

This logs every suspicious input without blocking the request. Review the logs to identify attack patterns and harden your prompts.

Security events in devtools

When devtools is enabled and securityWarnings is active, suspicious input patterns are automatically emitted as security:warning events to the devtools dashboard. No custom middleware needed.

What you get

  • Timeline badges: Traces with security warnings show a shield badge with count
  • Security filter: Filter the timeline to show only traces with warnings
  • Dashboard stats: Total warning count in the stats overview
  • Dedicated Security tab: Attack timeline, per-prompt vulnerability matrix, and highlighted input previews
  • CLI TUI: Warning count in the crux dev --tui stats panel

How it works

Security warnings are emitted from the devtools middleware with full trace correlation — each warning links to the generate() call that triggered it. The onSecurityWarning instrumentation hook is also available for custom integrations:

import { updateRuntime } from '@crux/core'

updateRuntime({
  instrumentationHooks: {
    onSecurityWarning: (event) => {
      // event: { promptId, field, pattern, message, inputPreview }
      myLogger.send(event)
    },
  },
})

Next steps

On this page