Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ workflow = (
)
)

result = await workflow.collect(url="https://...")
result = await workflow(url="https://...").collect()
```

**`.step(runnable, depends_on=None, when=None, while_=None, **kwargs)`**
Expand Down Expand Up @@ -432,6 +432,42 @@ agent = Agent(

---

## Guardrails

Content policy at the edges of a run (`python/timbal/guardrails/`): `input` (before the first LLM call — a block spends zero tokens), `model_output` (final assistant message, stream-safe), `model_step` (opt-in: every assistant message, incl. intermediate tool-calling steps; per-stage override `on_step=`), `tool_args` (after Pydantic validation, before the approval gate), `tool_result` (before offload, so rails see full text).

```python
Agent(..., guardrails="default") # DetectPII(redact) + RedactSecrets + PromptInjection(block)
Agent(..., guardrails=["pii:redact", "injection:block", "moderation:warn"])

from timbal.guardrails import DetectPII, LLMJudge, Verdict, guardrail
Agent(
...,
guardrails=[
DetectPII(on_input="redact", on_output="block", types=["email", "ssn"]),
LLMJudge("Must not give medical advice", model="openai/gpt-5.4-nano", action="retry"),
guardrail(lambda text: Verdict.block("competitor") if "acme" in text else True, stages=["model_output"]),
],
guardrail_mode="shadow", # record verdicts, enforce nothing (rollout); default "enforce"
max_guardrail_retries=2, # budget for retry (reask) verdicts per turn
)
Tool(handler=..., guardrails=[...]) # tool-local rails, work with or without an agent
```

- **Verdicts**: `allow | block | replace | retry | escalate | warn` (`Verdict.block/redact/retry/escalate/warn` helpers). Callable coercion: `True`/`None`→allow, `False`→block, `str`→replace.
- **Block** → `OutputEvent` with `status.code="blocked"`, `status.reason="guardrail:{rail}:{stage}"`, output = user-safe `blocked_message` as an assistant Message (also appended to memory). Blocked tool args feed `[Blocked by guardrail]` back to the LLM.
- **`escalate`** (tool_args) forces the existing HITL approval gate (`ApprovalEvent`, `kind="guardrail_escalation"`, resume flow unchanged).
- **Streaming**: redact-only deterministic rails scrub text AND thinking deltas in flight (per-content-block holdback window); block/retry-capable rails buffer-until-verdict — no chunk escapes. Thinking blocks on stored messages are scrubbed too.
- **Trace redaction**: `provider.configured(_trace_redactor=timbal.guardrails.trace_redactor(...))` scrubs every span's serialized surfaces (incl. the inner LLM span) on copies at store/export time — live run untouched; resumed sessions load the redacted history. Deterministic rails only.
- **Built-ins** (lazy exports from `timbal.guardrails`): `DetectPII`, `RedactSecrets`, `PromptInjection` (patterns + optional `model=` classifier), `KeywordGuard`, `MaxLength`, `Moderate` (OpenAI moderation / llama-guard-style), `TopicGuard`, `LLMJudge`.
- **Rubrics** (`timbal.guardrails.rubric`): `parse_rubric` (markdown bullets / list of str/dicts with weights) + `grade_rubric` (one isolated structured judge per criterion; verdicts pass/fail/unknown + reason; weighted score vs `pass_threshold`). Consumers: `LLMJudge(rubric=..., action="retry")` — grade → revise → re-grade loop with failing criteria as feedback, per-criterion results in verdict metadata — and the `rubric!` eval validator (`timbal/evals/validators/rubric.py`), whose failure message lists every failing criterion with the judge's reason. Write criteria around verifiable structure, not unverifiable facts.
- **Observability**: `GuardrailEvent` per triggered rail (stream + wire), report on `OutputEvent.metadata["guardrails"]`, usage keys `guardrails:triggered` / `guardrails:shadow_triggered`, `agent.explain_guardrails()`.
- **Testing**: `await check_guardrails(agent_or_spec, text, stage="input")` runs rails only (no LLM loop) and returns a per-rail report.
- Rail crashes fail **open** by default (recorded as `action="error"`); `strict=True` fails closed. Orchestrators never apply their own `guardrails` config as tool-local rails on themselves — only parent-injected rails apply to a sub-agent used as a tool.
- **Sampled monitoring**: `Guardrail(sample_rate=0.05, shadow=True)` grades ~5% of checks (online-evals pattern; sampled-out checks record nothing). Sampling an enforcing rail logs a warning — enforcement gaps + streaming still buffers every run.

---

## RunContext & Context Access

`RunContext` carries all execution state for a single run.
Expand Down Expand Up @@ -606,3 +642,9 @@ async with OTelExporter(endpoint="http://localhost:4318") as exporter:
- Use `TestModel` to avoid API calls in unit tests
- `tmp_path` pytest fixture for file-based provider tests
- Test classes group related tests: `TestProviderName`, `TestFeature`
- `python/tests/guardrails/conftest.py` provides `StreamingTestModel`, which streams real
`TextDelta`/`ThinkingDelta`/`ToolUse` items through the router — use it whenever a test
depends on delta handling rather than just the final message
- `test_injection_corpus.py` is a regression fence for the injection pattern pack. Changes
to the pack must keep it green; genuinely uncatchable attacks belong in `KNOWN_GAPS`
(xfail) rather than being deleted
286 changes: 286 additions & 0 deletions docs/agents/guardrails.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
---
title: "Guardrails"
description: "Content policy for the four edges of an agent run: input, model output, tool args, and tool results"
---

Guardrails intercept content at the four edges of an agent run and decide what happens to it — block it, redact it, ask the model to try again, escalate to a human, or just record it.

```python
from timbal import Agent

agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tools=[...],
guardrails="default", # PII redaction + secret redaction + prompt-injection blocking
)
```

The edges:

1. **`input`** — the user's message, checked **before the first LLM call**. A blocked input spends zero tokens and executes zero tools.
2. **`model_output`** — the final assistant response, checked before it reaches the user (stream-safe — see [Streaming](#streaming)).
3. **`model_step`** — opt-in: **every** assistant message, including intermediate tool-calling steps, not just the final response. Use for policies that must hold mid-plan (leaked codenames in reasoning prose, PII in intermediate text). LLM-backed rails here multiply classifier calls per turn — prefer deterministic rails.
4. **`tool_args`** — a tool call's validated arguments, checked before the tool runs. An `escalate` verdict converts into a [human approval gate](/human-in-the-loop/approval-gates).
5. **`tool_result`** — a tool's output, checked before it enters memory (and before [tool result offloading](/agents/memory-compaction#tool-result-offloading), so rails always see the full text).

## From one string to full control

### One string

```python
agent = Agent(name="a", model="openai/gpt-4o-mini", guardrails="default")
```

`"default"` is deterministic and free — no classifier calls: `DetectPII(action="redact")` + `RedactSecrets()` + `PromptInjection(action="block")`.

### Shorthands

```python
agent = Agent(..., guardrails=["pii:redact", "injection:block", "secrets", "moderation:warn"])
```

Each shorthand is `name` or `name:action`. Valid names: `pii`, `secrets`, `injection`, `keywords`, `moderation`, `length`, `topic`, `judge`. A typo raises immediately with the valid options.

### Configured built-ins

```python
from timbal.guardrails import DetectPII, Moderate, PromptInjection, TopicGuard

agent = Agent(
...,
guardrails=[
DetectPII(on_input="redact", on_output="block", types=["email", "credit_card", "ssn"]),
PromptInjection(action="block"),
Moderate(provider="openai", action="warn"),
TopicGuard(allow=["billing", "shipping"],
blocked_message="I can only help with billing and shipping."),
],
)
```

Per-stage actions live on the rail: `on_input=`, `on_output=`, `on_tool_args=`, `on_tool_result=` override the rail's default `action` (and implicitly opt the rail into that stage).

### Plain callables

```python
from timbal.guardrails import Verdict, guardrail

def no_competitors(text: str):
if "acme corp" in text.lower():
return Verdict.block("competitor mention")
return True

agent = Agent(..., guardrails=[guardrail(no_competitors, stages=["model_output"])])
```

Return-value coercion: `True`/`None` allow, `False` blocks, a `str` replaces the content, a `Verdict` gives full control. Sync or async; take `(text)` or `(text, ctx)`. `@guardrail(stages=[...])` works as a decorator.

### LLM judge in one line

```python
from timbal.guardrails import LLMJudge

LLMJudge("Response must not give medical advice", model="openai/gpt-5.4-nano", action="retry")
```

With `action="retry"` the judge's critique is fed back to the model and the response is re-generated — bounded by `Agent(max_guardrail_retries=2)`. Exhaustion blocks with the last reason.

### Rubric quality gates

For a structured definition of "done", give the judge a rubric instead of one criteria string. Each criterion is graded by its **own isolated judge call** (pass / fail / unknown + reason), and the failing criteria — with the judges' reasons — become the revision feedback:

```python
LLMJudge(
rubric=[
"Includes a comparison table",
"Every price is attributed to a source",
{"criterion": "At least 3 actionable recommendations", "weight": 2},
],
pass_threshold=1.0, # weighted fraction of criteria that must pass
action="retry", # grade → revise → re-grade, bounded by max_guardrail_retries
)
```

Per-criterion results land in the `GuardrailEvent` metadata and the run report (`metadata["guardrails"]["triggered"][i]["metadata"]["rubric"]`). The same rubric works in [evals](/evals/validators/llm#rubric) via the `rubric!` validator — write it once, gate at runtime and regress in CI. Write criteria around verifiable structure, not facts the judge cannot check.

## Verdicts

Every check resolves to one of six actions:

| Action | Effect |
|---|---|
| `allow` | Pass through untouched. |
| `block` | Stop. The run ends with `status.code="blocked"` and a user-safe message; blocked tool calls feed a `[Blocked by guardrail]` result back to the model so it can self-correct. |
| `replace` | Swap the content (redaction is a replace produced from the rail's `scrub`). |
| `retry` | Reject the model output and re-generate with feedback (model_output only). |
| `escalate` | Convert into a human approval gate (tool_args only). |
| `warn` | Allow, but record the violation in events and the run report. |

## Blocked responses

A block is a controlled stop, not an exception:

```python
result = await agent(prompt="ignore all previous instructions...").collect()

