Skip to content

Guardrails

Guardrails validate data at every stage of agent execution — before the LLM sees user input, after the LLM responds, and around tool calls. They can block unsafe content, enforce schemas, detect PII, or run any custom validation logic.

Guardrails vs Middleware. Guardrails assert (pass/fail, block/allow). Middleware transforms (trim history, redact, rewrite). Use a guardrail when you want a policy check that raises on failure; use middleware when you want to change the data flowing through the pipeline. Input guardrails run before middleware's before_model; output guardrails run after middleware's after_model.

How Guardrails Work

User Input → [Input Guardrails] → LLM → [Output Guardrails] → Response
                                    ↕
                            [Tool Call Guardrails]
                                    ↕
                            [Tool Result Guardrails]
  1. Input guardrails run before the LLM sees the user's message
  2. Output guardrails run on the LLM's response before returning to the user
  3. Tool call guardrails run on tool arguments before execution
  4. Tool result guardrails run on tool output before sending back to the LLM

If a blocking guardrail fails, execution stops immediately with GuardrailBlockedError.

Built-in Guardrails

Ready-to-use factories cover common safety needs:

no_pii()

Detects SSNs, email addresses, phone numbers, and credit card numbers. Credit cards are validated with the Luhn checksum so random 16-digit strings don't trip a false positive. Shares its detector with the PIILeakage scorer.

from fastaiagent.guardrail import no_pii, GuardrailPosition

# On output (default) — catches PII in LLM responses
agent = Agent(guardrails=[no_pii()])

# On input — blocks users from sending PII to the LLM
agent = Agent(guardrails=[no_pii(position=GuardrailPosition.input)])

# Opt into extra entity types, or the Presidio backend (needs [safety] extra)
agent = Agent(guardrails=[no_pii(entities=("email", "phone", "ssn", "credit_card", "ip"))])

Detected patterns: | Type | Pattern Example | |------|----------------| | SSN | 123-45-6789 | | Email | user@example.com | | Phone | 555-123-4567 | | Credit Card | 4111 1111 1111 1111 (Luhn-validated) | | ip / iban | opt-in via entities= |

no_prompt_injection()

Blocks prompt-injection / jailbreak attempts — input that tries to override, ignore, or extract the system instructions ("ignore all previous instructions", "reveal your system prompt", DAN, role-overrides, delimiter attacks). Defaults to the input position. Zero-dependency heuristic mode by default; opt into an LLM classifier with mode="llm". Shares its detector with the PromptInjection scorer.

from fastaiagent.guardrail import no_prompt_injection

# Blocks malicious user input before the LLM ever sees it
agent = Agent(guardrails=[no_prompt_injection()])

# Opt into the LLM-classifier mode (costs a call, catches more)
agent = Agent(guardrails=[no_prompt_injection(mode="llm")])

# Or the Decisions API (1.84.0): a calibrated probability as the score
agent = Agent(guardrails=[no_prompt_injection(mode="decisions")])

openai_moderation()

Blocks content flagged by the OpenAI moderation endpoint. Defaults to the output position. Requires the openai package and an API key.

from fastaiagent.guardrail import openai_moderation

agent = Agent(guardrails=[openai_moderation()])

json_valid()

Ensures the LLM's output is valid JSON. Useful for agents that must return structured data.

from fastaiagent.guardrail import json_valid

agent = Agent(
    system_prompt="Always respond with valid JSON.",
    guardrails=[json_valid()],
    llm=LLMClient(provider="openai", model="gpt-4.1"),
)

toxicity_check()

Keyword-based detection of toxic or harmful language by default. mode="llm" asks a chat model for a 0..1 score, and mode="decisions" (1.84.0) uses the Decisions API's probability that the text is toxic. Either blocks at threshold.

from fastaiagent.guardrail import toxicity_check

agent = Agent(guardrails=[toxicity_check()])
agent = Agent(guardrails=[toxicity_check(mode="decisions", threshold=0.5)])

cost_limit()

Policy marker for enforcing cost limits on agent execution.

