Custom Validators¶
Overview¶
agentguard ships with built-in schema and hallucination validators, but every application has its own business rules. Custom validators let you plug arbitrary logic into the guard pipeline.
Anatomy of a Validator¶
A validator is a callable that receives a ToolCall (or a ToolResult for output validators) and returns a ValidationResult:
from agentguard.core.types import ToolCall, ToolResult, ValidationResult, ValidatorKind
def my_validator(call: ToolCall) -> ValidationResult:
# Inspect call.kwargs, call.args, call.tool_name, call.session_id...
if something_wrong:
return ValidationResult(
valid=False,
kind=ValidatorKind.CUSTOM,
message="Human-readable error message",
details={"key": "extra context for debugging"},
)
return ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
Attach it to @guard:
from agentguard import guard
@guard(custom_validators=[my_validator])
def my_tool(query: str) -> str: ...
Writing Input Validators¶
Block dangerous SQL¶
from agentguard.core.types import ToolCall, ValidationResult, ValidatorKind
DANGEROUS_KEYWORDS = {"DROP", "DELETE", "TRUNCATE", "ALTER", "CREATE", "GRANT", "REVOKE"}
def no_dangerous_sql(call: ToolCall) -> ValidationResult:
"""Block SQL queries with destructive keywords."""
sql = call.kwargs.get("sql", "")
found = [kw for kw in DANGEROUS_KEYWORDS if kw in sql.upper()]
if found:
return ValidationResult(
valid=False,
kind=ValidatorKind.CUSTOM,
message=f"Dangerous SQL keyword(s): {', '.join(found)}",
details={"keywords": found, "sql": sql[:200]},
)
return ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
@guard(validate_input=True, custom_validators=[no_dangerous_sql])
def run_query(sql: str) -> list[dict]: ...
Validate URL safety¶
import re
from agentguard.core.types import ToolCall, ValidationResult, ValidatorKind
BLOCKED_DOMAINS = {"internal.corp.com", "localhost", "127.0.0.1", "169.254.169.254"}
def safe_url_only(call: ToolCall) -> ValidationResult:
"""Prevent the agent from calling internal endpoints."""
url = call.kwargs.get("url", "")
match = re.search(r"https?://([^/]+)", url)
if not match:
return ValidationResult(
valid=False, kind=ValidatorKind.CUSTOM,
message=f"Invalid URL format: {url}"
)
domain = match.group(1).lower()
if any(domain == blocked or domain.endswith(f".{blocked}")
for blocked in BLOCKED_DOMAINS):
return ValidationResult(
valid=False, kind=ValidatorKind.CUSTOM,
message=f"Blocked domain: {domain}",
details={"url": url, "domain": domain},
)
return ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
@guard(custom_validators=[safe_url_only])
def fetch_url(url: str) -> str: ...
Enforce parameter ranges¶
from agentguard.core.types import ToolCall, ValidationResult, ValidatorKind
def safe_limit(call: ToolCall) -> ValidationResult:
"""Ensure limit parameter is within safe bounds."""
limit = call.kwargs.get("limit", 100)
if not isinstance(limit, int):
return ValidationResult(
valid=False, kind=ValidatorKind.CUSTOM,
message=f"limit must be an integer, got {type(limit).__name__}"
)
if limit > 10_000:
return ValidationResult(
valid=False, kind=ValidatorKind.CUSTOM,
message=f"limit {limit} exceeds maximum of 10,000",
details={"limit": limit, "max": 10_000},
)
return ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
Writing Output Validators¶
Output validators inspect the tool's return value. They receive a ToolResult:
from agentguard.core.types import ToolResult, ValidationResult, ValidatorKind
def no_pii_in_output(result: ToolResult) -> ValidationResult:
"""Detect potential PII in tool output."""
import re
output = str(result.return_value)
# Simple SSN pattern
if re.search(r'\b\d{3}-\d{2}-\d{4}\b', output):
return ValidationResult(
valid=False, kind=ValidatorKind.CUSTOM,
message="Potential SSN detected in output",
)
# Credit card pattern (basic Luhn check would be better)
if re.search(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b', output):
return ValidationResult(
valid=False, kind=ValidatorKind.CUSTOM,
message="Potential credit card number in output",
)
return ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
Attach to validate_output:
@guard(validate_output=True, custom_validators=[no_pii_in_output])
def search_user_data(email: str) -> dict: ...
Input vs Output Validators
The custom_validators list is passed to both input and output validation phases. The validator must handle both ToolCall and ToolResult inputs — check isinstance(call_or_result, ToolResult) to distinguish them.
Combining Multiple Validators¶
from agentguard import guard
validators = [
no_dangerous_sql,
safe_limit,
require_authenticated_session,
]
@guard(
validate_input=True,
custom_validators=validators,
)
def run_query(sql: str, limit: int = 100) -> list[dict]: ...
Validators run in order. The first failure stops the chain and raises ValidationError.
Stateful Validators¶
Validators can carry state (e.g., rate limiting, audit logging):
from agentguard.core.types import ToolCall, ValidationResult, ValidatorKind
import threading
class AuditLogger:
"""Log every tool call to an audit trail."""
def __init__(self, audit_store):
self.store = audit_store
self._lock = threading.Lock()
def __call__(self, call: ToolCall) -> ValidationResult:
with self._lock:
self.store.append({
"tool": call.tool_name,
"session": call.session_id,
"timestamp": call.timestamp.isoformat(),
"args": call.kwargs,
})
return ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
audit_log = []
auditor = AuditLogger(audit_log)
@guard(custom_validators=[auditor])
def sensitive_operation(data: str) -> dict: ...
Using SemanticValidator¶
For validation rules that are applied per-tool and registered by name, use SemanticValidator:
from agentguard.validators import SemanticValidator
validator = SemanticValidator()
# Register a rule for "run_query"
validator.register(
"run_query",
lambda call: (
ValidationResult(
valid=False,
kind=ValidatorKind.CUSTOM,
message="SQL too long",
)
if len(call.kwargs.get("sql", "")) > 5000
else ValidationResult(valid=True, kind=ValidatorKind.CUSTOM)
)
)
@guard(custom_validators=[validator])
def run_query(sql: str) -> list[dict]: ...
Best Practices¶
-
Keep validators pure — avoid side effects in validation logic. Use
after_callhooks for side effects like audit logging. -
Return detailed error messages — the agent sees the error and may try to correct its call. Clear messages help:
"limit 50000 exceeds maximum of 10000"is more actionable than"invalid limit". -
Use
detailsfor debugging —ValidationResult.detailsis included in trace logs. Put diagnostic information there. -
Validate at the right level — use
validate_input=Truefor business rule checks on arguments, andvalidate_output=Truefor checks on return values. Don't use validators as a replacement for proper type hints. -
Test your validators independently — validators are plain Python callables. Test them directly:
def test_no_dangerous_sql():
from agentguard.core.types import ToolCall
safe_call = ToolCall(tool_name="run_query", kwargs={"sql": "SELECT * FROM users"})
result = no_dangerous_sql(safe_call)
assert result.valid
dangerous_call = ToolCall(tool_name="run_query", kwargs={"sql": "DROP TABLE users"})
result = no_dangerous_sql(dangerous_call)
assert not result.valid
assert "DROP" in result.message