Guardrails
Boundary-targeted policies that allow, block, warn, or rewrite content before unsafe data crosses a runtime boundary.
Guardrails protect runtime boundaries. They run on semantic model ingress,
model output, memory writes, validation feedback, and other Safety surfaces. Use
a guardrail when you need to block, redact, mask, hash, normalize, or report on
content without re-calling the model. Raw tool call/result policy belongs in
toolPolicy.
import { boundary, guardrail } from "@use-crux/core/safety";
export const injectionGuard = guardrail({
id: "prompt-injection",
on: boundary.input.text(),
run: guardrail.injection({ action: "block" }),
});
export const piiGuard = guardrail({
id: "pii",
on: [boundary.input.text(), boundary.output.text()],
run: guardrail.pii({ strategy: "mask" }),
});Attach guardrails globally with createSafetyPlugin(), on prompts and contexts,
or per call with guardrails: [...].
Authoring
Every guardrail has a stable id, one boundary or a boundary array, and a
run(subject, ctx) callback. The boundary drives the subject type:
const redactEmail = guardrail({
id: "email-redactor",
on: boundary.output.text(),
run: async (text, ctx) => {
const next = text.replace(/\S+@\S+/g, "[email]");
if (next === text) return { action: "allow" };
return {
action: "rewrite",
value: next,
rewrite: { kind: "redact" },
findings: [{ type: "email", count: 1 }],
};
},
});Supported result actions:
| Action | Effect |
|---|---|
allow | Continue with the current subject. |
block | Stop the call with GuardrailBlockedError. |
warn | Record a Safety decision without changing content. |
rewrite | Replace the subject. Use rewrite.kind for redact, mask, hash, or normalize. |
hold | Growing text units only (.deltas()/.sentences()/.lines()/.segments()). Wait for more text before releasing the segment. |
strip | Media only. Remove an optional canonical part in enforce mode; report intent without mutation in report mode; block when the part is required. |
Malformed or unknown results fail closed with SafetyResultError, including
when JavaScript callers escape the TypeScript result type.
Built-in Strategies
First-party strategies are provider-agnostic and carry safe strategy metadata for observability, Devtools, and Project Index:
guardrail.pii({ strategy: "redact" });
guardrail.secrets();
guardrail.injection({ action: "warn" });
guardrail.classifier({
classifier: async (subject) => ({ unsafe: subject.includes("risk") }),
blockWhen: (result) => result.unsafe,
});
guardrail.media({
mediaTypes: { allow: ["image/*", "application/pdf"] },
size: { maxBytes: 10 * 1024 * 1024 },
});pii, secrets, and injection are text strategies. They default to
sentence-gated streaming. classifier defaults to final-output checks because
classifier calls are usually too expensive or stateful for every segment.
To score canonical image, audio, video, or file/document parts with any structured-generation provider, see Media classifier guardrails.
Boundaries
Common boundaries:
| Boundary | Subject |
|---|---|
boundary.input.text() | Canonical untrusted text, with semantic provenance, before a provider call. |
boundary.input.media() | One canonical user/tool media part and its stable origin. |
boundary.input.instructions() | Trusted developer/system instructions before the provider call. |
boundary.input.tools() | One canonical provider-visible tool definition. |
boundary.output.text() | Model output text before the caller sees it. |
boundary.output.media() | One canonical model/operation output-media part before public accumulation. |
boundary.output.object<T>() | Parsed structured output. |
boundary.output.object<T>().path('a.b') | One typed structured-output path. |
boundary.memory.write<T>() | A managed-memory candidate immediately before commit. |
Guardrails can bind to several boundaries with on: [...]. A duplicate
boundary in the same guardrail is a configuration error. Duplicate policy ids
across attached policies are also invalid; use one policy with multiple
boundaries when the same policy should apply in several places.
The optionless input helpers are the default and match every supported semantic
source. Use from only for source-specific policy:
const retrievalInjection = guardrail({
id: "retrieval-injection",
on: boundary.input.text({ from: "retrieval" }),
run: guardrail.injection({ action: "block" }),
});Text accepts user, tool, retrieval, memory, handoff, or feedback;
media accepts user or tool. Managed memory and blackboard renderings both
use source memory, distinguished by ctx.origin.kind. Retrieved assets are
never hydrated automatically, so retrieval media has no canonical
model-ingress boundary yet. A system-role carrier does not make retrieval,
memory, blackboard, or handoff text trusted: those rendered contributions keep
their untrusted provenance, while authored system content is checked as
boundary.input.instructions().
Use one source-aware policy for lifecycle content when the same rule applies:
const lifecycleIngress = guardrail({
id: "lifecycle-ingress",
on: boundary.input.text({
from: ["memory", "handoff", "feedback"],
}),
run: guardrail.secrets({ action: "block" }),
});Provider-visible tools
Tool-definition guardrails run after authored and discovered tools have been merged, immediately before provider exposure. A root boundary receives the canonical name, description, and frozen JSON Schema:
const discoveredTools = guardrail({
id: "discovered-tools",
on: boundary.input.tools({ from: "discovered" }),
run: (tool) =>
tool.name.startsWith("public_")
? { action: "allow" }
: { action: "strip", reason: "Only public tools may be exposed." },
});An enforced root strip removes that tool from both the provider-visible list
and executable registration; an enforced block prevents the call. To rewrite
only provider-visible text, target the tool description and every JSON Schema
description independently:
const descriptions = guardrail({
id: "tool-descriptions",
on: boundary.input.tools().descriptions(),
run: (text) => ({
action: "rewrite",
value: text.replaceAll("internal", "available"),
rewrite: { kind: "normalize" },
}),
});Description policies cannot rename tools or alter schema structure. Report mode records strip, block, or rewrite intent and sends the original tool envelope.
Managed memory writes
boundary.memory.write<T>() governs managed adapter capture immediately before
durable commit:
const safeMemory = guardrail({
id: "safe-memory",
on: boundary.memory.write<MyMemory>(),
run: (candidate) =>
candidate.safe
? { action: "allow" }
: { action: "drop", reason: "Do not persist this candidate." },
});The commit order is block-local redaction, global/prompt/call memory guardrails,
block-local validation, shouldRemember, then persistence. Enforced rewrite
replaces the candidate before validation, drop skips persistence without
failing generation, and block fails the write. Report mode preserves the
candidate and commit behavior.
This per-call gate applies only to managed memory capture performed by an adapter. Calling a memory block's standalone capture API has no originating Safety registry and continues to use its block-local policy. Direct blackboard writes likewise do not pass through this global/prompt/call memory-write boundary.
Multimodal Content
Use guardrail.media() with either media boundary—or an input/output media
tuple—for a declarative content policy. Crux checks the real canonical image,
audio, video, or file part at its exact runtime boundary:
import { boundary, guardrail } from "@use-crux/core/safety";
export const safeAttachments = guardrail({
id: "safe-attachments",
on: boundary.input.media(),
run: guardrail.media({
mediaTypes: {
allow: ["image/png", "image/jpeg", "application/pdf"],
},
size: {
maxBytes: 10 * 1024 * 1024,
},
sources: {
allowHosts: ["cdn.example.com"],
allowInline: true,
allowProviderFiles: true,
allowUrlUserInfo: false,
allowUrlQuery: true,
},
action: "block",
}),
});At least one of mediaTypes, size, or sources is required. MIME patterns
accept exact essences and top-level subtype wildcards such as image/*.
Unknown MIME types and sizes fail their configured rule by default; set that
rule's allowUnknown: true only when the application accepts sources whose
metadata cannot be known locally. A raw remote URL normally has unknown size,
while URL assets and provider-file references can supply a declared size.
Source rules use these categories:
- Bytes, Blobs, data assets, and data URLs are inline.
- Provider-owned file references are provider files, not remote hosts.
- Raw URLs,
URLobjects, and URL assets are remote URLs.
allowHosts performs case-normalized exact hostname matching—no wildcards,
paths, ports, or suffix matching. URL authority userinfo is rejected by
default, while query strings (including signed-URL parameters) are allowed by
default. Set allowUrlQuery: false for a stricter posture. Crux does not use
credential-name heuristics.
Omitting sources performs no source checks. Passing sources: {} opts into
the documented baseline: inline and provider files allowed, URL userinfo
blocked, and query strings allowed. Inspection uses only supplied metadata and
local bytes. It never fetches a URL or asks a provider for metadata. Decisions
can report a MIME type or byte counts, but never include the URL, observed
hostname, path, query, userinfo, filename, provider/file id, or payload bytes.
The violation action defaults to block. Set action: 'strip' to remove only
an offending part in enforce mode. In report mode, both actions record what
would happen while leaving the request unchanged:
const attachmentRollout = guardrail({
id: "attachment-rollout",
mode: "report",
on: boundary.input.media(),
run: guardrail.media({
size: { maxBytes: 10 * 1024 * 1024 },
action: "strip",
}),
});Use a guardrail here because attachments are caller input that must be accepted, blocked, or stripped before the first provider call. Constraints are retryable assertions about model output and cannot repair unsafe input. Input media rewrites remain tracked in #212.
For policy logic beyond the built-in rules, provide a custom media callback.
Narrowing part.type exposes that canonical variant's fields; the callback type
is inferred from the boundary without type arguments.
The callback receives { part, origin }. Narrow origin.kind before reading
message, step, or completed-operation coordinates. Those indexes refer to the
original canonical arrays before any earlier policy stripped a sibling, and
the same coordinates appear in audit and safe observability records. The
original part/source identity is passed through unchanged for inspection.
In the default mode: 'enforce', strip changes the messages sent to the
provider. With mode: 'report', it records a would-strip decision but sends the
original part. If an enforced strip removes the final part of a media-only
message, Crux blocks immediately and attributes the block to that policy and
original location.
Media policies accept allow, warn, block, and strip. They cannot stream,
return text actions such as rewrite or hold, or share an on tuple with a
text boundary.
Output media is guarded once per language step and on bounded-operation
results. Reasoning remains output text and tool-call parts cannot be edited.
For image generation, sibling strips reset result.image to the first retained
entry and a final-image strip blocks. Speech audio is required, so an enforced
strip blocks. Report mode preserves all result fields. Unmodified canonical
parts, raw, provider metadata, warnings, and usage retain their identities;
provider-native surfaces remain outside canonical Safety guarantees.
For stream(), output media is completion-only. A block rejects completion,
but text already emitted through textStream cannot be recalled, and the raw
provider stream remains unguarded. See the complete
content-primitive matrix.
Use boundary.input.text() when a policy instead needs the messageText()
projection. Text parts pass through verbatim and media parts become bounded
descriptors:
- URL sources include the full URL, such as
[image image/png https://example.com/chart.png]. - Byte and data sources include their size and a 12-character SHA-256 prefix,
such as
[image image/png 3B sha256:ab12cd34ef56]. Payloads over 256KB usesha256:omitted. - Blob sources include their size and type with
sha256:unavailable. - File descriptors include the filename, such as
[file application/pdf "report.pdf" ...].
For example, this text guard receives both the text and the image URL:
const mediaSourceGuard = guardrail({
id: "media-source",
on: boundary.input.text(),
run: async (subject) => {
// subject: "Describe this image\n[image image/png https://example.com/chart.png]"
return subject.includes("token=")
? { action: "block", reason: "Media URL contains a token." }
: { action: "allow" };
},
});block and warn apply to the complete text projection. rewrite applies only to
text segments: every media placeholder must remain verbatim so Crux can write
the rewritten text back around the original media parts. A rewrite fails
closed with SafetyResultError if it changes a placeholder, targets a
media-only message, or cannot be redistributed unambiguously. Use
boundary.input.media() to inspect or strip the canonical source instead of
rewriting its text placeholder.
Execution
Adapters create one Safety session per generate() or stream() call. The
session:
- Runs input guardrails over every applicable canonical user, converted tool, retrieval, memory, blackboard, handoff, feedback, instruction, and tool- definition contribution before every model call.
- Applies output guardrails before constraints, validation feedback, observability, memory, tools, and final caller access consume generated content.
- Keeps structured
textandobjectsynchronized after rewrites. - Emits safe Safety decisions and audit metadata by default.
For message input, Crux guards string content, every { type: "text", text }
part, and every assistant { type: "reasoning", text } part independently.
Roles, ordering, metadata, provider options, tool inputs, and non-text parts are
preserved. Opaque metadata and tool arguments are not searched recursively.
Guardrails do not re-call the model. Use a constraint when a semantic failure should retry generation with feedback.
Report Mode And Tuning
Use mode: 'report' when a guardrail should audit but not enforce:
const shadowPii = guardrail({
id: "pii-shadow",
mode: "report",
on: boundary.output.text(),
run: guardrail.pii(),
});You can also tune policy posture per call:
await adapter.generate(prompt, {
input,
safety: {
tune: {
"pii-shadow": { mode: "enforce" },
"prompt-injection": { enabled: false },
},
},
});Tune can change only mode and enabled. Unknown policy ids throw so accidental
misspellings do not silently weaken policy. Tune cannot change the streaming unit:
the unit is part of the policy's own definition, so a call site cannot quietly
widen what a guardrail is allowed to see.
Streaming
A guardrail does not have a separate streaming mode. The boundary decides the
unit of text the policy sees, and that same unit is used on generate() and
stream(). One policy, one mental model — a call site cannot widen it.
const pii = guardrail({
id: "pii",
on: boundary.output.text().sentences(),
run: guardrail.pii({ strategy: "mask" }),
});Crux gates the stream at that unit, runs each guardrail stage over the completed unit, then releases only the cleared text. Later guardrails see earlier rewrites.
Choosing the unit
| Refinement | Unit evaluated |
|---|---|
| (none) | Adaptive: one complete result on generate, one canonical delta on stream. |
.deltas() | Each canonical text delta, explicitly. |
.sentences(opts?) | Each complete sentence. |
.lines(opts?) | Each newline-delimited line; a trailing line completes at EOF. |
.segments({ maxCharacters, next }) | Your own deterministic segmenter. |
.complete() | The whole text once, at completion — on a stream this releases nothing until EOF. |
The effective unit resolves in a fixed order: an explicit refinement wins, then a bundled strategy's semantic default, then the adaptive default.
| Bundled strategy | Semantic default |
|---|---|
guardrail.pii(), .secrets(), .injection() | sentence |
guardrail.classifier() | the complete subject |
Media boundaries are not text and take no refinement: each canonical media part is one atomic occurrence.
So guardrail.pii() on an unrefined boundary.output.text() already checks
sentences — you do not need to spell it out. Refine explicitly to override:
// Strategy default (sentence) applies.
guardrail({ id: "pii", on: boundary.output.text(), run: guardrail.pii() });
// Explicit refinement wins over the strategy default.
guardrail({
id: "pii-lines",
on: boundary.output.text().lines(),
run: guardrail.pii(),
});A custom guardrail (no bundled strategy) on an unrefined streaming text
boundary falls through to the adaptive per-delta default, so a multi-character
match can straddle a provider delta. Crux emits a one-time development notice
saying so; any explicit refinement — including .deltas() — silences it, and
production emits nothing.
Holding
hold retains the current unit, coalesces the next fragment, and reruns the
policy from the start for that occurrence. It is available only on growing
units (.deltas(), .sentences(), .lines(), .segments()); closed units
(.complete(), the root object, a scalar path, an array item) exclude hold at
the type level, so an illegal hold is a compile error rather than a runtime
surprise.
Choosing .complete() therefore turns a stream into an all-or-nothing response: the
guardrail cannot judge the text until the text exists, so nothing is released until
the model finishes. Pick a growing unit when tokens should flow.
Held text is bounded: 2,000 characters per growing unit by default, tunable via
maxHold. maxHold.ms is measured on a monotonic clock and has no implicit
default.
guardrail({
id: "policy-phrase",
on: boundary.output.text().sentences({ maxHold: { chars: 500, ms: 2_000 } }),
run: (text) =>
isTruncatedPhrase(text) ? { action: "hold" } : { action: "allow" },
});Reaching a character or time limit — or hitting EOF while still held — fails
closed with StreamHoldLimitError. Held content is never emitted, logged as
public output, or made observable before release.
Structured output
Object and path guardrails run as their unit closes rather than only at the end
of the stream: a scalar path is checked when that value completes, .items() as
each array item closes, and an unrefined object() at completion. Text released
before a rewrite is never taken back, because it is not released until its
guardrail has cleared it.
Errors And Audit
GuardrailBlockedError is policy-terminal and carries safe decision metadata.
Audit and observability records include policy ids, boundaries, action,
duration, findings, and safe capture summaries. Raw pre-safety content is not
included by default.
import { GuardrailBlockedError } from "@use-crux/core/safety";
try {
await adapter.generate(prompt, { input, guardrails: [injectionGuard] });
} catch (error) {
if (error instanceof GuardrailBlockedError) {
error.decisions[0]?.policyId;
error.decisions[0]?.captured.preview;
}
}Use evaluateGuardrail() for focused unit tests and
ctx.expect.decisionReport.safety.toHaveOutcome(...) in Eval checks when
you want to assert on the canonical Safety decision report.