Stop Comparing Guardrail Models on Vibes
You shipped a guardrail. Maybe Llama Guard. Maybe OpenAI's moderation endpoint. Maybe your own fine-tune behind an HTTP API.
Now answer this: how does it actually do against the field, on the same datasets, with the same label conventions, at a threshold you control?
If that question makes you wince, you already know the problem. Stop comparing safety models on vibes. Every guard, moderation, and safety classifier reports its own metrics, on its own cherry-picked subset, with its own labels and thresholds โ so "comparing" them mostly means squinting at marketing tables.
That's why we built Guard Eval Harness (geh) โ a CLI-first harness for benchmarking guardrail, moderation, and safety classification models. One contract, one command, every model.
Let's just run it.
โก 60 seconds to your first eval
pip install geh
geh run --dataset xstest --model mock --limit 50
That's it. pip install, one geh run, and you have a complete, self-contained eval directory on disk. The mock adapter is deterministic, so this works with zero GPUs, zero API keys, and zero config โ a sanity check that the whole pipeline is wired up before you spend a token or a watt.
Want it to mean something? Point it at a real model:
geh run --dataset xstest,toxic_chat,harmful_qa --model hf --model-name meta-llama/Llama-Guard-3-8B
One flag swap โ --model mock becomes --model hf --model-name ... โ and you're running Llama Guard locally over three normalized benchmarks. Same command shape, completely different backend. That's the whole design philosophy: the model is a flag, not a rewrite.
The fragmentation problem is real
Here's what actually happens when you try to compare guard, moderation, and safety-classification models:
- Different datasets. Model A evaluates on its favorite five benchmarks. Model B picks five others. Barely any overlap.
- Different label conventions. One project's "toxic" is another's "borderline." Boolean safe/unsafe, multi-class categories, severity scores โ everyone encodes it differently.
- Different thresholds. A model that looks precise at one cutoff looks trigger-happy at another. If the threshold isn't fixed, the numbers aren't comparable.
- Different harnesses. Even on the same dataset, two teams' loaders, prompt formats, and scoring code diverge just enough to make the numbers drift.
The result is an apples-to-oranges mess. geh fixes it by normalizing everything to one shared contract, then running every model through the same uniform interface.
๐งฑ The secret sauce: one normalized sample contract
The reason a single command can span 80+ benchmarks (the CLI currently lists ~94) is that every dataset adapter emits the same normalized shape. Whether it's a text jailbreak prompt, a toxic comment, an unsafe image, or a vulnerable code snippet, each sample comes out as:
messages[]โ chat-style turns, so guard models see input the way they do in productionlabel.unsafeโ a single boolean ground truthcategory_labels[]โ optional fine-grained categories- deterministic sample IDs and stable
dataset/splitfields - multimodal content typed as text + media parts
That contract is what makes the comparison honest. Your model sees the same inputs, scored the same way, no matter where the data came from. Text and image are the lead modalities, with audio and code-vulnerability benchmarks shipping too. A taste of the coverage:
- Jailbreak / adversarial: XSTest, HarmBench, JBB Behaviors, AdvBench, Do-Anything-Now, StrongREJECT, MaliciousInstruct, WildGuardMix, WildJailbreak, JailbreakBench
- Toxicity: ToxicChat, ToxiGen, Jigsaw Toxicity, Civil Comments, RealToxicityPrompts, OR-Bench, OLID
- Hate & harassment: HateCheck, DynaHate, ETHOS, HatExplain, Implicit Hate, Measuring Hate Speech, Social Bias Frames, ConvAbuse, Hatemoji Check, TweetEval Hate
- General safety: BeaverTails 330k, Do-Not-Answer, PKU-SafeRLHF, Harmful-QA, CatQA
- Image: UnsafeBench, HoliSafeBench, JailbreakV, VLSBench, MSTS, Safe-vs-Unsafe Image Edits
- Code vulnerability: the VulnLLM-R suite (C, Python, Java, repo-level)
- Audio: Nemotron Content Safety
๐ฆ Packs: curated bundles, one flag
Picking individual datasets is great when you know what you want. When you don't, reach for a pack โ a curated bundle, so you compare on the right axis instead of assembling your own subset (which is how fragmentation starts in the first place):
geh run --pack core --model openai_moderation
geh run --pack jailbreak --model hf --model-name meta-llama/Llama-Guard-3-8B
There are 7 packs shipping today:
| Pack | What's in it |
|---|---|
core |
12 datasets โ the general-purpose starting point |
jailbreak |
10 adversarial / jailbreak benchmarks |
toxicity |
7 toxicity datasets |
hate_harassment |
11 hate & harassment benchmarks |
prompt_injection |
6 injection datasets |
code_vuln |
4 code-vulnerability benchmarks |
audio |
1 native-audio safety benchmark |
geh run --pack core --model openai_moderation evaluates OpenAI's Moderation endpoint across a dozen datasets in a single invocation. No glue scripts, no per-dataset loaders, no label-mapping spreadsheets.
๐ Adapters: bring literally any model
Here's the part that makes geh fun: the backend is pluggable. --model accepts:
mockโ deterministic, for testing the pipelinehfโ HuggingFace Transformers, local GPUvllmโ a vLLM serveropenai_compatibleโ any OpenAI-style APIopenai_moderationโ OpenAI's Moderation endpointanthropicโ the Claude APIhttpโ a generic HTTP endpoint for your own service
That covers local weights, hosted APIs, and your bespoke in-house guard behind a REST call โ all behind one uniform adapter interface. On top of those, geh ships specialized vision and audio guard adapters too: a HuggingFace image classifier, a VLM guard, ShieldGemma-2, Gemma-3/4 VLM, SafeQwen VLM, and an audio guard. 13 backends in total.
Not sure what's available? The CLI tells you:
geh list datasets
geh list also knows packs, metrics, and backends. Discovery is built in โ you never have to grep the source to find out what you can run.
๐ Metrics that respect the confusion matrix
A guard model that blocks everything has perfect recall and is completely useless. So geh computes the full picture. Every adapter emits an unsafe_score in [0, 1]; geh thresholds it to an unsafe_label, then computes โ automatically, per dataset:
accuracy, precision, recall, f1, auroc, auprc, fpr, fnr, and the raw tp / tn / fp / fn counts.
Two of those deserve a callout for anyone shipping a guard in production:
fpris your over-blocking rate โ false positives, how often you flag safe content as unsafe and annoy real users.fnris your missed-unsafe rate โ false negatives, how often genuinely harmful content slips through.
Those two numbers are the actual trade-off you live with, and they're the one most home-grown eval scripts forget. AUROC and AUPRC use the raw score directly, so they're threshold-independent โ you can reason about a model's ranking quality separately from where you happened to set the cutoff.
๐๏ธ Self-contained, portable run artifacts
Every run writes a complete, portable directory under out/<run>/. Nothing lives in a hidden database; everything you need to reproduce, audit, or share a result is right there:
out/<run>/
โโโ manifest.json # run metadata
โโโ resolved-config.json # exact config snapshot
โโโ summary.json # aggregated metrics
โโโ report.html # static HTML report
โโโ datasets/<dataset>/
โโโ predictions.jsonl # per-sample predictions
โโโ metrics.json # dataset-level metrics
โโโ dataset-manifest.json # dataset metadata
Because the config is snapshotted into resolved-config.json and sample IDs are deterministic, a run is something you can zip up, attach to a paper, and someone else can actually scrutinize. Per-sample predictions.jsonl lets a reviewer inspect exactly which samples your model got wrong โ not just trust an aggregate. Runs are resumable, and --limit N gives you fast smoke tests while you iterate.
Then the rest of the CLI helps you live with your results:
geh compare --run-a out/run1 --run-b out/run2
geh compare diffs two runs head-to-head. geh inspect --run-dir ... shows the manifest, summary, and artifacts. geh report --run-dir ... rebuilds the static HTML report. And when it's time to drop numbers into a sheet or a paper:
geh export --run-dir out/run1 --format csv --output results.csv
You can drive runs three ways: inline (--dataset X --model Y --model-name Z), from a YAML config (--config file.yaml), or via a pack (--pack core).
๐ Why this belongs in the HF ecosystem
Guardrail models live on the Hub right next to the models they protect. They deserve the same evaluation rigor: a normalized sample contract, a uniform adapter for any backend, the full confusion-matrix metric suite, and portable artifacts that make a result reproducible by construction.
You can have a real guard model benchmarked across a curated pack before your coffee gets cold:
pip install geh
geh run --pack core --model hf --model-name meta-llama/Llama-Guard-3-8B
Extras let you pull in exactly the backends you need:
pip install geh # core CLI
pip install "geh[hf]" # local HuggingFace models
pip install "geh[vllm]" # vLLM server backend
pip install "geh[api]" # OpenAI / Anthropic / HTTP APIs
Guard Eval Harness is MIT-licensed, runs on Python 3.10+, and is maintained by the Virtue AI team. We built it because comparing safety models should be a one-liner, not a research project. Now it is.
- GitHub: https://github.com/Virtue-Research/guard-eval-harness
- PyPI: https://pypi.org/project/geh/
- Docs: https://virtue-research.github.io/guard-eval-harness/
Pick a model. Pick a pack. geh run. Star the repo, run it against your stack, and open an issue if a benchmark you care about is missing โ let's make guardrail evaluation something the whole ecosystem can agree on. ๐ก๏ธ