Output Validation with Pydantic and Zod Against Malicious Content
Schema validation is a mechanical guardrail: before your host executes a tool call, updates state, or triggers automation, parse the model’s structured output with Pydantic (Python) or Zod (TypeScript) and reject anything outside the contract.
Summary
Define strict models for tool arguments and final answers, validate at the host boundary with allowlists and bounds, fail closed on errors, and never execute free-form strings as code or shell. Use the same idea for intermediate agent plans when they drive side effects.
Recipe
- List every side-effecting action (tools, webhooks, DB writes, emails).
- Write a schema per action: types, enums, max lengths, numeric ranges, URL allowlists.
- Parse model JSON (or tool-call arguments) only through that schema.
- Add semantic checks the type system cannot express (recipient allowlists, path sandboxing).
- On
ValidationError, retry once with the error text or escalate to a human; do not "best effort" execute. - Keep a separate schema for user-facing replies if automation consumes them.
- Log validation failures with redacted payloads for red-team mining.
- Mirror the same contracts in TypeScript (Zod) if your agent runtime is Node/edge.
- Prefer framework structured-output modes when available, still validate on the host.
- Re-test schemas whenever tools or enums change.
Working Example
Python (Pydantic v2)
from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal
from urllib.parse import urlparse
from pydantic import BaseModel, Field, ValidationError, field_validator
class Priority(str, Enum):
low = "low"
normal = "normal"
high = "high"
class CreateTicketArgs(BaseModel):
title: Annotated[str, Field(min_length=3, max_length=120)]
body: Annotated[str, Field(max_length=4000)]
priority: Priority = Priority.normal
# Block free-form "run this tool next" channels on a ticket object
labels: list[Annotated[str, Field(max_length=32)]] = Field(default_factory=list, max_length=8)
@field_validator("body")
@classmethod
def no_credential_dump_markers(cls, v: str) -> str:
lowered = v.lower()
for needle in ("begin private key", "api_key=", "authorization: bearer"):
if needle in lowered:
raise ValueError("body looks like a secret dump")
return v
class HttpGetArgs(BaseModel):
url: Annotated[str, Field(max_length=500)]
@field_validator("url")
@classmethod
def only_https_allowlisted_hosts(cls, v: str) -> str:
parsed = urlparse(v)
if parsed.scheme != "https":
raise ValueError("only https allowed")
host = (parsed.hostname or "").lower()
allowed = {"api.example.com", "status.example.com"}
if host not in allowed:
raise ValueError(f"host not allowlisted: {host}")
return v
class AgentAction(BaseModel):
type: Literal["create_ticket", "http_get", "reply"]
create_ticket: CreateTicketArgs | None = None
http_get: HttpGetArgs | None = None
reply: Annotated[str, Field(max_length=2000)] | None = None
def dispatch(action: dict) -> str:
try:
act = AgentAction.model_validate(action)
except ValidationError as e:
return f"rejected: {e.error_count()} validation error(s)"
if act.type == "create_ticket":
if act.create_ticket is None:
return "rejected: missing create_ticket payload"
return f"ticket queued: {act.create_ticket.title!r}"
if act.type == "http_get":
if act.http_get is None:
return "rejected: missing http_get payload"
return f"would GET {act.http_get.url}"
return f"reply: {(act.reply or '')[:80]}"
print(dispatch({
"type": "http_get",
"http_get": {"url": "https://evil.example/steal"},
}))
print(dispatch({
"type": "create_ticket",
"create_ticket": {
"title": "Late package",
"body": "Customer says package is late.",
"priority": "high",
},
}))TypeScript (Zod)
import { z } from "zod";
const CreateTicketArgs = z.object({
title: z.string().min(3).max(120),
body: z
.string()
.max(4000)
.refine((v) => !/api_key=/i.test(v), { message: "secret-like body" }),
priority: z.enum(["low", "normal", "high"]).default("normal"),
labels: z.array(z.string().max(32)).max(8).default([]),
});
const HttpGetArgs = z.object({
url: z
.string()
.max(500)
.url()
.refine((u) => u.startsWith("https://"), { message: "https only" })
.refine((u) => {
const host = new URL(u).hostname;
return ["api.example.com", "status.example.com"].includes(host);
}, { message: "host not allowlisted" }),
});
const AgentAction = z.discriminatedUnion("type", [
z.object({ type: z.literal("create_ticket"), create_ticket: CreateTicketArgs }),
z.object({ type: z.literal("http_get"), http_get: HttpGetArgs }),
z.object({ type: z.literal("reply"), reply: z.string().max(2000) }),
]);
export function dispatch(action: unknown): string {
const parsed = AgentAction.safeParse(action);
if (!parsed.success) {
return `rejected: ${parsed.error.issues.length} issue(s)`;
}
const act = parsed.data;
if (act.type === "http_get") return `would GET ${act.http_get.url}`;
if (act.type === "create_ticket") return `ticket queued: ${act.create_ticket.title}`;
return `reply: ${act.reply.slice(0, 80)}`;
}Deep Dive
Why schemas beat prompt rules
A prompt can say "never email outside the company."
A schema + allowlist makes to: attacker@evil.example a hard failure before SMTP runs.
Injection often succeeds at the linguistic layer. Validation shrinks the executable layer.
What to validate
| Surface | Validate | Examples |
|---|---|---|
| Tool arguments | Always | Paths, URLs, IDs, SQL fragments, emails |
| Final structured answer | When automation consumes it | Status enums, money fields |
| Multi-agent plans | When plans drive tools | Step types, tool names |
| Free-text chat | Lightly | Length, PII policies if required |
Structured output modes vs host validation
Many providers support JSON schema / tool calling so the model is biased toward valid shapes.
Still validate on the host:
- Models emit invalid JSON under load.
- Schemas drift between prompt and code.
- Semantic allowlists are rarely expressible in provider schemas alone.
Pydantic AI, Vercel AI SDK generateObject, and OpenAI-compatible tool calls all benefit from a second local parse.
Malicious content beyond types
Types catch wrong shapes. Add semantic guards:
- URL/host allowlists for fetch tools.
- Path sandboxes (
resolve+ root prefix check) for file tools. - Enum tool names matching the host registry only.
- Amount caps for payments and refunds.
- Secret-like content heuristics for outbound messages.
- No nested "commands" arrays on user-facing DTOs unless the host interprets them safely.
Fail-closed patterns
def run_tool(name: str, raw_args: dict, registry: dict) -> str:
if name not in registry:
return "rejected: unknown tool"
schema = registry[name]
try:
args = schema.model_validate(raw_args)
except ValidationError:
return "rejected: invalid args"
return registry[name].fn(args) # only after validateNever call registry[name].fn(raw_args) "to keep the demo working."
Gotchas
strfields without max length accept multi-megabyte injection and cost bombs.- Over-permissive URLs (
AnyHttpUrlwithout host checks) enable SSRF-style tool abuse. - Parsing with
json.loadsonly skips types, enums, and bounds. - Silent coercion (wrong types coerced) can hide attacks; prefer strict models.
- Valid schema, invalid business rule - e.g. refund amount within int range but over policy cap.
- Logging full args may store secrets the attacker tried to exfiltrate; redact.
- Zod transforms that "fix" bad data can accidentally execute intent; prefer reject.
- Duplicate schemas in prompt text and code drift; generate one from the other when possible.
Alternatives
| Approach | When it wins | When it loses |
|---|---|---|
| Pydantic / Zod host validation (this recipe) | Any tool-using agent | Pure creative chat with no automation |
| Provider JSON schema only | Quick prototypes | Semantic allowlists, defense in depth |
| LLM-as-judge on outputs | Fuzzy policy | High-stakes side effects alone |
| Human approval gates | Rare irreversible actions | High-volume low-risk tools |
| Capability tokens per tool | Strong distributed auth | More infra complexity |
FAQs
Does validation stop the model from being injected?
No.
It stops many injected actions from executing. Pair with isolation and least privilege.
Pydantic or Zod?
Use the language of your agent host.
Python services lean Pydantic; TypeScript/edge agents lean Zod. Contracts should match either way.
Should I validate tool results as well as tool args?
Yes when results feed automation or privileged planners.
Even a simple size/type check reduces garbage and some poison shapes.
How do I handle validation errors in the loop?
Return a structured error to the model for one repair attempt, then stop or escalate.
Infinite repair loops waste budget and can re-expose poison.
Can I put the schema in the system prompt only?
Prompt schemas help generation quality.
Executable validation must live in code.
What about protobuf or JSON Schema without Pydantic?
Any strict parser works.
Pick one library and enforce it at every tool boundary.
Is model_validate_json safer than validating a dict?
It avoids some partial dict construction mistakes.
Still apply the same field rules and semantic validators.
How does this relate to prompt injection detection?
Detection flags suspicious language.
Validation enforces what may run even when detection misses.
Related
Related: Prompt Injection & Guardrails Basics
Related: Isolating Untrusted Content from Trusted Instructions
Related: Guardrail Libraries and Content-Filtering Layers for Agents
Related: Red-Teaming Your Own Agent for Injection Vulnerabilities
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.