Agent Safety, Guardrails, and Alignment
How to prevent agents from making harmful decisions. Covers input validation, action filtering, monitoring, and recovery from policy violations.
Learning objectives
- Implement input validation to reject malicious or out-of-scope requests before they reach the model
- Design action filters that prevent the agent from calling dangerous tools
- Monitor agent behavior and detect policy violations: when the agent is doing something it shouldn't
ToolDix original visual
Frame
Name the outcome and constraints.
Build
Try one bounded workflow.
Review
Keep evidence, revise, and share.
The safety challenge
An agent with real-world powers (tool access, database connections) can cause harm:
- Unauthorized access: An agent may be tricked into querying a database it shouldn't read.
- Misspent resources: An agent might call expensive APIs repeatedly, costing thousands of dollars.
- Unintended side effects: An agent trained to "find the best deal" deletes a legitimate order to complete a refund.
- Social engineering: Users may try to trick the agent into breaking its own rules.
Safety layers prevent these harms.
Defense-in-depth strategy overview
A comprehensive safety system uses multiple layers. Here's how they work together:
| Layer | Mechanism | Catches | Cost | |-------|-----------|---------|------| | Input validation | Regex patterns, length/structure checks | Obvious injection attacks, OOB requests | Negligible (synchronous checks) | | Scope checking | Keyword matching or LLM classification | Off-topic or out-of-authority requests | Low (one LLM call per request) | | Rate limiting | Token bucket; per-user/per-tool quotas | Resource exhaustion, cost runaway | Negligible (in-memory tracking) | | Permissions matrix | Role-based access control (RBAC) | Unauthorized tool access | Negligible (lookup table) | | Action filtering | Policy checks before dispatch | Violated business rules (e.g., "no delete > $10k") | Low (policy evaluation) | | Monitoring & anomaly | Logs + statistical outliers | Subtle misuse (e.g., slow data exfil) | Medium (log storage, ML inference) | | Approval gate | Human review for high-risk actions | Critical mistakes (delete user, transfer funds) | High (human latency) | | Rollback capability | Transactional undo | Executed harmful actions | Varies (DB rollback vs. API undo) |
No single layer is foolproof. An agent might slip past input validation with a subtle prompt injection, or rate limiting with distributed calls. Layers are defense-in-depth: if one fails, others catch the issue.
Input validation and scope checking
Pre-agent validation
The first line of defense: filter requests before they reach the agent.
import re
from typing import Tuple
class InputValidator:
"""Validate user input before passing to agent."""
def __init__(self, max_input_length: int = 10000):
self.max_input_length = max_input_length
# Pattern list for obviously harmful input
self.injection_patterns = [
r"DROP\s+TABLE", # SQL injection
r"rm\s+-rf", # Shell injection
r"__import__\(", # Python injection
]
def validate(self, user_input: str) -> Tuple[bool, str]:
"""
Validate user input. Return (is_valid, error_message).
"""
# Length check
if len(user_input) > self.max_input_length:
return False, f"Input too long (max {self.max_input_length} chars)"
# Empty check
if not user_input.strip():
return False, "Input cannot be empty"
# Pattern check
for pattern in self.injection_patterns:
if re.search(pattern, user_input, re.IGNORECASE):
return False, "Input contains forbidden patterns"
return True, ""
def check_scope(self, user_input: str, allowed_topics: list[str]) -> Tuple[bool, str]:
"""
Verify the request is about an allowed topic.
Uses keyword matching (simple) or LLM-based classification (flexible).
"""
# Simple keyword matching
input_lower = user_input.lower()
for topic in allowed_topics:
if topic.lower() in input_lower:
return True, ""
# If no keyword match, use LLM to classify
classification_prompt = f"""
The user asked: "{user_input}"
Is this request about one of these topics? {', '.join(allowed_topics)}
Answer YES or NO.
"""
response = model.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=10,
messages=[{"role": "user", "content": classification_prompt}]
)
is_in_scope = "YES" in response.content[0].text.upper()
if is_in_scope:
return True, ""
else:
return False, f"Request is outside allowed topics: {', '.join(allowed_topics)}"
# Usage:
validator = InputValidator()
scope_topics = ["customer_support", "order_tracking", "product_info"]
user_input = "Can you delete my account?"
valid, error = validator.validate(user_input)
if not valid:
print(f"Invalid input: {error}")
else:
in_scope, error = validator.check_scope(user_input, scope_topics)
if not in_scope:
print(f"Out of scope: {error}")
else:
result = agent.run(user_input)
Tool whitelisting and call filtering
Rate limiting by tool
Even legitimate tools can be abused. Rate-limit calls to prevent resource exhaustion:
from collections import defaultdict
from datetime import datetime, timedelta
class ToolCallFilter:
"""Monitor and limit tool usage to prevent abuse."""
def __init__(self):
self.call_history = defaultdict(list) # tool_name -> list of timestamps
self.call_limits = {
"send_email": {"calls": 10, "window_minutes": 60}, # Max 10 emails/hour
"query_database": {"calls": 100, "window_minutes": 5}, # Max 100 queries/5min
"call_api": {"calls": 50, "window_minutes": 1}, # Max 50 API calls/minute
}
def is_allowed(self, tool_name: str) -> Tuple[bool, str]:
"""Check if a tool call is allowed under rate limits."""
if tool_name not in self.call_limits:
return False, f"Tool '{tool_name}' is not whitelisted"
limit = self.call_limits[tool_name]
now = datetime.now()
window_start = now - timedelta(minutes=limit["window_minutes"])
# Clean old history
self.call_history[tool_name] = [
ts for ts in self.call_history[tool_name]
if ts > window_start
]
# Check count
recent_calls = len(self.call_history[tool_name])
if recent_calls >= limit["calls"]:
return False, (
f"Rate limit exceeded for '{tool_name}': "
f"{recent_calls}/{limit['calls']} calls in last {limit['window_minutes']} minutes"
)
# Add this call to history
self.call_history[tool_name].append(now)
return True, ""
# Usage in dispatcher:
filter = ToolCallFilter()
def safe_dispatch(tool_call: dict) -> dict:
"""Dispatch a tool call after checking rate limits."""
tool_name = tool_call["name"]
# Check if tool is allowed
allowed, reason = filter.is_allowed(tool_name)
if not allowed:
logger.warning(f"Tool call blocked: {reason}")
return {"error": reason, "tool_name": tool_name}
# Execute the tool
return dispatch_tool(tool_call)
Permission matrix
For multi-user systems, define who can call which tools:
from enum import Enum
class UserRole(Enum):
ADMIN = "admin"
USER = "user"
GUEST = "guest"
class PermissionMatrix:
"""Define which roles can use which tools."""
def __init__(self):
self.permissions = {
UserRole.ADMIN: ["send_email", "query_database", "call_api", "delete_user"],
UserRole.USER: ["send_email", "query_database"],
UserRole.GUEST: ["query_database"],
}
def can_use_tool(self, user_role: UserRole, tool_name: str) -> bool:
"""Check if a role can use a tool."""
return tool_name in self.permissions.get(user_role, [])
# Usage:
permissions = PermissionMatrix()
def safe_dispatch_with_permissions(
tool_call: dict,
user_role: UserRole
) -> dict:
"""Dispatch only if user has permission."""
tool_name = tool_call["name"]
if not permissions.can_use_tool(user_role, tool_name):
logger.warning(f"Permission denied for {user_role.value}: {tool_name}")
return {"error": f"You don't have permission to use '{tool_name}'"}
return dispatch_tool(tool_call)
Monitoring and policy enforcement
Action logging
Log every significant action for audit and compliance:
import json
from datetime import datetime
class ActionLogger:
"""Log all agent actions for monitoring and audit."""
def __init__(self, log_file: str = "agent_actions.jsonl"):
self.log_file = log_file
def log_action(self, action: dict):
"""Log an action (tool call, decision, error) to file."""
log_entry = {
"timestamp": datetime.now().isoformat(),
"user_id": action.get("user_id"),
"action_type": action.get("type"), # "tool_call", "error", "policy_violation"
"tool_name": action.get("tool_name"),
"details": action.get("details"),
"status": action.get("status"), # "allowed", "denied", "failed"
}
with open(self.log_file, 'a') as f:
f.write(json.dumps(log_entry) + "\n")
def flag_suspicious(self, reason: str, action: dict):
"""Flag suspicious behavior for human review."""
logger.warning(f"SUSPICIOUS: {reason}")
self.log_action({
"type": "policy_violation",
"status": "flagged",
"details": reason,
**action
})
# Usage:
logger = ActionLogger()
result = safe_dispatch(tool_call)
logger.log_action({
"user_id": user_id,
"type": "tool_call",
"tool_name": tool_call["name"],
"status": "allowed" if "error" not in result else "denied",
"details": str(result)
})
Anomaly detection
Flag unusual patterns:
from collections import defaultdict
import statistics
class AnomalyDetector:
"""Detect unusual agent behavior."""
def __init__(self):
self.action_counts = defaultdict(list) # tool_name -> [count, count, ...]
self.baseline_window = 100 # Learn from last N calls
def record_call(self, tool_name: str):
"""Record a tool call."""
self.action_counts[tool_name].append(1)
def is_anomalous(self, tool_name: str) -> Tuple[bool, str]:
"""
Check if current call pattern is anomalous (e.g., calling same tool too many times).
Uses basic statistics: flag if > 2 std devs from mean.
"""
if tool_name not in self.action_counts:
return False, ""
history = self.action_counts[tool_name][-self.baseline_window:]
if len(history) < 10:
return False, "" # Not enough data
# Count consecutive calls
consecutive = sum(history[-5:]) # Calls in last 5 actions
mean = statistics.mean(history)
stdev = statistics.stdev(history) if len(history) > 1 else 0
if stdev == 0:
return False, ""
z_score = abs(consecutive - mean) / (stdev + 0.1)
if z_score > 2:
return True, f"Unusual call pattern for {tool_name} (z-score: {z_score:.1f})"
return False, ""
# Usage:
detector = AnomalyDetector()
result = dispatch_tool_call(tool_call)
detector.record_call(tool_call["name"])
is_anomalous, reason = detector.is_anomalous(tool_call["name"])
if is_anomalous:
logger.flag_suspicious(reason, {"tool_name": tool_call["name"]})
Case Study: When safety layers saved a company $50k
A healthcare startup deployed an agent to help administrative staff schedule appointments and manage patient records. They implemented:
- Input validation (length limits, pattern checks).
- Rate limiting (10 requests/minute per user).
- Permissions matrix (staff could only access their own clinic's data).
One afternoon, a junior staff member made a typo in a request and accidentally issued a command that would have deleted all patients in a clinic (a bug in the tool implementation, not the agent's fault). But the TransactionalAgent with rollback caught it: the deletion violated a policy, triggered rollback, and alerted the admin team.
Without rollback, the company would have had to:
- Restore from backup (4-hour downtime).
- Notify affected patients (compliance risk).
- Investigate who caused the deletion (blame-finding).
- Lawyer fees for potential liability.
The lesson: Build safety layers incrementally. Start with input validation and rate limiting (low cost, high benefit). Add permissions and action filtering once the system touches critical data. Reserve approval gates and rollback for the highest-risk actions. Layering saves you from a single-point catastrophe.
Edge case: The "benign violation" problem
Sometimes an agent's behavior is technically within the rules but violates the spirit of the rules:
# Rule: "Max 100 database queries per minute"
# Agent behavior: Makes exactly 100 queries, all simultaneously
# Result: Database gets hammered; query latency spikes 50x
# Did the agent violate the rule? Technically no.
# Should it? Absolutely.
To handle benign violations:
- Add burstiness constraints: Not just "100 per minute" but "no more than 20 per second."
- Monitor derived metrics: Don't just track "queries succeeded" — track "average query latency." If latency degrades, throttle even if query count is low.
- Use adaptive policies: "If average query latency > 500ms, reduce concurrency by 50% regardless of rate limit."
Recovery and escalation
Asking for approval
For high-risk actions, require user approval:
class ApprovalRequired:
"""Mark high-risk tool calls that need human approval."""
HIGH_RISK_TOOLS = [
"delete_user",
"transfer_funds",
"send_mass_email",
]
@staticmethod
def requires_approval(tool_name: str) -> bool:
"""Check if a tool call needs approval."""
return tool_name in ApprovalRequired.HIGH_RISK_TOOLS
@staticmethod
def request_approval(tool_call: dict, reason: str) -> bool:
"""
Request user approval for a tool call.
Returns True if approved, False otherwise.
In a real system, this might send a Slack message, trigger a 2FA prompt, etc.
"""
prompt = f"""
The agent wants to execute the following action:
Tool: {tool_call['name']}
Parameters: {tool_call['input']}
Reason: {reason}
Do you approve? [Y/n]
"""
response = input(prompt).strip().lower()
return response != 'n'
# Usage:
if ApprovalRequired.requires_approval(tool_call["name"]):
approved = ApprovalRequired.request_approval(tool_call, "Agent determined this action is necessary")
if not approved:
return {"error": "User denied approval for this action"}
result = dispatch_tool(tool_call)
Rollback on violation
Some systems allow rolling back actions if they violate policy:
class TransactionalAgent:
"""Execute actions within a transaction; rollback if policy violated."""
def __init__(self, transaction_store):
self.transaction_store = transaction_store
def execute_with_rollback(self, actions: list[dict], policies: list[callable]) -> Tuple[bool, str]:
"""
Execute multiple actions. Rollback if any violate policy.
"""
transaction_id = self.transaction_store.begin()
try:
for action in actions:
# Check policies
for policy_check in policies:
if policy_check(action):
# Policy violated; rollback everything
self.transaction_store.rollback(transaction_id)
return False, f"Action violates policy: {policy_check.__name__}"
# Execute action
result = dispatch_tool(action)
if "error" in result:
self.transaction_store.rollback(transaction_id)
return False, f"Action failed: {result['error']}"
# All actions succeeded and passed policies
self.transaction_store.commit(transaction_id)
return True, "All actions executed successfully"
except Exception as e:
self.transaction_store.rollback(transaction_id)
return False, f"Unexpected error: {e}"
# Example policies:
def policy_no_customer_deletion(action: dict) -> bool:
"""Prevent deleting customer records."""
return action.get("name") == "delete_customer"
def policy_max_transaction_value(action: dict, max_value: float = 10000) -> bool:
"""Prevent transactions over a threshold."""
amount = action.get("input", {}).get("amount", 0)
return amount > max_value
# Usage:
agent = TransactionalAgent(transaction_store)
actions = [
{"name": "create_refund", "input": {"user_id": 123, "amount": 500}},
{"name": "send_notification", "input": {"user_id": 123, "text": "Refund processed"}},
]
policies = [
policy_no_customer_deletion,
lambda a: policy_max_transaction_value(a, max_value=5000),
]
success, message = agent.execute_with_rollback(actions, policies)
print(f"Transaction: {message}")
Monitoring safety in the wild
Once safety layers are in place, instrument them to detect when they're triggered:
from prometheus_client import Counter, Histogram
# Track safety events
input_rejections = Counter('input_rejections_total', 'Rejected inputs', ['reason'])
scope_violations = Counter('scope_violations_total', 'Out-of-scope requests')
rate_limit_hits = Counter('rate_limit_hits_total', 'Rate limit exceeded', ['tool_name'])
approval_denials = Counter('approval_denials_total', 'User denied approval')
policy_violations = Counter('policy_violations_total', 'Action violated policy', ['policy_name'])
# Usage in your agent dispatcher:
def safe_agent_call(user_input: str, user_id: str):
# Input validation
valid, error = validator.validate(user_input)
if not valid:
input_rejections.labels(reason=error).inc()
return {"error": error}
# Scope check
in_scope, error = validator.check_scope(user_input, allowed_topics)
if not in_scope:
scope_violations.inc()
return {"error": error}
# Rate limiting
allowed, error = limiter.is_allowed(user_id, tool_name)
if not allowed:
rate_limit_hits.labels(tool_name=tool_name).inc()
return {"error": error}
# ... execute agent ...
return result
# In your monitoring dashboard:
# Alert if input_rejections > 100/hour (indicates attack probe)
# Alert if scope_violations spike (indicates confused users or misconfig)
# Alert if rate_limit_hits climb (indicates heavy usage or abuse)
Monitoring safety events early warns you of emerging threats and misconfigurations before they become incidents.
Common mistake
Assuming your safety layers are bulletproof and then deploying without monitoring. Even well-designed filters have gaps. Users are creative at finding workarounds. Treat safety as a continuous process: implement a layer, monitor what happens, find gaps, patch them. The layers (input validation, rate limits, approval, logging) are your defense-in-depth, but they work only if you actively monitor and iterate.
Sources and license context
These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.
- Anthropic: Constitutional AI and Alignment (opens anthropic.com in a new tab)External · anthropic.com (Anthropic research terms apply)
- OWASP: AI Security (opens owasp.org in a new tab)External · owasp.org (OWASP license)
- Adversarial Examples and Robustness in Machine Learning (opens arxiv.org in a new tab)External · arxiv.org (arXiv terms apply)
Keep going
Read these next on ToolDix.
Original lessons that build on what you just read.