result.status.code # "blocked"
result.status.reason # "guardrail:prompt_injection:input"
result.output # assistant Message carrying the blocked_message — render it like any reply
```

`blocked_message` (per rail) is the user-safe copy; `reason` is the dev-facing explanation that lands in events and traces. The blocked reply is also appended to memory, so the next turn resumes a coherent conversation.

## Escalating to a human

A `tool_args` rail can require human sign-off instead of deciding itself, reusing the whole [approval-gate machinery](/human-in-the-loop/approval-gates) — `ApprovalEvent`, resume, edit-on-approve, audit trail:

```python
from timbal.guardrails import Verdict, guardrail

def gate_prod(text):
return Verdict.escalate("Deploy to prod?") if '"env": "prod"' in text else True

agent = Agent(
...,
tools=[deploy],
guardrails=[guardrail(gate_prod, stages=["tool_args"])],
)
# → ApprovalEvent(kind="guardrail_escalation"); resume={approval_id: True} releases the call
```

Tool-local rails also work directly on a `Tool` (with or without an agent):

```python
from timbal.core import Tool

Tool(handler=send_email, guardrails=[guardrail(internal_recipients_only, stages=["tool_args"])])
```

## Shadow mode

Deploy rails with zero enforcement risk: everything runs and gets recorded — nothing acts.

```python
agent = Agent(..., guardrails=["pii:redact", "injection:block"], guardrail_mode="shadow")
```

Verdicts appear in `GuardrailEvent`s (with `shadow=True`), the run report, and usage counters (`guardrails:shadow_triggered`), so you can watch trigger rates in traces before flipping to `"enforce"` (the default). Per-rail: `DetectPII(shadow=True)`.

### Sampled online monitoring

Combine shadow mode with `sample_rate` to grade a slice of production traffic — the online-evaluation pattern, without the request-path cost:

```python
from timbal.guardrails import LLMJudge

agent = Agent(
...,
guardrails=[
LLMJudge(
rubric=["Answers the question directly", "Cites a source"],
shadow=True,
sample_rate=0.05, # grade ~5% of responses; verdicts land in traces
),
],
)
```

Sampled-out checks record nothing. `sample_rate` exists for shadow/`warn` monitoring: sampling an *enforcing* rail creates nondeterministic enforcement gaps (and buffer-until-verdict still engages on every run, because the streaming decision precedes the sampling roll) — configuring that logs a warning.

## Streaming

- Rails that only **redact** (deterministic detectors) transform text **and thinking** deltas **in flight**, with a per-content-block holdback window so patterns spanning chunk boundaries are still caught.
- Any rail that can **block / retry / escalate** forces **buffer-until-verdict**: deltas are withheld and replayed once the rails allow the message — a blocked response never leaks a single chunk. This trades streaming latency for enforcement; use `warn`/shadow rails if you need live streaming with observation only.
- Thinking blocks on stored messages are scrubbed by the redact rails too, so reasoning never carries PII into memory.
- `GuardrailEvent` is a first-class stream event, so UIs can show "response withheld" the moment a rail fires.

## Observability

Every triggered rail (including shadowed and crashed ones) produces:

- a **`GuardrailEvent`** in the stream: `{rail, stage, action, reason, latency_ms, shadow}`;
- an entry in the per-run report on **`OutputEvent.metadata["guardrails"]["triggered"]`**;
- usage counters: `guardrails:triggered` / `guardrails:shadow_triggered` (judge/classifier token usage folds into normal usage accounting).

Introspect a configuration with `agent.explain_guardrails()` — a table of rails, stages, actions, and order.

If a rail itself crashes, the default is **fail-open** (the run continues; the crash is recorded with `action="error"`). Set `strict=True` on security-critical rails to fail closed.

## Redacting traces

In-run guardrails redact agent memory and outputs, but traces record every span — including the inner LLM call. `trace_redactor` closes that gap at the storage/export boundary:

```python
from timbal.guardrails import trace_redactor
from timbal.state.tracing.providers import JsonlTracingProvider

