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:
| Helper | Boundary |
|---|---|
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.* → modeltoolPolicy.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 mediaTypes → size →
sources order and aggregate all failures into one privacy-safe decision.
Top-level options:
| Option | Type | Default | Behavior |
|---|---|---|---|
mediaTypes | MediaTypeGuardrailRule | omitted | MIME allowlist; exact essences and subtype wildcards such as image/*. |
size | MediaSizeGuardrailRule | omitted | Maximum locally known or explicitly declared payload size. |
sources | MediaSourceGuardrailRule | omitted | Source-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:
| Option | Type | Default | Behavior |
|---|---|---|---|
mediaTypes.allow | non-empty readonly MediaTypePattern[] | required | MIME essences such as image/png or image/*; matching is case-normalized and ignores parameters. |
mediaTypes.allowUnknown | boolean | false | Allows a part whose MIME type cannot be observed locally. |
size.maxBytes | number | required | Positive safe-integer byte ceiling. |
size.allowUnknown | boolean | false | Allows a part whose size cannot be observed without external I/O. |
Source rules:
| Option | Type | Default | Behavior |
|---|---|---|---|
sources.allowHosts | readonly string[] | all hosts | Exact normalized hostnames. Schemes, ports, paths, queries, userinfo, and wildcards are invalid config. |
sources.allowInline | boolean | true | Allows bytes, Blobs, data assets, and data URLs. |
sources.allowProviderFiles | boolean | true | Allows provider-owned file references; URL rules do not apply to them. |
sources.allowUrlUserInfo | boolean | false | Allows username/password fields in a URL authority. |
sources.allowUrlQuery | boolean | true | Allows 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:
guardInput()before the provider call.finalizeOutput()after schema validation.stamp()before returning metadata.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? }.bufferedByis 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 unreleasedpendingtext.transform()returns aTransformStream<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
| Need | Use |
|---|---|
| Block, redact, transform, or warn on raw input/output | guardrail() |
| Enforce business rules on generated output and retry | constraint() |
| Gate tool calls, tool results, or approvals as Safety decisions | toolPolicy() |
| Instrument or modify tool execution mechanics | toolMiddleware() |
| Enforce a minimum LLM-judge score and retry | constraint.judge(...) |
| Regression-test production Safety behavior in Evals | ctx.expect.safety or ctx.expect.decisionReport.safety |
| Repair JSON/schema parse failures | validationRetry |
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:
| Character | Replacement |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
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 escapedraw() 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-escapedwrap(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:
| Category | Examples detected |
|---|---|
| Role injection | </system>, </assistant>, <|im_start|>: attempts to close or open model-level message delimiters |
| Instruction override | ignore previous, disregard, new instructions: attempts to override system-level instructions |
| Delimiter manipulation | ---, ===, ***: attempts to inject visual separators that could confuse prompt structure |
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()Related
- Guide: Safety
- Guide: Guardrails
- Guide: Constraints
- Guide: Security