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.

O
OurToken Team//13 min
AI Agent Guardrails: Input, Tool, and Output Checks

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:

LayerDecidesPrevents
InputWhat enters the model and what context may be attachedPrompt injection, secret leakage, tenant confusion, over-long prompts
ToolWhether a proposed tool call may run and with which argumentsUnsafe mutations, unauthorized records, destructive operations, schema abuse
OutputWhether a response may be returned and in what formInvalid 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 reasonUser-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.

TestExample assertion
Input secretCredential in prompt is blocked, not logged
Input lengthLong request is truncated or rejected by policy
Tenant boundaryCross-tenant record access is denied
Unknown toolProposed call never reaches execution
Role restrictionCustomer cannot call an internal mutation
Argument schemaMalformed ticket ID fails before repository access
Human approvalStatus change waits for approval tied to the exact payload
Output schemaMissing or extra fields are blocked or retried
Review timeoutAmbiguous case goes to a human instead of auto-approval
Audit trailDecision, 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.

AI Agent Guardrails: Input, Tool, and Output Checks