from fastaiagent.guardrail import cost_limit

agent = Agent(guardrails=[cost_limit(max_usd=0.50)])

allowed_domains()

Restricts URLs in tool calls to a whitelist of domains. Position defaults to tool_call.

from fastaiagent.guardrail import allowed_domains

agent = Agent(
    guardrails=[allowed_domains(["api.mycompany.com", "internal.service.local"])],
    tools=[my_rest_tool],
)

# Tool calls to https://evil.com will be blocked
# Tool calls to https://api.mycompany.com/data will pass

Custom Guardrails

Inline Function

The simplest way — pass a function that returns True (pass) or False (block):

from fastaiagent.guardrail import Guardrail, GuardrailPosition

# Block responses longer than 500 characters
length_guard = Guardrail(
    name="max_length",
    position=GuardrailPosition.output,
    blocking=True,
    fn=lambda text: len(text) < 500,
)

# Block input containing specific keywords
keyword_guard = Guardrail(
    name="no_competitor_names",
    position=GuardrailPosition.input,
    blocking=True,
    fn=lambda text: not any(name in text.lower() for name in ["competitor_a", "competitor_b"]),
)

Returning GuardrailResult

For richer feedback, return a GuardrailResult with score and message:

from fastaiagent.guardrail import Guardrail, GuardrailResult

def check_quality(text: str) -> GuardrailResult:
    word_count = len(text.split())
    if word_count < 10:
        return GuardrailResult(
            passed=False,
            score=word_count / 10,
            message=f"Response too short ({word_count} words, minimum 10)",
        )
    return GuardrailResult(passed=True, score=1.0)

quality_guard = Guardrail(name="quality_check", fn=check_quality)

Ten Implementation Types

Beyond inline functions, guardrails support nine more implementation types for configuration-driven validation. Three of them — content_safety, groundedness and topic — are model-backed judges with structure; two more — pii and secrets — are detector-backed and are the only types that can genuinely redact. All five are documented in full under Actions, severity & floor — the judges under Three model-backed check types, the detectors under Two detector-backed check types:

Code (default)

Python function execution, as shown above. Always pass the callable via fn=. The legacy config={"code": "..."} string-execution path was removed in 1.10.0 because its sandbox was bypassable; supplying a code string now fails closed without executing anything.

Guardrail(
    name="custom_check",
    guardrail_type=GuardrailType.code,
    fn=lambda text: "confidential" not in text.lower(),
)

Migrating from config={"code": "..."} (1.9.0 → 1.10.0)

If you previously wrote a guardrail by passing a Python string through config — e.g. loading guardrail definitions from a YAML/JSON file — move the logic into a real function and pass it via fn=. Three common shapes:

from fastaiagent.guardrail import Guardrail, GuardrailType, GuardrailResult

# 1. One-liner: lambda is enough.
#
#    BEFORE (no longer executes — fails closed):
#      Guardrail(
#          name="no_secret",
#          guardrail_type=GuardrailType.code,
#          config={"code": "result = 'secret' not in data"},
#      )
#
#    AFTER:
no_secret = Guardrail(
    name="no_secret",
    fn=lambda text: "secret" not in text.lower(),
)


# 2. Multiple checks + custom message: use a named function.
#
#    BEFORE:
#      Guardrail(
#          name="length_band",
#          guardrail_type=GuardrailType.code,
#          config={"code": "result = 10 <= len(data) <= 500"},
#      )
#
#    AFTER:
def length_band(text: str) -> GuardrailResult:
    n = len(text)
    if n < 10:
        return GuardrailResult(passed=False, message=f"Too short ({n} chars)")
    if n > 500:
        return GuardrailResult(passed=False, message=f"Too long ({n} chars)")
    return GuardrailResult(passed=True, score=1.0)

length_guard = Guardrail(name="length_band", fn=length_band)


# 3. Loading guardrails from config files: import the callable by name
#    instead of embedding source code in YAML/JSON.
#
#    Recommended: ship a small registry module the loader can resolve,
#    e.g. ``my_app.guardrails:no_secret``. The loader looks up the
#    callable and passes it via ``fn=``.