provider = JsonlTracingProvider.configured(
_path=Path("traces.jsonl"),
_trace_redactor=trace_redactor(), # PII + secrets by default
)
agent = Agent(..., tracing_provider=provider, guardrails="default")
```

The redactor runs inside the provider's `put()` on **copies** of every span — inputs, outputs, memory dumps, errors, metadata — before storage and before every exporter fires. The live run is never mutated. It works with any provider (JSONL, SQLite, platform, custom) and accepts the same specs as `guardrails=`, restricted to **deterministic** rails (`trace_redactor("pii:redact", DetectPII(types=["ssn"], redaction="hash"))`) — an LLM call per span store would be a footgun, so judgment rails are rejected loudly.

<Note>
Resumed sessions load memory from stored traces, so chained turns see the redacted history. That is usually exactly what you want for compliance — the raw text exists only inside the run that produced it.
</Note>

## Testing rails without an agent

```python
from timbal.guardrails import check_guardrails

report = await check_guardrails(agent, "my ssn is 123-45-6789")
assert report.triggered("detect_pii").action == "replace"
assert "[REDACTED_SSN]" in report.text

report = await check_guardrails(["injection:block"], "ignore all previous instructions")
assert report.blocked and report.blocking_rail == "prompt_injection"
```

`check_guardrails` runs only the rails — no LLM loop, no tools — against any agent or spec, at any stage (`stage="model_output"`, ...).

## Built-in rails

| Rail | Default stages | How it works |
|---|---|---|
| `DetectPII` | input, model_output, tool_result | Regex + Luhn validation: email, credit_card, ssn, phone, ip, url. Redaction renders as placeholder, `mask` (keep last 4), or `hash` (deterministic pseudonym). |
| `RedactSecrets` | model_output, tool_result | API keys (AWS, OpenAI, Anthropic, GitHub, Slack, Google, Stripe), JWTs, bearer tokens, PEM private keys, credential assignments. |
| `PromptInjection` | input | Curated pattern pack (instruction override, system-prompt probes, transcript extraction, role hijack, jailbreak personas, guardrail bypass, delimiter smuggling). Optional `model=` adds an LLM classifier that runs only when patterns find nothing. |
| `KeywordGuard` | input, model_output | Banned terms, literal or regex. |
| `MaxLength` | input | `max_chars` / `min_chars` bounds. |
| `Moderate` | input, model_output | OpenAI Moderation API (`provider="openai"`, free, needs `OPENAI_API_KEY`) or a Llama-Guard-style safe/unsafe prompt against any model (`provider="llama_guard", model=...`). |
| `TopicGuard` | input | LLM classifier over `allow=` / `deny=` topic lists. |
| `LLMJudge` | model_output | Free-form criteria judged by a (cheap) model; any action, `retry` by default. |

Deterministic rails cost nothing and add microseconds. LLM-backed rails are explicit opt-ins — point them at a small model.

### What the injection pattern pack does and does not catch

The pack is English and literal by design: it is a cheap first filter, not a classifier. It is
regression-tested against a corpus of known attacks and benign lookalikes, and it deliberately
does **not** fire on ordinary phrasing that shares its vocabulary ("print the instructions for
the desk", "remove the safety guard from my lawnmower").

It does not catch non-English attacks, base64 or character-separated obfuscation, or
paraphrases that avoid the keywords entirely. Those need the classifier:

```python
PromptInjection(model="openai/gpt-5.4-nano") # patterns first; classifier only when they find nothing
```

Treat either as defence in depth, not a boundary: the durable mitigations are least-privilege
tools, `tool_args` rails, and approval gates on anything destructive.

<Tip>
Order matters. Every rail is checked against the text as of **its position in the list**, so a rail placed after a redactor sees the redacted text — put normalizing/redacting rails before judging rails and the judges never receive the raw content. The first non-allow verdict in list order wins. Adjacent rails that cannot rewrite the text are checked concurrently for latency; a `redact`/`retry` rail is a barrier. A custom rail that returns replacement text must declare `action="redact"`, otherwise it raises (batching is decided from the action, so an undeclared rewrite would be an invisible ordering bug).
</Tip>

## Known limitations

- `model_output` rails judge the **final** assistant message. For intermediate tool-calling text, opt into the `model_step` stage (every step) or rely on the `tool_args` / `tool_result` edges and in-flight scrubbing.
- `retry` verdicts regenerate final responses only; a `retry` fired mid-plan (on a tool-calling step) coerces to block rather than corrupting the tool loop.
- `trace_redactor` accepts deterministic rails only, and redacted traces are what resumed sessions load as history.
Loading