AI Agent Guardrails: Input, Tool, and Output Checks
Build AI agent guardrails with input, tool, and output checks. Learn policy schemas, Python middleware, safe tool calls, review, tests, and rollout.

AI agent guardrails are not one prompt injection sentence at the end of a system message. They are executable controls placed before the model sees input, before a tool runs, and before a response reaches a user or another system. Without those boundaries, an agent can expose private data, call a tool with destructive arguments, or return a plausible answer that violates policy.
This guide shows how to design guardrails as a small middleware architecture. It covers policy schemas, input checks, tool authorization, output validation, optional model-assisted review, model routing, failure modes, tests, and rollout. The goal is a guardrail layer that is auditable, testable, and safe to evolve across models.
This article focuses on the guardrail layer. For provider authentication and transport security, read the MCP server authentication guide. For request formatting and tool-call mechanics, see the OpenAI-compatible tool calling guide. The two are complementary; neither replaces policy enforcement in your application.
Why AI Agent Guardrails Need Three Layers
A single final output filter is easy to add and easy to bypass. It cannot inspect a tool call before side effects happen, and it cannot repair an input that leaked too much context. Three checkpoints give you control where risk actually occurs:
| Layer | Decides | Prevents |
|---|---|---|
| Input | What enters the model and what context may be attached | Prompt injection, secret leakage, tenant confusion, over-long prompts |
| Tool | Whether a proposed tool call may run and with which arguments | Unsafe mutations, unauthorized records, destructive operations, schema abuse |
| Output | Whether a response may be returned and in what form | Invalid schemas, unsafe actions, unsupported claims, sensitive data exposure |
Keep each layer independent. An input guard should not trust that the output guard will catch a leaked secret. A tool guard should not assume the model understood the tool description. An output guard should not rely on the model to confess policy violations.
User input
|
v
Input guard --> allow | redact | transform | block
|
v
Agent loop
|
v
Proposed tool call
|
v
Tool guard --> allow | deny | transform | ask_human
|
v
Model response
|
v
Output guard --> allow | repair | redact | block
|
v
Safe response or failure
Each arrow should return a typed decision, not a boolean. The caller needs to know whether to continue, retry with transformed data, ask a human, or stop.
Define Guardrails as Explicit Policies
Start with a policy object that product, security, and engineering can review together. Avoid scattering rules across prompts and controller code.
from dataclasses import dataclass, field
from enum import Enum
class Decision(str, Enum):
ALLOW = "allow"
REDACT = "redact"
TRANSFORM = "transform"
BLOCK = "block"
ASK_HUMAN = "ask_human"
@dataclass
class GuardDecision:
decision: Decision
reason: str
transformed: dict | None = None
violations: list[str] = field(default_factory=list)
@dataclass
class ToolPolicy:
name: str
allowed_roles: set[str]
tenant_scoped: bool = True
max_rows: int = 100
allowed_statuses: set[str] | None = None
require_human_approval: bool = False
dry_run_supported: bool = False
@dataclass
class OutputPolicy:
max_chars: int = 8000
require_schema: bool = False
redact_pii: bool = True
allowed_external_domains: set[str] = field(default_factory=set)
The policy does not need to be perfect on day one. It needs to be visible and versioned. When an incident happens, you should be able to answer: which policy version was active, which rule fired, and what the agent tried to do.
Separate Deterministic Controls from Model-Assisted Review
Use deterministic checks for anything that must be guaranteed:
- Authentication and tenant identity.
- Role-based permissions.
- JSON Schema validation.
- Row limits, amount limits, and rate limits.
- Domain allowlists.
- Idempotency and audit records.
Use model-assisted review for fuzzy judgment:
- Is this message likely attempting an injection?
- Does this support request contain risky intent that patterns missed?
- Does this draft response make an unsupported claim?
- Is this content borderline rather than clearly prohibited?
The model can reduce review cost, but it should not be the sole gate for irreversible actions. A classifier can disagree, fail, or be influenced by the same content it is reviewing. Treat it as an advisor with its own confidence, timeout, and fallback policy.
Implement Input Guardrails Before Model Calls
Input guardrails answer three questions: Is this request allowed? Is it safe to send? Does it contain the minimum context needed?
import re
SECRET_PATTERNS = [
r"sk-[A-Za-z0-9]{16,}",
r"xox[baprs]-[A-Za-z0-9-]+",
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
]
def contains_secret(text: str) -> bool:
return any(re.search(pattern, text, re.IGNORECASE) for pattern in SECRET_PATTERNS)
def guard_input(
text: str,
*,
role: str,
max_chars: int = 12_000,
) -> GuardDecision:
violations = []
if role not in {"customer", "support_agent", "admin"}:
return GuardDecision(Decision.BLOCK, "unknown_role", violations=violations)
if len(text) > max_chars:
violations.append("input_too_long")
if contains_secret(text):
return GuardDecision(
Decision.BLOCK,
"secret_detected",
violations=violations,
)
if violations:
return GuardDecision(
Decision.TRANSFORM,
"requires_truncation",
transformed={"text": text[:max_chars]},
violations=violations,
)
return GuardDecision(Decision.ALLOW, "input_accepted", violations=violations)
This example deliberately keeps secret detection simple. Production systems should use a maintained detector, cover tokens and credentials relevant to your stack, avoid logging the original match, and test false positives. The important design rule is that suspicious secrets fail closed.
Attach Context Through an Allowlist
Retrieval and memory should be filtered before the model request, not after. Pass an access scope into the retrieval layer and verify tenant ownership for every selected record.
def select_context(
query: str,
*,
owner_id: str,
scopes: set[str],
) -> list[dict]:
candidates = retrieve_candidates(query)
return [
item
for item in candidates
if item["owner_id"] == owner_id
and item["access_scope"] in scopes
and not item["expired"]
]
Do not put raw internal IDs, database errors, or complete customer profiles into the prompt because they happen to be available. The context selector should return the smallest useful projection.
Guard Tool Calls Before Execution
Tool calls are where guardrails matter most. A model can propose an argument; it cannot safely decide whether the current principal may mutate a record. That decision belongs to the tool guard.
from dataclasses import dataclass
@dataclass
class ProposedToolCall:
call_id: str
name: str
arguments: dict
principal: dict
TOOL_POLICIES = {
"search_tickets": ToolPolicy(
name="search_tickets",
allowed_roles={"customer", "support_agent"},
max_rows=50,
),
"update_ticket_status": ToolPolicy(
name="update_ticket_status",
allowed_roles={"support_agent"},
allowed_statuses={"open", "pending_customer", "resolved"},
require_human_approval=True,
dry_run_supported=True,
),
"delete_workspace": ToolPolicy(
name="delete_workspace",
allowed_roles=set(),
require_human_approval=True,
),
}
def guard_tool_call(call: ProposedToolCall) -> GuardDecision:
policy = TOOL_POLICIES.get(call.name)
if policy is None:
return GuardDecision(Decision.BLOCK, "unknown_tool")
if call.principal["role"] not in policy.allowed_roles:
return GuardDecision(Decision.BLOCK, "role_not_allowed")
if policy.tenant_scoped:
requested_owner = call.arguments.get("owner_id")
if requested_owner != call.principal["owner_id"]:
return GuardDecision(Decision.BLOCK, "tenant_mismatch")
if policy.allowed_statuses and call.arguments.get("status") not in policy.allowed_statuses:
return GuardDecision(Decision.BLOCK, "status_not_allowed")
if call.arguments.get("limit", 20) > policy.max_rows:
return GuardDecision(
Decision.TRANSFORM,
"limit_reduced",
transformed={**call.arguments, "limit": policy.max_rows},
)
if policy.require_human_approval:
return GuardDecision(Decision.ASK_HUMAN, "approval_required")
return GuardDecision(Decision.ALLOW, "tool_call_allowed")
For destructive actions, prefer two controls together: a dry-run response that shows the exact change, followed by a human approval tied to that dry-run hash. Do not let an approval from an earlier step cover a changed argument set.
Validate Arguments with a Schema
Role checks are not enough. An update_ticket_status call with a malformed ticket ID should fail before it reaches the repository.
def validate_update_ticket_arguments(arguments: dict) -> list[str]:
errors = []
ticket_id = arguments.get("ticket_id")
status = arguments.get("status")
if not isinstance(ticket_id, str) or not ticket_id.startswith("TCK-"):
errors.append("invalid_ticket_id")
if status not in {"open", "pending_customer", "resolved"}:
errors.append("invalid_status")
return errors
For complex tools, use JSON Schema and a validator instead of a growing list of conditionals. When validation fails, return a structured error to the agent so it can correct the call, but do not execute the original request.
Enforce Fail-Closed Authorization
A fail-open guard catches exceptions and allows the action. That is usually the wrong default for mutations. Better behavior:
unknown tool -> block
policy load error -> block and alert
schema failure -> deny; allow model retry with structured errors
identity missing -> block
reviewer timeout -> ask human or block, depending on action risk
read-only failure -> may degrade, but only with an explicit policy
Read-only search can sometimes degrade gracefully. Deletion, payment, deployment, and access changes should not.
Validate Model Output Before Delivery
Output guardrails turn a model response into a safe product event. They should enforce schema, length, action scope, PII policy, and external-link rules.
import json
def guard_json_output(raw: str, schema_fields: set[str]) -> GuardDecision:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return GuardDecision(Decision.REPAIR, "invalid_json")
if not isinstance(parsed, dict):
return GuardDecision(Decision.BLOCK, "expected_object")
missing = schema_fields - parsed.keys()
extra = parsed.keys() - schema_fields
if missing or extra:
return GuardDecision(
Decision.BLOCK,
"schema_mismatch",
violations=[f"missing={sorted(missing)}", f"extra={sorted(extra)}"],
)
return GuardDecision(Decision.ALLOW, "output_valid", transformed=parsed)
For free-text responses, validate before rendering:
- Length and language requirements.
- External domains in links.
- Claims that imply pricing, legal, medical, or security guarantees.
- Quotes and citations when the answer depends on retrieved documents.
- PII that should not appear to another tenant or support agent.
When a response fails validation, prefer a structured retry with the exact error class over a silent rewrite. Log the failure count; repeated invalid output is a model-selection or prompt-design problem, not just a guardrail problem.
Use Model-Assisted Review Carefully
A second model call can classify ambiguous input or review a draft response. Make the boundary explicit:
def review_with_model(text: str, *, threshold: float = 0.80) -> GuardDecision:
result = call_review_model(text)
if result.timed_out:
return GuardDecision(Decision.ASK_HUMAN, "review_timeout")
if result.risk_score >= threshold:
return GuardDecision(Decision.BLOCK, "high_risk_review")
if result.risk_score >= 0.50:
return GuardDecision(
Decision.TRANSFORM,
"needs_redaction",
transformed={"text": result.redacted_text},
)
return GuardDecision(Decision.ALLOW, "review_passed")
The review model should receive the minimum content needed, never receive credentials, and never be asked to enforce tenant authorization. Authorization is a database and policy problem. Model review is a content-risk signal.
Normalize Errors Without Leaking Policy Internals
Users need enough information to continue; attackers do not need your rule set.
| Internal reason | User-facing response |
|---|---|
unknown_role | "This account cannot use this action." |
tenant_mismatch | "This record is not available." |
secret_detected | "Remove credentials and try again." |
schema_mismatch | "The request could not be completed." |
approval_required | "A reviewer must approve this action." |
Keep the internal reason in structured logs with the policy version and request ID. Do not log secrets, complete prompts, or sensitive attachments.
Put Guardrails in a Small Middleware Pipeline
The pipeline owns the order of operations and emits comparable telemetry.
from dataclasses import dataclass
@dataclass
class AgentRequest:
request_id: str
principal: dict
text: str
def run_guarded_agent(request: AgentRequest) -> dict:
input_decision = guard_input(request.text, role=request.principal["role"])
if input_decision.decision == Decision.BLOCK:
return public_error(input_decision.reason)
effective_text = (
input_decision.transformed["text"]
if input_decision.decision == Decision.TRANSFORM
else request.text
)
for proposed_call in propose_tool_calls(effective_text, request.principal):
tool_decision = guard_tool_call(proposed_call)
if tool_decision.decision == Decision.BLOCK:
return public_error(tool_decision.reason)
if tool_decision.decision == Decision.ASK_HUMAN:
return request_human_approval(proposed_call)
if tool_decision.decision == Decision.TRANSFORM:
proposed_call.arguments = tool_decision.transformed
execute_tool(proposed_call)
response = generate_model_response(effective_text)
output_decision = guard_json_output(
response.text,
schema_fields={"answer", "ticket_id", "status"},
)
if output_decision.decision == Decision.BLOCK:
return public_error(output_decision.reason)
if output_decision.decision == Decision.REPAIR:
return request_schema_retry(response.request_id, output_decision.reason)
return {"response": output_decision.transformed, "request_id": request.request_id}
The helper names are intentionally abstract. Replace them with your agent loop and tools, but keep one boundary where every guard decision is visible.
Route Guardrail Models Without Compromising Safety
Guardrails often mix cheap classification work with harder judgment. A single endpoint can make routing explicit: one model may classify routine input, another may review complex cases, and another may generate the final response.
For example, a team might evaluate GPT-5.6 Terra for complex review, DeepSeek V4 Pro for selected analytical workloads, and GLM 5.2 for summary or classification routes where quality requirements are met. These are candidate routes, not universal recommendations. Verify exact model IDs and feature support in the live OurToken model catalog.
The LLM model routing guide explains how to choose routes by task. For guardrails, add two constraints: safety-critical review should have a timeout and fallback, and routing must never bypass a deterministic permission check.
When schemas are central to your agent's contract, the Structured Outputs JSON Schema guide shows how to reduce invalid output before it reaches the guard. The OpenAI API rate-limit guide is also relevant because guardrail review calls can increase request volume; use bounded retries and budget-aware fallback.
Test and Roll Out AI Agent Guardrails
Guardrails are code under regulatory pressure. Test them like authentication logic.
| Test | Example assertion |
|---|---|
| Input secret | Credential in prompt is blocked, not logged |
| Input length | Long request is truncated or rejected by policy |
| Tenant boundary | Cross-tenant record access is denied |
| Unknown tool | Proposed call never reaches execution |
| Role restriction | Customer cannot call an internal mutation |
| Argument schema | Malformed ticket ID fails before repository access |
| Human approval | Status change waits for approval tied to the exact payload |
| Output schema | Missing or extra fields are blocked or retried |
| Review timeout | Ambiguous case goes to a human instead of auto-approval |
| Audit trail | Decision, reason, policy version, and request ID are recorded |
Use fixed fixtures for common attacks and legitimate tasks. Include cross-tenant attempts, malformed tool arguments, unsafe external links, partial secrets, expired memory, and high-quality requests that should pass. A guardrail suite without legitimate cases will teach the model layer to overblock.
Production Checklist
Before enabling guardrails for real traffic, verify:
[ ] Input, tool, and output guards run in a fixed, tested order.
[ ] Every guard returns allow, redact, transform, block, or ask_human.
[ ] Deterministic permissions run before model-assisted review.
[ ] Tool mutations have schema validation, tenant checks, and limits.
[ ] Destructive actions require dry-run plus human approval.
[ ] Unknown tools and authorization failures block by default.
[ ] Output schemas, domains, PII, and unsafe claims are validated.
[ ] Review models have timeouts, thresholds, and safe fallbacks.
[ ] Model routes are approved and cannot bypass deterministic checks.
[ ] Logs include decision, reason, policy version, and request ID.
[ ] Secrets and sensitive prompt content never enter general logs.
[ ] Legitimate-use tests pass alongside attack tests.
[ ] Rollback disables new policy rules without removing audit records.
Conclusion
Effective AI agent guardrails are a system boundary, not a prompt suffix. Separate input, tool, and output checkpoints; give each one a typed decision; keep deterministic authorization ahead of model-assisted review; and validate every irreversible action before it executes.
This design scales because policy is explicit, failures are observable, and model routes can change without changing the safety contract. Start with a small middleware pipeline for your highest-risk tools, add golden tests for legitimate and adversarial cases, and roll out by workload. That is how guardrails become a product capability instead of an afterthought.
FAQ
What are AI agent guardrails?
They are executable checks that control what enters a model, which tool calls may run, and what responses may leave the system. Good guardrails combine deterministic policy, schema validation, authorization, audit logs, and optional model-assisted review.
Can a system prompt replace guardrails?
No. Prompts influence behavior but cannot guarantee authorization, prevent destructive tool calls, or enforce a schema. Use prompts to communicate policy; use middleware to enforce it.
Where should model-assisted review run?
Use it after deterministic checks for ambiguous content risk, policy interpretation, or draft review. It should have a confidence threshold, timeout, and fallback. It should not be the only gate for mutations or tenant authorization.
How do I stop an agent from calling a tool unsafely?
Validate the proposed call before execution. Check the tool name, principal role, tenant ownership, argument schema, row and amount limits, and approval requirements. Return a structured denial to the agent and an audit event to your logs.
Should guardrails fail open or closed?
Fail closed for destructive or permission-sensitive actions. Read-only features may have an explicit degradation policy, but unknown tools, missing identity, policy-load errors, and approval timeouts should not auto-approve.
How do guardrails relate to MCP authentication?
MCP authentication controls who can connect to a server and how credentials are protected. Application guardrails decide what an authenticated agent may do with a specific tool and response. You often need both.
How do I reduce invalid model output?
Use a strict response schema, validate it before delivery, retry with a structured error, and log failure rates. If retries stay high, change the prompt, response format, or model route rather than loosening the guardrail.
How should I roll out guardrails?
Start with one low-risk workload, run legitimate and adversarial fixtures, log decisions, and review false blocks. Then expand to mutations, require approval for destructive actions, and keep policy changes reversible.