If you cannot move the logic into Python (e.g. the rules genuinely live in user-supplied configuration), reach for a non-code guardrail type instead — GuardrailType.regex, .schema, or .classifier cover the same cases declaratively without executing arbitrary code:

# Pattern check expressed declaratively — no code execution at all.
no_secret = Guardrail(
    name="no_secret",
    guardrail_type=GuardrailType.regex,
    config={"pattern": r"\bsecret\b", "should_match": False, "case_insensitive": True},
)

Regex

Pattern matching without writing a function.

pattern is required as of 1.64.0

An empty or missing pattern now raises. re.search("", text) returns a zero-width match at position 0, so the rule used to fail every payload under the default should_match=False — and not as errored, so on_error="allow" could not rescue it and the console showed a genuine block verdict rather than a broken rule. With should_match=True it passed everything instead.

If a rule authored on a control plane starts reporting could not run, check whether it uses the legacy patterns list rather than pattern: the plane falls back to it, the SDK reads only pattern, and such a rule previously blocked 100% of traffic at the edge.

from fastaiagent.guardrail import Guardrail, GuardrailType

# Block output containing URLs
no_urls = Guardrail(
    name="no_urls",
    guardrail_type=GuardrailType.regex,
    config={
        "pattern": r"https?://[^\s]+",
        "should_match": False,       # Fail if pattern IS found
        "case_insensitive": True,
    },
)

# Require output to contain a reference number
has_ref = Guardrail(
    name="has_reference",
    guardrail_type=GuardrailType.regex,
    config={
        "pattern": r"REF-\d{6}",
        "should_match": True,        # Fail if pattern is NOT found
    },
)

