Red Team Arena: Code Review
Technical code review of the Multi-Agent Red Team Arena codebase covering architecture, test coverage, design patterns, and improvement areas.
Red Team Arena: Code Review
Architecture Overview
The codebase lives in Multi_Agent_proj_code/ and follows a clean three-layer separation:
agents/ ← Agent implementations (attacker, defender, judge)
arena/ ← Infrastructure (controller, elo, logger, artifacts)
experiments/ ← Experiment orchestration (transferability, category, adaptive)
config.py ← Centralized configuration via environment variables
data/ ← Seed prompts (seed_prompts.json)
runs/ ← Per-run artifact directories
tests/ ← Pytest test suite
dashboard/ ← Plotly chart builders (Streamlit-ready)
Each layer depends only on the layers below it. Agents know nothing about the arena infrastructure. Experiments compose arena components. This is a sound dependency graph.
See also: red-team-arena-methodology, red-team-arena-results, red-team-arena-agents.
Component Analysis
1. Agent Layer (agents/)
attacker.py (248 lines) -- Five attacker implementations behind a common ABC:
| Class | Lines | Complexity | API Cost |
|---|---|---|---|
BaseAttacker | 15 | Low (ABC) | None |
TemplateAttacker | 55 | Low (string formatting) | None |
OllamaAttacker | 55 | Medium (HTTP + retry) | Local Ollama |
OpenAIAttacker | 55 | Medium (SDK + retry) | OpenAI API |
AdaptiveAttacker | 40 | Low (wrapper + history) | Inherits from base |
defender.py (183 lines) -- Three defender implementations:
| Class | Lines | Complexity | API Cost |
|---|---|---|---|
KeywordDefender | 45 | Low (regex matching) | None |
OpenAIModerationDefender | 40 | Medium (SDK) | Free moderation endpoint |
PromptGuardDefender | 50 | High (transformers + torch) | Local inference |
judge.py (202 lines) -- Single judge with LLM and heuristic modes:
| Mode | Complexity | Cost |
|---|---|---|
heuristic | Trivial (deterministic) | Zero |
llm | Medium (API + retry + parse) | Per-round API |
heuristic_fallback | Trivial | Zero |
Total agent code: ~683 lines across 3 files.
Strengths
-
Registry pattern with factory functions: Both attackers and defenders use a registry that maps string IDs to factory lambdas. This means
get_attacker("template_dan")always returns a fresh instance, preventing state leakage across tournaments. The comment in the code explicitly documents this design choice. -
AdaptiveAttacker as a decorator: The adaptive wrapper is a clean decorator pattern. It wraps any
BaseAttackerand adds conversation history tracking. Thereset()method allows the controller to clear state between seeds. -
Lazy client initialization: Both
OllamaAttackerandOpenAIAttackerdefer client creation to first use (_get_client()). This means the system can be imported and template attacks can run without API keys present. -
Clean dataclass contracts:
AttackResult,DefenderVerdict, andJudgeVerdictare well-defined dataclasses with explicit fields. No magic dictionaries, no unpacking surprises. -
15 jailbreak templates: The template library covers the major categories documented in the literature (DAN, roleplay, hypothetical, academic, developer mode, grandma exploit, opposite day, base64 prefix, continuation, translation, token smuggling, few-shot, nested roleplay, academic paper, direct).
Weaknesses
- DEFENDER_REGISTRY stores instances, not factories: Unlike the attacker registry (which uses lambdas), the defender registry stores pre-instantiated singletons:
DEFENDER_REGISTRY: dict[str, BaseDefender] = {
"keyword_baseline": KeywordDefender(),
"openai_moderation": OpenAIModerationDefender(),
"promptguard": PromptGuardDefender(),
}
This means get_defender() always returns the same object. If a defender were stateful (e.g., PromptGuard with a loaded model), this would be correct for performance. But it violates the pattern established by the attacker registry and could leak state between experiments. The KeywordDefender is stateless so this works, but PromptGuardDefender loads a model on first call -- sharing that instance is fine for inference but could cause issues if someone adds per-instance configuration.
-
Missing
__init__.pyfiles: Theagents/,arena/, andexperiments/directories have no__init__.py. Theconftest.pyadds the project root tosys.pathto make imports work. This works but is fragile -- it depends on pytest's path manipulation rather than proper package structure. -
Regex patterns in keyword defender are hardcoded: The
_KEYWORD_RULESdictionary is a module-level constant with 8 categories and ~60 regex patterns. Adding or modifying categories requires editing the source code. A config-driven approach (YAML, JSON, or environment variable) would be more flexible. -
No rate limiting on API calls: The
OllamaAttackerandOpenAIAttackermake synchronous HTTP calls without rate limiting. In high-volume tournaments (hundreds of rounds), this could hit API rate limits. TheOpenAIModerationDefenderhas the same issue. -
Temperature hardcoded to 0.9: Both
OllamaAttackerandOpenAIAttackerdefault to temperature 0.9. This is reasonable for creative attack generation but is not configurable without subclassing.
2. Arena Layer (arena/)
controller.py (280 lines) -- The orchestrator:
class ArenaController:
run_round(attacker, defender, seed, history, update_elo) -> RoundResult
run_tournament(rounds, category_filter, snapshot_every) -> list[RoundResult]
elo.py (130 lines) -- Elo rating system:
class EloSystem:
register_attacker/register_defender(id, name) -> EloRecord
update(attacker_id, defender_id, attacker_won) -> (float, float)
attacker_leaderboard/defender_leaderboard() -> list[dict]
to_dict/from_dict() # serialization
logger.py (180 lines) -- SQLite persistence:
class ArenaLogger:
log_round(dict) -> None
log_elo_snapshot(run_id, entity_id, ...) -> None
start_experiment/finish_experiment() -> None
get_attack_success_matrix() -> dict
get_category_breakdown() -> list[dict]
artifacts.py (120 lines) -- Run artifact management:
class RunArtifacts:
log_round(dict) -> None # stream to rounds.jsonl
finalize(logger, elo, run_ids) # produce summary.json, leaderboard.md, charts
Strengths
-
Separation of concerns: The controller handles orchestration, the Elo system handles ratings, the logger handles persistence, and the artifacts handle output generation. Each has a single responsibility.
-
SQLite for round data: Using SQLite as the backing store for round logs is a good choice. It handles concurrent writes, supports complex queries (the attack success matrix and category breakdown are SQL aggregations), and the database file is a single portable artifact.
-
Artifact streaming: The
RunArtifactsclass streams each round to arounds.jsonlfile in real time, so partial results are available even if the run is interrupted. The incomplete run9d160f59demonstrates this -- it hasrounds.jsonldata despite not producing asummary.json. -
update_elo=Falseflag: The controller'srun_roundmethod accepts anupdate_eloparameter that lets multi-turn experiments score an episode of several turns as a single Elo game. This is a clean solution to the problem of inflating defender ratings with intermediate failures. -
Seed exclusion with reset: The tournament loop tracks
used_seedsper pair and resets when exhausted. This prevents the same seed from appearing twice in a pair while ensuring all seeds are used eventually. -
Rich progress display: The controller uses the
richlibrary for progress bars, tables, and colored output. The output is clean and informative.
Weaknesses
-
RoundResult.to_log_dict uses time.time(): The timestamp in
to_log_dictistime.time()(Unix epoch float). This is fine for ordering but loses timezone information and is not human-readable. The artifact system uses ISO 8601 timestamps inmeta.json, creating an inconsistency. -
No transaction safety in logger: The
ArenaLogger.log_round()method appears to do individual INSERT statements without explicit transaction management. Under high concurrency (not currently an issue, but worth noting), this could cause locking issues. -
EloSystem stores history as list of floats: The
EloRecord.historyfield islist[float]storing the rating before each update. This is appended to with[current_rating]when reading trajectories. Theto_dict/from_dictserialization round-trips this correctly, but the history list grows without bound during long tournaments. -
Config uses module-level constants:
config.pyloads.envat import time and creates module-level constants. This is simple but means configuration cannot be changed at runtime without reimporting. For a research tool this is fine; for a production system it would be limiting. -
_snapshot_eloaccesses private attributes: The controller's_snapshot_elomethod accessesself.elo._attackersandself.elo._defendersdirectly, breaking encapsulation. Afor_each_record()iterator orall_records()method onEloSystemwould be cleaner.
3. Experiment Layer (experiments/)
transferability.py (145 lines), category_breakdown.py (120 lines), adaptive_vs_static.py (200 lines)
Strengths
-
Consistent experiment interface: All three experiments follow the same pattern:
__init__with optional parameters and defaults,run()as the entry point,_analyze()for post-processing,_print_results()for display. This makes adding new experiments straightforward. -
Paired comparison in adaptive experiment: The adaptive vs static experiment correctly samples ONE set of seeds and uses them for both arms. This controls for seed difficulty, making the comparison fair. The two-proportion z-test is the correct statistical test for this design.
-
Wilson intervals in transferability: The transferability experiment computes 95% Wilson confidence intervals per attacker-defender pair. This is more appropriate than normal approximation for the small sample sizes typical in these experiments.
-
Blind spot identification: The category breakdown experiment automatically identifies defender blind spots by sorting categories by attack success rate. This produces actionable output: "Defender X is weakest against category Y."
Weaknesses
-
No experiment isolation: All three experiments share the same
ArenaLoggerinstance by default, meaning their round data is interleaved in the same SQLite database. Theexperiment_tagfield distinguishes them in queries, but this relies on correct tagging. -
Category breakdown creates separate EloSystem per category: This is intentional (to avoid cross-category Elo contamination) but means ratings are not comparable across categories. A shared Elo system with category tagging would provide cross-category insights.
-
Hardcoded default participants: Each experiment has hardcoded default attacker/defender lists. For example,
TransferabilityExperimentdefaults to 5 template attackers and 2 defenders. This is reasonable for a research tool but limits reusability.
4. Dashboard (dashboard/charts.py)
charts.py (165 lines) -- Six Plotly chart builders:
| Function | Chart Type | Purpose |
|---|---|---|
elo_trajectory_chart | Line | Elo ratings over time |
attack_success_heatmap | Heatmap | Attacker x Defender success matrix |
category_bar_chart | Grouped bar | Per-category metrics |
elo_leaderboard_bar | Horizontal bar | Elo rankings |
severity_distribution | Donut pie | Harm severity distribution |
attacker_win_rate_over_time | Line (rolling) | 10-round rolling win rate |
Strengths
- All charts use
plotly_darktemplate (matches project's Anthropic dark mode preference) - Functions are stateless and composable
- Graceful handling of empty data (returns empty figure with title)
Weaknesses
attacker_win_rate_over_timeimports numpy inside the function body (should be at module level)- No chart validation or error handling for malformed data
Test Coverage
The test suite in tests/ has 8 files covering all major components:
| Test File | Lines | Coverage |
|---|---|---|
test_elo.py | 57 | Elo math, symmetry, zero-sum, serialization |
test_attacker.py | 41 | Template formatting, registry, adaptive reset |
test_defender.py | 41 | Keyword matching, regression tests, min_hits |
test_judge.py | 113 | Outcome derivation, JSON parsing, heuristic, LLM fallback |
test_logger.py | 96 | SQLite round-trip, migration, experiment lifecycle |
test_artifacts.py | 78 | Streaming, finalize, graceful degradation |
test_experiments.py | 35 | z-test, Wilson interval edge cases |
conftest.py | 6 | sys.path setup |
Total test code: ~467 lines.
Test Quality
-
Regression tests in test_defender.py: Tests specifically verify that "stalkerware" (not "stalkerwear") is matched, and that single keyword hits flag (regression from a ratio-threshold version). This shows the tests were written in response to real bugs.
-
Parametrized test in test_judge.py: The
_derive_outcomestest uses@pytest.mark.parametrizefor all 4 combinations of harmful/benign x flagged/unflagged. This is thorough. -
Edge case testing in test_experiments.py: Tests verify that empty samples produce safe results (z=0, p=1), equal proportions are not significant, and large differences are significant. Wilson interval tests verify bounds are valid and tighter with more data.
-
Mock-based judge tests: The judge tests verify that LLM failures gracefully fall back to heuristic mode, and that the model's own
attack_successfield is ignored (onlypolicy_violationcounts).
Coverage Gaps
-
No integration tests: No test runs a full tournament round through the controller. All tests are unit-level.
-
No test for OllamaAttacker or OpenAIAttacker: The API-based attackers have no tests (likely because they require running services).
-
No test for PromptGuardDefender: The HuggingFace-based defender is untested (requires torch installation).
-
No test for dashboard charts: The chart functions are untested.
Configuration Management
config.py uses python-dotenv to load .env files with sensible defaults:
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
JUDGE_MODEL = os.getenv("JUDGE_MODEL", "claude-haiku-4-5-20251001")
DEFAULT_ROUNDS = int(os.getenv("DEFAULT_ROUNDS", "50"))
ELO_K_FACTOR = float(os.getenv("ELO_K_FACTOR", "32"))
This is clean and follows the 12-factor app pattern. The defaults allow the system to run without any environment configuration (heuristic judge mode, template attackers only).
Dependency Analysis
requirements.txt lists 9 required packages and 3 optional:
Required: openai, anthropic, requests, python-dotenv, streamlit, plotly, pandas, numpy, rich, pytest
Optional: transformers, torch (for PromptGuard), datasets (for HuggingFace downloads)
The dependency footprint is reasonable. The core system (template attacks + keyword defender + heuristic judge) needs only requests, rich, and python-dotenv. The heavier dependencies (openai, anthropic, plotly, pandas) are needed for the full feature set.
Summary Assessment
| Dimension | Rating | Notes |
|---|---|---|
| Architecture | Strong | Clean separation of agents/arena/experiments |
| Test coverage | Good | All core logic tested; gaps in integration and API-based components |
| Code quality | Good | Consistent style, clear dataclasses, explicit error handling |
| Documentation | Moderate | Module docstrings are excellent; inline comments are sparse |
| Configuration | Good | 12-factor pattern with sensible defaults |
| Extensibility | Good | New attackers/defenders are easy to add via registry |
| Error handling | Good | Lazy initialization with clear error messages; judge has retry + fallback |
| Performance | Adequate | Synchronous execution; could benefit from async for API-based components |
Recommended Improvements
- Add
__init__.pyfiles to make the project a proper Python package - Convert defender registry to factories for consistency with attacker registry
- Add integration tests that run a small tournament through the controller
- Add async support for API-based attackers and defenders
- Make keyword rules configurable via YAML or JSON
- Add rate limiting for API calls in high-volume experiments
- Test the dashboard chart functions with mock data