Crux
API Reference@use-crux/core

Safety

Boundary-targeted guardrails, constraints, tool policies, and the per-call Safety session.

import {
  boundary,
  constraint,
  createSafety,
  createSafetyPlugin,
  defaultConstraintFeedbackFormatter,
  evaluateConstraint,
  evaluateGuardrail,
  guardrail,
  toolPolicy,
} from "@use-crux/core/safety";

@use-crux/core/safety is the runtime policy boundary engine. Policies are authored as frozen objects and executed through one per-call Safety session. Adapters construct that session internally; application code usually authors policies and attaches them globally, to prompts/contexts, or per call.

Boundaries

Policies bind to typed boundary descriptors:

HelperBoundary
boundary.input.text()Canonical untrusted text with semantic provenance before the model.
boundary.input.media()Canonical user and tool media immediately before provider normalization.
boundary.input.instructions()Trusted developer and system instructions.
boundary.input.tools()Canonical provider-visible authored or discovered tool definitions.
boundary.output.text()Final model output text.
boundary.output.media()Each canonical model/operation output media part before public accumulation.
boundary.output.object<T>()Parsed structured output object.
boundary.output.both<T>(){ text, object } structured output pair.
boundary.output.object<T>().path('a.b')Typed structured-output path.
boundary.memory.write<T>()A managed-memory candidate immediately before durable commit.
boundary.validation.feedback()Deprecated compatibility target for validation feedback only.

Boundary ids are stable decision/config ids. boundary.stream.* is not a public boundary family, and there is no separate streaming configuration: the boundary itself carries the unit. Refine text with .deltas(), .complete(), .sentences(), .lines(), or .segments(); refine structured output with .path('a.b'), and an array path with .items(). Growing text units permit a hold verdict; closed units (.complete(), the root object, a scalar path, an array item) exclude hold at the type level.

Semantic model ingress

The optionless helpers are the normal choice. They cover every supported semantic source:

const textIngress = boundary.input.text();
const mediaIngress = boundary.input.media();

Filter by source only when a policy genuinely differs by owner. The callback's ctx.origin narrows with the filter:

const retrievalText = boundary.input.text({ from: "retrieval" });
const lifecycleText = boundary.input.text({
  from: ["memory", "handoff", "feedback"],
});
const toolContent = [
  boundary.input.text({ from: "tool" }),
  boundary.input.media({ from: "tool" }),
] as const;

An input boundary is a canonical destination plus semantic provenance. Text supports user, tool, retrieval, memory, handoff, and feedback; media supports user and tool. Managed memory and blackboard state both use source memory, distinguished by ctx.origin.kind. Origin records contain stable, privacy-safe coordinates only, never the guarded text or payload. boundary.input.media({ from: "retrieval" }) is intentionally invalid because retrieval results do not yet have canonical media ingress. Crux never hydrates retrieved assetRef values automatically.

Tool policy and model-input policy are separate, ordered layers:

toolPolicy.result(raw) → toModelOutput → boundary.input.* → model

toolPolicy.result() sees the raw application result. After toModelOutput() (or default conversion), canonical text and media are guarded immediately before the next model call. A custom converter cannot bypass input Safety. JSON output is checked through its deterministic model-facing text representation; Crux does not recursively search arbitrary JSON for media-like values.

Direct retrieval contributions are guarded after rendering with source retrieval. A retrieval tool is ordinary tool output at ingress and therefore has source tool. System role does not establish trust: when retrieval text is folded into a system message, that prefix remains untrusted retrieval text while the adjacent authored system instructions use boundary.input.instructions().

Framework-generated retry text uses source feedback. Prefer boundary.input.text({ from: "feedback" }) for validation feedback, constraint feedback, and rejected output. The deprecated boundary.validation.feedback() remains a validation-feedback-only compatibility target.

Provider-visible tool definitions

boundary.input.tools() receives each canonical name, description, and frozen JSON Schema after authored and discovered tools merge. Filter by provenance or target provider-visible description strings:

const discovered = boundary.input.tools({ from: "discovered" });
const descriptions = boundary.input.tools().descriptions();