Regex guardrails run on a ReDoS-resistant engine under a hard timeout, so a catastrophic-backtracking pattern fails closed instead of hanging the run. The default is 2 seconds; adjust with "timeout_seconds" in the config (clamped to 0.1–10s so a plane-supplied policy can't disable the protection):

config={"pattern": r"...", "should_match": False, "timeout_seconds": 1.0}

Schema

JSON Schema validation — useful for structured agent output.

A rule with no schema errors, it does not pass

An empty schema validates everything: the validator finds no violations in {}, so the rule reported every payload as valid while showing as an active control — worse than no rule, because it looks like one. Since 1.59.0 a missing, empty or non-object schema raises, so on_error decides what it costs and the result is marked errored. json_schema is accepted as an alias for schema.

Full JSON Schema, since 1.62.0

The schema guardrail validates with jsonschema against the whole spec — enum, minimum/maximum, minLength/maxLength, pattern, format, const, oneOf/anyOf/allOf, minItems, uniqueItems, $ref and the rest, with the dialect resolved from $schema exactly as the control plane resolves it.

Before 1.62.0 it used the SDK's own tool.schema.validate_schema, which understands five keywords and silently ignores the rest. A rule authored centrally with an enum was correctly rejected by Test and passed everything in production. Nothing that previously blocked stops blocking — the rule simply enforces what the console already promised.

validate_schema keeps its own job, tool drift detection, where its leniency is the point.

schema_guard = Guardrail(
    name="response_schema",
    guardrail_type=GuardrailType.schema,
    config={
        "schema": {
            "type": "object",
            "properties": {
                "answer": {"type": "string"},
                "confidence": {"type": "number", "minimum": 0, "maximum": 1},
                "sources": {"type": "array", "items": {"type": "string"}},
            },
            "required": ["answer", "confidence"],
        }
    },
)

LLM Judge

Use an LLM to evaluate quality. The judge LLM responds with PASS or FAIL:

judge_guard = Guardrail(
    name="relevance_judge",
    guardrail_type=GuardrailType.llm_judge,
    blocking=False,  # Log but don't block
    config={
        "prompt": "Is this response relevant and helpful? Respond PASS or FAIL.\n\nResponse: {data}",
        "pass_value": "PASS",
        "llm": {"provider": "openai", "model": "gpt-4.1"},
    },
)

Classifier

Keyword-based category detection with blocked category lists:

content_filter = Guardrail(
    name="content_filter",
    guardrail_type=GuardrailType.classifier,
    config={
        "categories": {
            "financial_advice": ["invest", "stock", "portfolio", "buy shares"],
            "medical_advice": ["diagnosis", "prescribe", "treatment plan"],
            "legal_advice": ["sue", "liable", "legal action"],
        },
        "blocked": ["financial_advice", "medical_advice", "legal_advice"],
    },
)

blocked narrows — it names which of the detected categories should fail the rule. Omit it and every detected category blocks, which is the useful default: an operator who listed the categories has already said what they care about.

Behaviour change in 1.64.0

Omitting blocked used to mean nothing ever blocked — the rule detected the category and then reported success. The control plane has always done the opposite, so the same rule and payload reached opposite verdicts on the two sides, with the SDK on the unsafe one. The SDK now matches the plane.

If you were relying on a blocked-less classifier as detect-only, set action="warn" — that records the finding and lets the run continue, which is what detect-only should have been.

A classifier with no categories at all now raises rather than passing: it has nothing to look for, so a pass would be indistinguishable from finding nothing.

Model-backed judges

Three types are judges with structure — a model call whose question, and whose answer, have a fixed shape, so an operator can say which harm, which bar, or which topic they mean and read from the audit row which one tripped. Their prompts and parsers are mirrored from the plane so a rule reaches the same verdict at the edge as it does centrally. Each is documented in full under Actions, severity & floor:

Type Decides Configured with
content_safety Which MLCommons hazard categories (S1–S14) the payload hits, each against its own bar categories, threshold, thresholds
groundedness Whether an answer is supported by the context it was given threshold, context_key, answer_key
topic Which named topics the payload discusses, then deny or allow on that topics ({name, description}), mode
no_competitors = Guardrail(
    name="no_competitors",
    guardrail_type=GuardrailType.topic,
    config={
        "topics": [
            {"name": "Competitor products",
             "description": "Any mention or comparison of a competing vendor's product."},
        ],
        "mode": "deny",   # "allow" makes the same list an on-topic gate
    },
)

Unlike classifier above, this is not substring matching: the description is what lets the judge catch "the other vendor's offering" without the word "competitor" appearing anywhere.

Decisions API backend

New in 1.84.0. llm_judge, content_safety and topic can ask OpenAI's Decisions API instead of a chat model. Set config["backend"] = "decisions", the same key pii uses to choose its engine. The payload is sent as evidence and each check is a yes/no question, so the answer is a probability. There's no prompt for the payload to inject into, and no JSON verdict to parse.

Type With backend="decisions" Extra config
llm_judge One predicate, instructions. PASS when its probability ≥ threshold instructions (required: the condition a passing payload meets), threshold (0.5)
content_safety One predicate per hazard category. The probability is that category's score, and threshold / thresholds apply unchanged —
topic One predicate per topic (a payload can be about several). A topic matches at topic_threshold; mode applies unchanged topic_threshold (0.5)
polite = Guardrail(
    name="polite",
    guardrail_type=GuardrailType.llm_judge,
    config={
        "backend": "decisions",
        "instructions": "The reply is polite to the customer.",
        "threshold": 0.5,
        "llm": {"model": "gpt-6-luna"},   # LLMClient kwargs; this is the default model
    },
)
no_medical = Guardrail(
    name="no-medical-advice",
    guardrail_type=GuardrailType.topic,
    config={"backend": "decisions", "topics": ["medical advice"], "mode": "deny"},
)

Nothing that can't decide ever passes clean. Each of these raises, so the rule reports errored=True and on_error applies:

  • a refusal on any question;
  • an unknown backend value (a typo never silently falls back to chat);
  • llm_judge without instructions;
  • a threshold outside 0..1;
  • backend="decisions" on a type it doesn't cover. That includes groundedness, because a single probability would drop its unsupported_claims evidence.

The default backend="chat" (or no key) is unchanged, and so is its metadata. The decisions engine adds backend: "decisions" to the result metadata, and for llm_judge also the probability and threshold. Those are the only extra keys that reach the exported span.

A rule the control plane distributes with "backend": "decisions" in its config runs as-is. Whether the plane lets you author one is the plane's decision.

The builtins no_prompt_injection(mode="decisions") and toxicity_check(mode="decisions") ask a single predicate. Its probability becomes the result's score. See 104_decision_guardrails_evals.py.

Detector-backed types

Two types ask a detector rather than a model — the same detect_pii / detect_secrets that have backed no_pii(), no_secrets() and the PIILeakage scorer all along. They are deterministic and free, and because a detector returns offsets where a judge returns a verdict, they are the only types that can genuinely redact:

Type Decides Configured with
pii Which personal-data entities the payload contains entities, backend, mask_token
secrets Which credential kinds it leaked mask_token only — narrowing a credential detector only weakens it
redact_pii = Guardrail(
    name="redact_pii",
    guardrail_type=GuardrailType.pii,
    config={"entities": ["email", "ssn"], "mask_token": "[REDACTED]"},
    action="mask",     # the payload continues, redacted, instead of blocking
)

Both report counts and entity kinds, never the matched values. See Actions, severity & floor for the full contract, including why an unknown entity raises rather than narrowing the scan.

Positions

All four guardrail positions are fully wired and operational:

Position When it runs Use case
GuardrailPosition.input Before LLM sees user message Block PII, profanity, prompt injection
GuardrailPosition.output After LLM responds Block PII leaks, validate format, quality check
GuardrailPosition.tool_call Before tool executes Restrict URLs, validate arguments, audit
GuardrailPosition.tool_result After tool returns (success only) Validate tool output, filter sensitive data
from fastaiagent import Agent, LLMClient
from fastaiagent.guardrail import Guardrail, GuardrailPosition, allowed_domains

agent = Agent(
    name="safe-agent",
    llm=LLMClient(provider="openai", model="gpt-4.1"),
    tools=[my_api_tool],
    guardrails=[
        # Block tool calls to unapproved domains
        allowed_domains(["api.mycompany.com"]),
        # Block sensitive data in tool results
        Guardrail(
            name="no-secrets-in-results",
            position=GuardrailPosition.tool_result,
            blocking=True,
            fn=lambda text: "sk-" not in text,
        ),
    ],
)

Tool-position guardrails work in both arun() and astream() execution modes.

from fastaiagent.guardrail import GuardrailPosition

# Same guardrail logic, different positions
agent = Agent(
    guardrails=[
        Guardrail(name="input_check", position=GuardrailPosition.input, fn=check_fn),
        Guardrail(name="output_check", position=GuardrailPosition.output, fn=check_fn),
    ],
)

Blocking vs Non-Blocking

Blocking (default): Execution stops immediately if the guardrail fails. A GuardrailBlockedError is raised.

# Blocking — raises exception on failure
strict = Guardrail(name="strict", blocking=True, fn=my_check)

Non-blocking: Failure is recorded but execution continues. Useful for monitoring and logging without interrupting the user.

# Non-blocking — logs failure, continues execution
monitor = Guardrail(name="quality_monitor", blocking=False, fn=quality_check)

When multiple guardrails are attached to an agent: 1. Blocking guardrails run first, sequentially — first failure stops everything 2. Non-blocking guardrails run in parallel after all blocking guardrails pass

Guardrail Executor

For advanced use cases, call the executor directly:

from fastaiagent.guardrail import execute_guardrails, GuardrailPosition

outcome = await execute_guardrails(
    guardrails=[guard1, guard2, guard3],
    data="text to validate",
    position=GuardrailPosition.output,
)

for r in outcome:
    print(f"Passed: {r.passed}, Time: {r.execution_time_ms}ms, Message: {r.message}")

# A `mask` or `override` rule rewrites the payload, so the outcome also carries
# the value to carry forward. `outcome` iterates and indexes like the list it
# used to be, so existing code keeps working.
print(outcome.data, outcome.modified)
Field Description
results The verdicts, one per applicable guardrail
data The payload to carry forward — rewritten when something rewrote it, otherwise the original
modified Did anything rewrite it?
reask The first rule that asked to re-prompt the model, if any

GuardrailResult

Field Type Description
passed bool Whether validation passed
score float \| None Optional quality score (0.0-1.0)
message str \| None Human-readable explanation
execution_time_ms int How long the check took
metadata dict Extra data (e.g., detected PII types, blocked categories)
errored bool True when the check itself failed to run; passed then reflects the on_error policy, not a verdict
action str What the rule was configured to cost: block / warn / mask / override / reask
action_taken str What it actually did: none / blocked / warned / masked / overridden / reask. Branch on this, never on action
modified_data str \| dict \| None The rewritten payload, when the action produced one

See Actions, severity & floor for what each action does and the two cases where a rewrite degrades to a block.

Fail policy: on_error

Model-judged guardrails depend on an LLM call that can fail. on_error decides what a failed check means, independent of the verdict it would have returned:

from fastaiagent.guardrail import toxicity_check, grounded

toxicity_check(mode="llm", on_error="allow")  # fail open — an error passes through
grounded(reference, on_error="block")         # fail closed — an error blocks

# Works on any guardrail you build yourself:
Guardrail(name="my_judge", guardrail_type=GuardrailType.llm_judge,
          config={...}, on_error="block")
on_error On a check error Built-ins that default to it
"allow" (fail open) Content passes through toxicity_check, no_prompt_injection, banned_topics
"block" (fail closed) Content is blocked grounded, openai_moderation, allowed_topics, custom Guardrail/llm_judge

Whichever you choose, the failure is visible: the result is errored=True, the trace span carries fastaiagent.guardrail.errored, and the Local UI logs an errored outcome. The no_pii(), no_secrets(), json_valid() and allowed_domains() builtins can't make a fallible call, so on_error doesn't apply to them — but the pii type can, and does: an unknown entity, an unknown backend or a missing [safety] extra all raise, and on_error decides the cost. Override a whole responsible_ai(...) bundle at once with responsible_ai(on_error="block", ...).

Serialization

Guardrails serialize to JSON for platform push:

data = guardrail.to_dict()
# {
#   "name": "no_urls",
#   "guardrail_type": "regex",
#   "position": "output",
#   "config": {"pattern": "https?://...", "should_match": false},
#   "blocking": true,
#   "description": "Blocks URLs in output",
#   "on_error": "block",
#   "action": "block",
#   "severity": null,
#   "floor": false
# }

restored = Guardrail.from_dict(data)

Inline functions (fn=) are NOT serialized — and since 1.64.0 that fails loudly

to_dict() cannot serialize a callable and from_dict() never restores one, so a code guardrail that has been round-tripped arrives with no logic.

Until 1.64.0 it then returned passed=True. Every builtin — no_pii(), no_secrets(), toxicity_check(), grounded() — is a code rule, so Agent.from_dict(agent.to_dict()) rebuilt an agent whose guardrails all passed unconditionally, and Replay.fork_at(...).rerun() re-ran an incident with every guardrail disarmed while writing green passed rows for checks that never executed.

It now raises, which on_error turns into a fail-closed errored result. Use a config-driven type — regex, schema, classifier, pii, secrets, topic — for any guardrail that must survive serialization or replay, or rebuild the code rule with fn= in the calling process.

Error Handling

from fastaiagent._internal.errors import GuardrailBlockedError

try:
    result = agent.run("Some input")
except GuardrailBlockedError as e:
    print(f"Guardrail: {e.guardrail_name}")  # Which guardrail blocked
    print(f"Message: {e}")                    # Why it blocked
    print(f"Results: {e.results}")            # All guardrail results up to the failure

Next Steps

  • Agents — Attach guardrails to agents
  • Tools — Guard tool calls and results
  • Platform Sync — Push guardrails to the platform