Guardrails: Validating Inputs and Outputs
On this page
What is a guardrail?
A guardrail is a validation layer that sits between the user and the model on input, or between the model and the user on output. The distinction from the moderation discussed in the previous chapter is sharp, and you must maintain it: moderation answers the question “Is this content offensive?”—a universal question whose answer does not depend on your business. Guardrails answer questions that only you can formulate: Is the message within the expected scope? Does the response contain forbidden information? Is the requested action authorized for this role? A banking assistant and a bookstore assistant share the same moderation but have no guardrails in common.
By 2026, guardrails have become a standard component of AI agent architectures, indispensable whenever a model has access to tools or sensitive data.
A pipeline architecture
The structural choice is to treat each guardrail as an independent object with a single responsibility, then chain them in a chosen order. The benefit appears during maintenance: adding a business rule requires no changes to existing code, and a rule that becomes too noisy can be removed in a single line.
A third verdict joins allow and block: modify. This changes the nature of the exercise. Many real-world situations do not justify a refusal—a response containing an internal server path remains useful once that path is masked. Blocking would lose the information; modifying preserves it by removing what poses a problem.
from dataclasses import dataclass
from enum import Enum
class Decision(Enum):
ALLOW = "allow"
BLOCK = "block"
MODIFY = "modify"
@dataclass
class ValidationResult:
decision: Decision
reason: str
modified_content: str | None = None
class Guardrail:
"""Base class for a guardrail."""
def validate(self, content: str, context: dict) -> ValidationResult:
raise NotImplementedError
class GuardrailPipeline:
"""Chains multiple guardrails in sequence."""
def __init__(self):
self.input_guardrails: list[Guardrail] = []
self.output_guardrails: list[Guardrail] = []
def add_input(self, guardrail: Guardrail):
self.input_guardrails.append(guardrail)
def add_output(self, guardrail: Guardrail):
self.output_guardrails.append(guardrail)
def validate_input(self, message: str, context: dict) -> ValidationResult:
for g in self.input_guardrails:
result = g.validate(message, context)
if result.decision == Decision.BLOCK:
return result
if result.decision == Decision.MODIFY:
message = result.modified_content
return ValidationResult(Decision.ALLOW, "All validations passed")
def validate_output(self, response: str, context: dict) -> ValidationResult:
for g in self.output_guardrails:
result = g.validate(response, context)
if result.decision == Decision.BLOCK:
return result
if result.decision == Decision.MODIFY:
response = result.modified_content
return ValidationResult(Decision.ALLOW, "OK", response)
Three typical guardrails
The scope guardrail keeps the conversation within its domain. It serves both security and reputation: a customer service assistant trained to answer questions about deliveries has nothing to gain by commenting on political news, and a screenshot of such a response circulates quickly. The list of forbidden topics must be discussed with the business side, never drafted by the technical team alone.
class ScopeGuardrail(Guardrail):
"""Checks that the message stays within the authorized scope."""
def __init__(self, allowed_topics: list[str], forbidden_topics: list[str]):
self.allowed_topics = allowed_topics
self.forbidden_topics = forbidden_topics
def validate(self, content: str, context: dict) -> ValidationResult:
content_lower = content.lower()
# Check forbidden topics
for topic in self.forbidden_topics:
if topic.lower() in content_lower:
return ValidationResult(
Decision.BLOCK,
f"Out-of-scope topic detected: {topic}"
)
return ValidationResult(Decision.ALLOW, "Within scope")
The format guardrail seems trivial yet provides the most daily value. It costs a few microseconds, depends on no network calls, and cuts short the oversized messages used for volumetric injections and budget exhaustion attempts. Place it first in the chain: there is no point in paying for API moderation on a 200,000-character message you will reject anyway.
class FormatGuardrail(Guardrail):
"""Validates message format and length."""
def __init__(self, max_tokens: int = 4000, max_lines: int = 100):
self.max_tokens = max_tokens
self.max_lines = max_lines
def validate(self, content: str, context: dict) -> ValidationResult:
# Rough token estimation (1 token ~ 4 characters in English)
estimated_tokens = len(content) // 4
if estimated_tokens > self.max_tokens:
return ValidationResult(
Decision.BLOCK,
f"Message too long: ~{estimated_tokens} tokens (max {self.max_tokens})"
)
line_count = content.count("\n") + 1
if line_count > self.max_lines:
return ValidationResult(
Decision.BLOCK,
f"Too many lines: {line_count} (max {self.max_lines})"
)
return ValidationResult(Decision.ALLOW, "Valid format")
The third monitors output and illustrates the use of the “modify” verdict. The four families it searches for share a convenient property: they have a recognizable form independent of meaning. An API key, an internal address, a private IP, or a server path are identified by their structure, without needing to understand the sentence containing them.
import re
class AntiExfiltrationGuardrail(Guardrail):
"""Prevents leakage of sensitive data in outputs."""
def __init__(self):
self.sensitive_patterns = {
"api_key": r"(sk|pk|api[_-]?key)[_-][a-zA-Z0-9]{16,}",
"internal_email": r"[a-zA-Z0-9.]+@(company|internal|corp)\.\w+",
"private_ip": r"\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b",
"server_path": r"(/data/|/etc/|/home/|C:\\Users\\)",
}
def validate(self, content: str, context: dict) -> ValidationResult:
for data_type, pattern in self.sensitive_patterns.items():
if re.search(pattern, content):
# Mask instead of blocking
cleaned_content = re.sub(pattern, "[REDACTED]", content)
return ValidationResult(
Decision.MODIFY,
f"Sensitive data redacted: {data_type}",
cleaned_content,
)
return ValidationResult(Decision.ALLOW, "No leakage detected")
Assembling it all
The complete assembly shows where the pieces fit together. The asymmetry jumps out: two guardrails on input, only one on output. This is frequent and healthy—you filter heavily before calling the model because that is where costs are avoidable, and you keep the minimum necessary on output to avoid damaging correct responses. The message returned when blocking on output follows the same caution: intentionally vague, it does not say what was detected, information that would primarily serve someone trying to map your defenses.
from openai import OpenAI
def create_secure_assistant():
"""Creates an assistant with a complete guardrail pipeline."""
client = OpenAI()
pipeline = GuardrailPipeline()
# Input guardrails
pipeline.add_input(FormatGuardrail(max_tokens=2000))
pipeline.add_input(ScopeGuardrail(
allowed_topics=["products", "orders", "delivery", "returns"],
forbidden_topics=["politics", "religion", "competitors"],
))
# Output guardrails
pipeline.add_output(AntiExfiltrationGuardrail())
def process(message: str) -> str:
context = {"user_role": "customer"}
# Input validation
input_check = pipeline.validate_input(message, context)
if input_check.decision == Decision.BLOCK:
return f"I cannot process this request: {input_check.reason}"
valid_message = input_check.modified_content or message
# Model call
response = client.chat.completions.create(
model="gpt-5.6-sol",
messages=[
{"role": "system", "content": "Acme Corp customer service assistant."},
{"role": "user", "content": valid_message},
],
)
reply = response.choices[0].message.content
# Output validation
output_check = pipeline.validate_output(reply, context)
if output_check.decision == Decision.BLOCK:
return "I am unable to answer this question."
return output_check.modified_content or reply
return process
When the rule cannot be written in regex
Some business rules resist any formalization by patterns. “The response must not constitute investment advice,” “the message must not contain health data”: no regular expression captures this. A second model, fast and inexpensive, can then act as a judge, with a response constrained to JSON format to remain usable by the calling code. The trade-off is explicit—you gain precision at the cost of latency and determinism, which reserves this approach for rules that truly justify it.
def llm_guardrail(message: str, rule: str) -> ValidationResult:
"""Uses a fast model to validate compliance."""
client = OpenAI()
response = client.chat.completions.create(
model="gpt-5.6-terra",
messages=[
{"role": "system", "content": f"""You are a compliance validator.
Rule to check: {rule}
Respond ONLY with JSON: {{"compliant": true/false, "reason": "..."}}"""},
{"role": "user", "content": f"Message to validate:\n{message}"},
],
)
import json
result = json.loads(response.choices[0].message.content)
if result["compliant"]:
return ValidationResult(Decision.ALLOW, result["reason"])
return ValidationResult(Decision.BLOCK, result["reason"])
Key takeaways
- Guardrails validate business compliance, not just offensive content
- Pipeline architecture allows combining multiple validation layers
- Each guardrail has a single responsibility: scope, format, exfiltration, etc.
- Output guardrails are as important as input guardrails
- For complex cases, a fast LLM can serve as a validator