Root tool policies return allow, warn, block, or strip. In enforce mode, strip removes the tool from provider exposure and executable registration, while block prevents the call. Description policies are closed text guardrails: rewrites can change the root tool description and schema descriptions, but cannot rename tools or change schema structure. Report mode records intent and preserves the original tool envelope.

Managed memory commits

boundary.memory.write<T>() receives the post-redaction managed-memory candidate:

const memoryCommit = boundary.memory.write<MyMemory>();

The order is block-local redaction, global/prompt/call guardrails, block-local validation, shouldRemember, then persistence. Enforced rewrite replaces the candidate, drop skips persistence without failing generation, and block fails the write. Report mode does not change the candidate or commit.

This gate applies to adapter-managed capture. Standalone memory capture has no originating per-call Safety registry and remains block-local. Direct blackboard writes are not globally guarded by boundary.memory.write().

Multimodal content

Both media helpers infer { part, origin }. Narrow part.type for canonical variant fields and origin.kind for original message, step, or completed- operation coordinates. Indexes refer to the pre-strip canonical arrays and do not shift when an earlier policy removes a sibling.

Media policies support allow, warn, block, and strip; the latter three require a reason. Enforced strips remove optional canonical parts, while report mode records intent without mutation. Removing required media—such as the last generated image, generated speech audio, transcription audio, or an edit mask's final reference—escalates to a block.

See Safety for content primitives for the full primitive/boundary matrix, completed-operation write-back rules, step coverage, and stream temporal caveat.

boundary.input.text() is the projected-text boundary. Text parts remain verbatim while media becomes a bounded descriptor containing its kind and MIME type plus the full URL or byte size and a 12-character SHA-256 prefix. Payloads over 256KB use sha256:omitted, Blob hashes use sha256:unavailable, and file descriptors include the filename.

Guardrails

guardrail(config) creates a policy that can allow, block, warn, rewrite, or hold stream segments. Media guardrails use strip instead of rewrite or hold.

const pii = guardrail({
  id: "pii",
  category: "pii",
  on: [boundary.input.text(), boundary.output.text()],
  run: guardrail.pii({ strategy: "mask" }),
});

Built-in strategies:

  • guardrail.pii({ strategy })
  • guardrail.secrets()
  • guardrail.injection({ action })
  • guardrail.classifier({ classifier, blockWhen, findings })
  • guardrail.media({ mediaTypes, size, sources, action })
  • guardrail.mediaClassifier({ generate, model, categories, threshold, ... })

Text strategies default to sentence-level streaming checks. Classifier and object/path guardrails run at final output unless tuned.

guardrail.media(options)

Creates a callback restricted to media-only boundaries, including an ordered input/output media tuple. At least one rule group is required. Configured rules run in stable mediaTypessizesources order and aggregate all failures into one privacy-safe decision.

Top-level options:

OptionTypeDefaultBehavior
mediaTypesMediaTypeGuardrailRuleomittedMIME allowlist; exact essences and subtype wildcards such as image/*.
sizeMediaSizeGuardrailRuleomittedMaximum locally known or explicitly declared payload size.
sourcesMediaSourceGuardrailRuleomittedSource-category and remote-URL rules. Omitted means no source checks.
action'block' | 'strip''block'Result returned when any configured rule fails.

MIME and size rules:

OptionTypeDefaultBehavior
mediaTypes.allownon-empty readonly MediaTypePattern[]requiredMIME essences such as image/png or image/*; matching is case-normalized and ignores parameters.
mediaTypes.allowUnknownbooleanfalseAllows a part whose MIME type cannot be observed locally.
size.maxBytesnumberrequiredPositive safe-integer byte ceiling.
size.allowUnknownbooleanfalseAllows a part whose size cannot be observed without external I/O.

Source rules:

OptionTypeDefaultBehavior
sources.allowHostsreadonly string[]all hostsExact normalized hostnames. Schemes, ports, paths, queries, userinfo, and wildcards are invalid config.
sources.allowInlinebooleantrueAllows bytes, Blobs, data assets, and data URLs.
sources.allowProviderFilesbooleantrueAllows provider-owned file references; URL rules do not apply to them.
sources.allowUrlUserInfobooleanfalseAllows username/password fields in a URL authority.
sources.allowUrlQuerybooleantrueAllows URL query strings, including signed-URL parameters.

sources: {} enables all source defaults in the table; omitting sources disables source evaluation entirely. Invalid configuration throws SafetyConfigError when the strategy is constructed.

MIME inspection prefers the declared part.mediaType, then source-owned metadata, then a data-URL type. A bounded image signature sniff is used only for undeclared local bytes; it does not verify a declared MIME type against payload contents. Size inspection uses local payload bytes or declared asset/provider metadata. It never fetches remote URLs or calls providers.

The exported public types are MediaGuardrailOptions, MediaTypeGuardrailRule, MediaSizeGuardrailRule, MediaSourceGuardrailRule, MediaGuardrailAction, and MediaTypePattern. The returned callback carries normalized, frozen runtime metadata as { kind: 'guardrail.media', config }. Project Index records authored helper calls with static strategy kind media and includes config only when the whole argument is statically resolvable.

Reasons and observability can contain MIME types and byte counts, but never raw media, URLs, observed hostnames, paths, queries, userinfo, filenames, or provider/file identifiers.

guardrail.mediaClassifier(options)

Creates a provider-neutral classifier callback restricted to input/output media boundaries. It makes one structured-generation call per included canonical image, audio, video, or file/document part. threshold is required and inclusive; thresholds accepts only authored category IDs. Defaults are action: 'block', all four modalities, and exact UnsupportedCapabilityError propagation.

Public types are MediaClassifierOptions, MediaClassifierCategory, MediaClassifierAction, MediaClassifierModality, and MediaClassifierUnsupportedAction. The configured generator receives the selected media and authored descriptions but never the original providerOptions. Ordinary provider, transport, abort, and response-validation errors propagate unchanged, including in report mode.

See the Media classifier guide for provider examples, disclosure and portability guidance, explicit fail-open audit behavior, and the native helper migration.

Constraints

constraint(config) creates a retryable semantic assertion. Constraints run on guarded output candidates.

const citeSources = constraint({
  id: "cite-sources",
  on: boundary.output.text(),
  severity: "assert",
  maxRetries: 2,
  run: async (text) =>
    text.includes("[1]")
      ? { pass: true }
      : { pass: false, feedback: "Include at least one citation." },
});

Built-in strategies include constraint.judge({ judge, minScore }) and constraint.citations(...). Use judge() from @use-crux/core/scoring to create the judge instance, then bridge it through constraint.judge(...) for runtime enforcement.

Tool Policies

toolPolicy() is the Safety-owned surface for tool decisions. It returns ordinary tool middleware so it fits the adapter protocol, but blocked and approval-requested tools carry Safety decision evidence, Project Index rows, and Devtools Explain entries.

const blockDeletes = toolPolicy({
  id: "block-delete",
  match: { tool: "deleteUser" },
  action: "block",
  reason: "Delete user requires an operator override.",
});

const screenResults = toolPolicy.result({
  id: "screen-customer-result",
  match: { tool: "lookupCustomer" },
  run: async ({ output }) =>
    typeof output === "string" && output.includes("ssn")
      ? { action: "block", reason: "Tool result contained sensitive data." }
      : { action: "allow" },
});

Use toolMiddleware() instead for execution plumbing such as logging, timing, caching, retry wrapping, or argument normalization.

Result policies run on the raw value before toModelOutput(). Model-input guardrails then inspect the converted representation, so raw lifecycle controls and model-ingress controls can be composed without evaluating either layer twice.

Registration

Global policies are installed with createSafetyPlugin():

import { config } from "@use-crux/core";
import { createSafetyPlugin } from "@use-crux/core/safety";

config({
  plugins: [
    createSafetyPlugin({ guardrails: [pii], constraints: [citeSources] }),
  ],
});

Prompt, context, and call-site policies compose into the same registry. Duplicate policy ids are invalid by default. Use one guardrail with an on array when the same policy should apply to several boundaries.

Consumption

Adapters consume safety through createSafety(). Custom adapter dialects call:

  1. guardInput() before the provider call.
  2. finalizeOutput() after schema validation.
  3. stamp() before returning metadata.
  4. openStream() for streamed text.
const safety = createSafety({
  call: opts,
  resolved,
  promptId,
  model,
  systemPrompt,
});

({ messages } = await safety.guardInput({ messages }));

const final = await safety.finalizeOutput(output, regenerate, {
  suspended: finishReason === "tool_approval_required",
  messages,
});

const meta = safety.stamp(traceMeta);

finalizeOutput() applies output guardrails before constraints. Regenerated candidates are guarded before constraints re-check them. Structured output rewrites keep returned text and object synchronized or fail closed.

openStream() exposes the streaming sub-protocol:

  • feed(chunk) returns { kind: 'emit', content } or { kind: 'hold', bufferedBy? }. bufferedBy is an optional content-free reason explaining the pause without exposing held bytes — one of 'boundary', 'guardrail', 'serialization', 'constraint', 'validation-retry', 'adapter', reported in that precedence order when several gates are active, and omitted when a hold names no reason.
  • finish() runs .complete() guards, report-mode constraints, and returns the final seal with any unreleased pending text.
  • transform() returns a TransformStream<string, string>.

Tuning And Capture

Per-call safety.tune can change policy posture without replacing policy logic:

await adapter.generate(prompt, {
  input,
  safety: {
    tune: {
      pii: { mode: "report" },
      "cite-sources": { enabled: true },
    },
  },
});

Allowed tune fields are mode and enabled. Unknown ids throw. The streaming unit is not tunable: it belongs to the policy's boundary, so a call site cannot widen what a policy sees.

Safety artifacts participate in observability.capture. Use capture presets and per-artifact overrides when traces leave a trusted boundary. Public audit, errors, and observability records are safe-by-default. Media audit entries add the canonical part type and original pre-strip indexes, never the media source. Model-input decisions use semantic target labels (Model input · Text, Model input · Media, or Model instructions) plus privacy-safe source provenance; they never store guarded content.

Choosing The Primitive

NeedUse
Block, redact, transform, or warn on raw input/outputguardrail()
Enforce business rules on generated output and retryconstraint()
Gate tool calls, tool results, or approvals as Safety decisionstoolPolicy()
Instrument or modify tool execution mechanicstoolMiddleware()
Enforce a minimum LLM-judge score and retryconstraint.judge(...)
Regression-test production Safety behavior in Evalsctx.expect.safety or ctx.expect.decisionReport.safety
Repair JSON/schema parse failuresvalidationRetry

Testing

Use evaluateGuardrail() and evaluateConstraint() for focused policy tests. Use Eval decision-report matchers for runtime behavior:

ctx.expect.decisionReport.safety.toHaveOutcome("pii", "rewrite");

Security Utilities

Input sanitization helpers ship from the core root, not the safety subpath:

import {
  safe,
  raw,
  limit,
  wrap,
  escapeXml,
  truncate,
  userContent,
  detectSuspiciousPatterns,
} from "@use-crux/core";

escapeXml(text)

Replaces the five characters that can break out of XML tag attributes or content:

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

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 "@use-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 SafeWrapper: use 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 SafeWrapper: use 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, so you 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>

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 "@use-crux/core";

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

detectSuspiciousPatterns is a heuristic, not a guarantee: treat it as an extra layer of defense, not the only one.

Security warning records

When generation.securityWarnings is enabled (the default outside production), suspicious input patterns are emitted as canonical security.warning graph records (and related security.report artifacts) with full trace correlation: each warning links to the generate() call that triggered it. emitSecurityWarningSpan opens a real span, not a free-floating span:event. Subscribe to the graph stream for custom integrations:

import { subscribeObservability } from "@use-crux/core/observability";

const unsubscribe = subscribeObservability((record) => {
  // emitSecurityWarningSpan opens a real span (not a free-floating span:event).
  if (record.type === "span:start" && record.primitive === "security.warning") {
    // attributes: promptId, field, pattern, inputPreview
    myLogger.send(record);
  }
  if (record.type === "artifact" && record.kind === "security.report") {
    // JSON preview carries severity, message, field, pattern
    myLogger.send(record);
  }
});

// later: unsubscribe()

On this page