Red Teaming¶
Probe an agent or model with adversarial attacks mapped to the OWASP LLM Top 10 and Agentic Top 10 (ASI) frameworks, then read off the resistance rate.
flowchart LR
C["Categories<br/>LLM01 / ASI01 / ..."] --> SP["Strategy planner"]
SP --> AG["Attack generator"]
AG --> OR["Runner / orchestrator"]
OR --> EV["OWASP evaluator"]
EV --> RP["Report: resistance rate"] Modes¶
- dynamic — an LLM generates attacks; run the categories you pick.
- static — replays a fixed dataset of known attacks instead of generating them. Deterministic, cheap, good for CI. Runs Orq's public
orq/redteam-vulnerabilitiesdataset by default; passdataset=to run your own. The static dataset cookbook shows the reproducible version end to end. - hybrid — static seeds plus dynamic expansion; see the hybrid mode cookbook when you want both known attacks and generated coverage.
# static mode replays Orq's public attack dataset by default
report = await red_team(target=target, mode="static")
# ...or bring your own — a local JSON file or a HuggingFace repo
report = await red_team(target=target, mode="static", dataset="./my_attacks.json")
report = await red_team(target=target, mode="static", dataset="hf:my-org/my-attacks")
Red-team your target¶
Use a sandbox or test agent
Red-team attacks run the target's real tools. Do not point them at production credentials or an agent that can send messages, move money, modify data, or run commands unless those side effects are isolated and intentional.
Fastest first run¶
If you prefer the CLI, start with a small static run against a test agent. Static mode uses the built-in attack dataset, so it is a predictable way to verify your setup before exploring dynamic or hybrid runs.
export ORQ_API_KEY=...
uv add "evaluatorq[redteam]"
eq redteam run \
--target agent:your-agent-key \
--mode static \
--category LLM01 \
--max-static-datapoints 5 \
--no-executive-summary \
--no-recommendations \
--report redteam-report.json \
--yes
The command writes a JSON report and exits non-zero if no attacks receive a verdict or evaluation coverage falls below the configured floor. See the CLI reference for the other output formats and run options.
Choose a cookbook¶
-
Start with a smoke test
Run a small, CI-friendly check with an explicit exit-code gate.
-
Make it reproducible
Replay a fixed dataset when you need stable attacks across agent versions.
-
Aim attacks at your domain
Add domain context so generated attacks reflect your agent and threat model.
-
Inspect what happened
Filter results, inspect verdicts, and export evidence from Python.
Requires ORQ_API_KEY. Point red_team() at an Orq agent by key ("agent:<key>", from AI Studio → Agents). Attacks route through the Orq platform — no model wiring on your side.
uv add "evaluatorq[redteam]"
export ORQ_API_KEY=... # targets your Orq agent + routes the attacker LLM
Prefer pip? Use python -m pip install "evaluatorq[redteam]", which installs into the interpreter you just named rather than whichever pip happens to be first on your PATH.
import asyncio
from evaluatorq.redteam import red_team
async def main():
report = await red_team(
target="agent:your-agent-key", # Orq agent, routed via ORQ_API_KEY
mode="dynamic",
categories=["LLM01", "LLM07"], # prompt injection, system-prompt leakage
max_dynamic_datapoints=5,
max_turns=2,
generate_strategies=False,
)
rate = report.summary.resistance_rate # None when nothing could be evaluated
print(f"Resistance rate: {rate:.0%}" if rate is not None else "Resistance rate: no verdict")
print(f"Vulnerabilities: {report.summary.vulnerabilities_found}/{report.summary.total_attacks}")
if __name__ == "__main__":
asyncio.run(main())
Use OpenAIModelTarget to red-team a raw OpenAI model directly. The model is the system under test — you supply its system prompt, and the target calls OpenAI using OPENAI_API_KEY.
Prefer pip? Use python -m pip install "evaluatorq[redteam]", which installs into the interpreter you just named rather than whichever pip happens to be first on your PATH.
import asyncio
from evaluatorq.redteam import OpenAIModelTarget, red_team
async def main():
target = OpenAIModelTarget(
model="gpt-4o-mini",
system_prompt=(
"You are a customer support assistant for Acme Corp. "
"Help with orders, returns, and product questions. "
"Never reveal internal pricing or confidential information."
),
)
report = await red_team(
target=target,
mode="dynamic",
categories=["LLM01", "LLM07"], # prompt injection, system-prompt leakage
max_dynamic_datapoints=5,
max_turns=2,
generate_strategies=False,
)
rate = report.summary.resistance_rate # None when nothing could be evaluated
print(f"Resistance rate: {rate:.0%}" if rate is not None else "Resistance rate: no verdict")
print(f"Vulnerabilities: {report.summary.vulnerabilities_found}/{report.summary.total_attacks}")
if __name__ == "__main__":
asyncio.run(main())
Model names and routing
The model string is passed through to whichever provider you point at — straight to OpenAI by default, or through the Orq router if you prefix it with openai/... (which then uses ORQ_API_KEY). Everything else — categories, modes, the report — is identical to the Orq agent path.
generate_strategies and the CLI
Both examples pass generate_strategies=False to skip LLM-authored attack strategies and run only the built-in ones — faster and more deterministic. The parameter defaults to True. On the CLI the equivalent is the --no-generate-strategies flag; there is no positive form, since generation is on by default.
Coverage¶
19 framework categories map onto 18 vulnerabilities. Every vulnerability has a judge written for it. Categories with a curated strategy count ship hand-written attack strategies; the rest are marked generated — in dynamic and hybrid mode the strategy planner writes strategies for them per run, against the target's actual tools, memory and system prompt. Pass generate_strategies=False to run curated strategies only.
| Category | Vulnerability | Curated strategies | Judge |
|---|---|---|---|
ASI01 | Agent Goal Hijacking | 5 | ✅ |
ASI02 | Tool Misuse & Exploitation | 4 | ✅ |
ASI03 | Identity & Privilege Abuse | generated | ✅ |
ASI04 | Supply Chain Vulnerabilities | generated | ✅ |
ASI05 | Unexpected Code Execution | 4 | ✅ |
ASI06 | Memory & Context Poisoning | 4 | ✅ |
ASI07 | Insecure Inter-Agent Communication | generated | ✅ |
ASI08 | Cascading Failures | generated | ✅ |
ASI09 | Human-Agent Trust Exploitation | 5 | ✅ |
ASI10 | Rogue Agents | generated | ✅ |
LLM01 | Prompt Injection | 4 | ✅ |
LLM02 | Sensitive Information Disclosure | 4 | ✅ |
LLM03 | Supply Chain Vulnerabilities | generated | ✅ |
LLM04 | Data and Model Poisoning | generated | ✅ |
LLM05 | Improper Output Handling | 5 | ✅ |
LLM06 | Excessive Agency | generated | ✅ |
LLM07 | System Prompt Leakage | 5 | ✅ |
LLM08 | Vector and Embedding Weaknesses | generated | ✅ |
LLM09 | Misinformation | 5 | ✅ |
45 curated strategies in total, delivered through 16 delivery methods (direct-request, tool-response, role-play, crescendo, many-shot, base64, leetspeak, multilingual, refusal-suppression, and more). Add your own vulnerabilities, strategies and judges — see Custom Evaluators & Frameworks.
Inspect results in Python¶
For the same numbers rendered as a browsable report, see Reading a run in the dashboard below.
The report fields most users need are:
report.summary.resistance_rate: the fraction of evaluated attacks resisted; higher is better.Nonemeans no attack received a verdict.report.summary.evaluated_attacksandtotal_attacks: check both before trusting a rate.evaluation_coverageandcoverage_below_minimumexpose the same check for CI.report.results: the per-attack evidence.result.vulnerable is Nonemeans the attack was not evaluated, not that it was resisted.report.summary.by_vulnerability: the pre-aggregated vulnerability breakdown.
A datapoint that fails before its strategy can create an attack is not counted as an attack result. Its structured RunError is stored in report.errors, and report.summary.pre_execution_errors records how many rows failed at that stage; the dashboard surfaces that count separately from executed attacks.
When a judge fails to return a verdict, the reason is captured on result.evaluation_error (a RunError with a code like timeout, parse, api_connection, api_status, or unknown). It is deliberately separate from result.error: error means the attack itself never ran, evaluation_error means the attack ran and the transcript exists but no judge could score it. Both roll up into report.summary.errors_by_type, where judge failures appear under evaluation/<code> keys (execution failures use the bare code) — so a systematically blocked judge shows up as one named cause (evaluation/api_status: 40 attacks) instead of vanishing into forty individual results.
What a run costs¶
report.summary.token_usage_total covers usage recorded for attack generation, the target, and the judge. It includes calls and priced_calls alongside token counts and the dollar figure. If priced_calls < calls, some calls reported usage without a provider price, so the displayed cost is a lower bound. Use the calculator below to include the fixed setup calls in a planning estimate.
Ballpark the cost¶
Three numbers describe a run: how many setup calls it makes before attacking, how many attacks it runs, and how long each attack is. Everything else is fixed by the pipeline or by the price tier you run at.
Call count is setup calls + attacks × (turns × 2 + 1): each turn is one adversarial generation plus one target call, and the judge runs once after the turn loop, not per turn.
One attack is one strategy against one vulnerability, not one category — a category contributes several. The count for a run is the sum, over each selected category, of the registry strategies that apply to the target plus generated_strategy_count (default 2), then capped by max_per_category and max_dynamic_datapoints. A full dynamic sweep of one target therefore tops out at 65: 45 registry strategies across 10 categories, plus 2 generated each. Capability filtering only removes strategies, so 65 is a ceiling; multiple targets multiply it. The run plan shown before datapoint generation carries the exact count, so you never have to guess for a run you are about to start.
Setup calls are the ones that happen before and after the attack loop. The default of 7 is a dynamic run against one tool-bearing target with the standard report options on:
| Stage | Calls |
|---|---|
| Resource inference | 1 per target |
| Tool classification | 1 per target, only when the target exposes tools |
| Strategy generation | ~1 per selected vulnerability or unresolved category, batched (more than eight objectives split across calls) |
| Executive summary | 1, best-effort |
| Recommendations | 1 per selected top focus area — up to max_areas, default 5 |
Set it to 0 for static and replay runs: a replay selects nothing, so capability classification is skipped entirely. The CLI quickstart above also disables strategy generation, recommendations, and the executive summary, so its baseline is 0 too. Classification is skipped whenever no LLM client or credentials resolve.
The token model behind the dollar figure is a ballpark, not a knob — these values are fixed:
- 1,000 tokens per turn, per side. Each turn call emits one block and the transcript grows by one block, so input cost is quadratic in turns — which is why long multi-turn attacks cost more than the call count suggests.
- 90% cache hit rate on the attack transcript, billed at 0.1× the base input price — the standard cached-read rate for the models listed. A run that never repeats a prefix pays more than this estimate.
- One judge call per attack, reading the finished transcript and emitting ~200 tokens. A jury multiplies that by panel size × repetitions; the judges do not extend the transcript for each other, so five judges cost five reads of the same transcript, not five compounding ones.
- Setup calls are priced at the full input rate, one block each way. They share no prefix with the attack transcript, so no cache discount applies.
- Round price tiers, not named models. Frontier is the flagship tier (Claude Opus, GPT-5.6-terra); mid-tier is the workhorse most runs use; cheap is the small-and-fast tier (Gemini Flash, GPT-5.6-luna). A named-model list goes stale on every provider release, and a planning estimate does not need the third significant figure — pick Custom when you need an exact one. Regional deployments run above these rates: Orq's EU entries carry roughly a 10% uplift. At runtime evaluatorq prices calls from the live Orq
/v2/modelscatalogue, so reported costs use your actual model and region, not this estimate.
Content-filter retries on the attacker turn are real billed calls and are not in this estimate. Actual spend varies with prompt and completion length.
If you want separate attacker and evaluator model settings rather than one default model, see the 11_redteam_config.py cookbook.
What the totals do not include¶
token_usage_total records the calls the run makes on the attack path. Setup and post-processing calls are real spend that lands outside it:
- Capability classification (resource inference, tool classification) and blackbox target classification.
- Strategy and objective generation.
- Structured-output retries and the
json_objectfallback re-request. - Recommendations, trace condensing, and the executive summary.
- Target-side usage, when the backend does not report it back.
If priced_calls < calls in the summary, some counted calls had no provider price either, so the dollar figure is a lower bound on a subset.
In CI¶
For a fast gate, run a small fixed set of attacks and assert a minimum resistance rate, failing the build if the target regresses:
report = await red_team(
target=OpenAIModelTarget(model="gpt-4o-mini", system_prompt="..."),
mode="static", # replay a fixed dataset — deterministic, cheap
categories=["LLM01", "LLM07"],
max_static_datapoints=10,
)
rate = report.summary.resistance_rate
assert rate is not None, "no attack could be evaluated — the target was not tested"
assert rate >= 0.9, f"resistance {rate:.0%} below the 0.9 gate"
The is not None check matters: a run where every judge call failed has no honest rate. eq redteam run exits 1 when attacks ran but none could be scored (report.summary.no_verdict).
Gate on coverage as well as resistance
EvaluatorConfig.min_evaluation_coverage defaults to 0.8. The CLI exits 1 when report.summary.coverage_below_minimum is true. The Python API returns the report, so a Python CI gate should check both summary.no_verdict / summary.coverage_below_minimum and summary.resistance_rate before accepting the run.
The runnable smoke example (08_quick_smoke_test.py) wraps this same pattern; the report inspection cookbook shows how to consume the resulting JSON in Python.
Reading a run in the dashboard¶
Runs are saved to .evaluatorq/runs/<name>_<timestamp>.json by default (--save none skips the file; --save detail also keeps per-stage artifacts in --artifacts-dir), and eq dashboard browses those files locally — no external service:
uv add "evaluatorq[dashboard]"
eq dashboard # browse every saved run
eq dashboard .evaluatorq/runs/red-team_<timestamp>.json # deep-link to one report
Land on the cross-surface overview, pick the run from Red Team, then work down the report tabs from headline to evidence:
| Tab | What it answers |
|---|---|
| Overview | Executive summary, resistance rate and ASR, severity split |
| Agents | Per-target ASR and discovered tools, skills and knowledge |
| Focus areas | Fixes ranked by success rate × avg severity, with remediations |
| Breakdowns | Attack success per framework category, worst first |
| Attacks | Every attack, expandable to the judge verdict and transcript |
| Usage | Tokens and API calls per agent |
| Config | What was tested — and what was not |
Two traps worth knowing before you read a number: the resistance rate covers only the categories this run attacked (check Config for the skipped ones), and it is measured over evaluated attacks, so failed judge calls drop out of the denominator rather than counting as resisted.
The tab-by-tab walkthrough, with screenshots, lives in the Dashboard reference: Reading a red-team run. See also filters, trace links and downloads.
External agent frameworks¶
red_team() accepts any AgentTarget, and each supported framework ships a wrapper that adapts its agent into one — so you red-team an agent built in your framework of choice without rewriting it. Install the matching extra, wrap the agent, and pass it straight to red_team():
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from evaluatorq.integrations.langgraph_integration import LangGraphTarget
from evaluatorq.redteam import red_team
graph = create_react_agent(model=ChatOpenAI(model="gpt-4o-mini"), tools=[...], prompt="...")
report = await red_team(
target=LangGraphTarget(graph=graph),
categories=["LLM01", "ASI01"],
)
| Framework | Wrapper | Extra | Runnable example |
|---|---|---|---|
| LangGraph | LangGraphTarget | evaluatorq[langgraph] | 17_langgraph_target.py |
| OpenAI Agents SDK | OpenAIAgentTarget | evaluatorq[openai-agents] | 18_openai_agents_target.py |
| Pydantic AI | PydanticAITarget | evaluatorq[pydantic-ai] | 19_pydantic_ai_target.py |
| CrewAI | CrewAITarget | evaluatorq[crewai] | 20_crewai_target.py |
Demo runs¶
Live runs of the four examples above (dynamic mode, 3 attacks each, routed through the Orq AI Router) with real attack transcripts and judge verdicts are captured in examples/redteam/_sample_output/RES-931-external-framework-runs.md. The headline: LangGraph, OpenAI Agents, and Pydantic AI each execute an indirect prompt injection (goal hijack via tool output); CrewAI resists all three.
Screen recordings of each run:
LangGraph¶
OpenAI Agents SDK¶
Pydantic AI¶
CrewAI¶
Known limitations¶
Verified edge cases and framework-specific quirks to know before you rely on external-framework targets:
| Area | Behavior | Applies to |
|---|---|---|
| Conversation state | Stateful targets own history internally and thread it across turns; call .new() for each parallel attack job to avoid cross-talk. | LangGraph, Pydantic AI |
| First-turn role | respond() requires the last message to be role="user"; other roles raise ValueError. | LangGraph, Pydantic AI, CrewAI |
| Tool-call visibility | Tool calls are surfaced to the judge, so tool-misuse (ASI) attacks are scored. | LangGraph, OpenAI Agents, Pydantic AI |
| CrewAI is opaque | A crew exposes only its final output — intermediate agent/tool steps are not surfaced, so tool-misuse (ASI) attacks can't be scored; use LLM-tier categories. The whole transcript is flattened into one {conversation} input per turn (no native turn memory), so very long conversations may approach task-description limits. | CrewAI |
| Token usage | Best-effort. Frameworks that don't surface usage metadata report usage=None (never a false non-zero). | all |
| Tool arguments | Non-JSON-object tool arguments are normalized before scoring; exotic argument shapes may be simplified. | LangGraph, OpenAI Agents |
| Routing / keys | The examples point each framework's model at the Orq AI Router with ORQ_API_KEY (model id openai/gpt-4o-mini), so no OpenAI key is needed — the attacker and judge auto-route the same way. The client is constructed eagerly, so ORQ_API_KEY must be set even to build the target. | all |
Where to next¶
- Examples › Red Teaming — static datasets, category filtering, custom clients, multi-target, report inspection, custom hooks.
- API Reference › redteam — the full
Vulnerabilityenum and the OWASPLLM__/ASI__category codes you can pass tocategories=. The CLI Reference lists the same as--category/--vulnerability. - Custom Evaluators & Frameworks — add your own vulnerabilities and attack strategies.