Crux
GuidesSafety

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.

guardrails.ts
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:

ActionEffect
allowContinue with the current subject.
blockStop the call with GuardrailBlockedError.
warnRecord a Safety decision without changing content.
rewriteReplace the subject. Use rewrite.kind for redact, mask, hash, or normalize.
holdGrowing text units only (.deltas()/.sentences()/.lines()/.segments()). Wait for more text before releasing the segment.
stripMedia 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:

BoundarySubject
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:

safe-attachments.ts
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, URL objects, 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 use sha256: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:

  1. Runs input guardrails over every applicable canonical user, converted tool, retrieval, memory, blackboard, handoff, feedback, instruction, and tool- definition contribution before every model call.
  2. Applies output guardrails before constraints, validation feedback, observability, memory, tools, and final caller access consume generated content.
  3. Keeps structured text and object synchronized after rewrites.
  4. 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

RefinementUnit 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 strategySemantic 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.

On this page