Safety, guardrails, and steering¶
Three layers cooperate inside an agent run:
- Validation — typed tool arguments are JSON-schema-checked before the call lands. No opt-in needed.
- Guardrails — content policy, PII redaction, dangerous-tool blocking, prompt/result length caps. Runs as a hook on the prompt-in / output-out boundaries.
- Steering — a second model evaluates every tool call before it fires. The judge sees the recent tool-call activity and your policy, and emits one of PROCEED / GUIDE / INTERRUPT (allow, cancel with feedback, or pause for a human).
Each layer plugs in independently. You can turn one on without the others.
When to reach for which layer¶
| Situation | Layer |
|---|---|
| Tool args from the model are sometimes malformed | Validation — already on; nothing to do |
| Public-facing agent — block prompt injection, SQL/command/path-traversal patterns, cap input length | GuardrailsHook with the default GuardrailConfig |
| Customer-facing answer where leaking PII (emails, SSN, credit cards, IPs) is a compliance issue | GuardrailsHook with PII patterns enabled |
High-stakes tools (isolate_host, block_indicator, delete_*) — want a second model to sanity-check the call |
SteeringHook with a steering model= and a policy string |
| Domain restriction — "the user came in to triage this alert, reject anything else" | SteeringHook with that policy verbatim |
| Internal-only agent, trusted prompts, low-stakes tools | none of the above; default validation is enough |
Getting started¶
Guardrails — block dangerous tools and redact PII¶
from tulip.agent import Agent
from tulip.hooks.builtin.guardrails import (
GuardrailsHook, GuardrailConfig, GuardrailAction,
)
config = GuardrailConfig(
block_dangerous_tools=frozenset({"shell", "exec", "rm", "drop"}),
max_prompt_length=50_000,
default_action=GuardrailAction.BLOCK,
)
agent = Agent(
model="anthropic:claude-sonnet-4-6",
tools=[query_siem, summarise],
hooks=[GuardrailsHook(config=config)],
)
GuardrailsHook ships with sensible defaults — the empty
GuardrailConfig() already blocks eval, exec, system, shell,
rm, delete, drop, truncate; detects email / phone / SSN /
credit-card / IP patterns; and watches for SQL-injection,
path-traversal, and command-injection shapes in tool inputs.
Topic and content policies — domain restriction¶
TopicPolicy and ContentPolicy are enforced by OutputFilterHook,
which scans the model's output (not tool inputs — that's
GuardrailsHook's job):
from tulip.hooks.builtin.guardrails import (
OutputFilterHook, TopicPolicy, ContentPolicy,
)
topic_policy = TopicPolicy(
blocked_topics={"weapons", "hacking"},
keywords={
"weapons": ["gun", "rifle", "ammunition"],
"hacking": ["exploit", "zero-day", "rootkit"],
},
)
content_policy = ContentPolicy(
enabled_categories={"hate_speech", "self_harm", "illegal_activity"},
)
agent = Agent(
model="anthropic:claude-sonnet-4-6",
tools=[...],
hooks=[OutputFilterHook(
topic_policy=topic_policy,
content_policy=content_policy,
)],
)
Both policies are simple keyword classifiers — fast, predictable,
auditable. For production-grade content moderation, swap in an
ML-backed policy (OpenAI Moderation, etc.)
behind the same Policy.check(text) -> str | None shape.
Steering — a second model judges every tool call¶
from tulip.models import AnthropicModel
from tulip.hooks.builtin.steering import SteeringHook
# The steering model is a model instance — it can be a smaller/cheaper
# model than the agent's main one.
judge = AnthropicModel(model="claude-sonnet-4-6")
agent = Agent(
model="anthropic:claude-sonnet-4-6",
tools=[query_siem, block_indicator, isolate_host],
hooks=[
SteeringHook(
model=judge,
policy=(
"The user came in to triage alert A-4271. "
"Block any tool call unrelated to that alert."
),
),
],
)
Before block_indicator or isolate_host fires, the judge sees the
recent tool-call activity and your policy, then emits one of three
decisions:
- PROCEED — the call goes through.
- GUIDE — the call is cancelled and corrective feedback is fed back to the model, triggering a re-plan.
- INTERRUPT — the run pauses for human approval (also used for any
tool listed in
interrupt_tools=).
Use the smallest model that gives reliable decisions — a mini /
flash / haiku is usually enough.
Validation (you don't have to do anything)¶
The @tool decorator builds a JSON schema from the function's typed
signature. Every model tool call goes through that schema before the
function body runs. Schema violations come back to the model as a
tool error so it can retry with corrected arguments — you don't have
to write any of that defensively.
@tool
def isolate_host(host_id: str, incident_id: str, mode: Literal["full", "soft", "audit"]) -> dict:
...
A model call with mode="quarantine" is rejected before the body
runs; the model sees the typed-error message and retries with "full".
Structural guardrails — restrict the tool, not just the call¶
The strongest guardrail is a tool that cannot perform the dangerous action in the first place. Hooks and steering inspect a call and decide; a capability-restricted tool removes the capability, so there is nothing to decide. The two compose — but when you can constrain the tool, do that first.
The cloud-posture agent is the worked example. Its
use_aws tool admits an operation only if its name starts with a read verb
(Describe / List / Get / …), and it checks before any API call:
from tulip.security import use_aws
use_aws("iam", "CreateUser", {"UserName": "x"})
# PermissionError: refused: iam:CreateUser is not a read-only operation.
The principle is defense in depth across a trust boundary: pair an in-process gate (fast, explicit intent) with an out-of-process backstop that doesn't trust your code at all —
| In-process gate | Out-of-process backstop |
|---|---|
read-verb allowlist in use_aws |
read-only IAM identity (SecurityAudit + ViewOnlyAccess) |
block_dangerous_tools in GuardrailsHook |
container without the binary; seccomp profile |
| SQL-shape detection in tool inputs | database role with SELECT-only grants |
If the gate is ever bypassed, the backstop still refuses. Neither layer trusts the model — and the backstop doesn't trust the gate either.
Common gotchas¶
| Symptom | Likely cause |
|---|---|
| PII redaction over-aggressive | The default IP regex matches version strings too. Drop ip_address from pii_patterns or tighten to a CIDR-aware pattern. |
| Steering rejects almost everything | Judge model is too strict. Tune the policy or move to a stronger model — a nano is often too small for nuanced judgement. |
GuardrailsHook blocks a legitimate message |
Inspect hook._violations after the run for the violation type, then add an action override (action_overrides={"sql_injection": ALLOW}) or trim the regex. |
| Validation error swallows a tool-arg bug | The error came back to the model — it's in the trace, look for ToolCompleteEvent.error. |
Source and notebooks¶
notebook_50_guardrails_security.py— basic guardrails.notebook_51_guardrails_advanced.py— topic + content + PII layered.notebook_49_steering.py— judge-model approval.tulip.hooks.builtin.guardrailstulip.hooks.builtin.steering
See also¶
- Hooks — how
GuardrailsHookandSteeringHookplug into the lifecycle. - Tools — the
@tooldecorator and its schema validation. - Reasoning: grounding — the answer-side analogue, claim-by-claim.
- Cloud-posture agent — read-only-by-construction tools in practice.
- Threat scenarios — which guardrail defends which OWASP / ATLAS item.