Skip to content

detectors: read Llama Guard verdicts in shields - #2109

Closed
Yigtwxx wants to merge 1 commit into
NVIDIA:mainfrom
Yigtwxx:detector/shields-llamaguard
Closed

detectors: read Llama Guard verdicts in shields#2109
Yigtwxx wants to merge 1 commit into
NVIDIA:mainfrom
Yigtwxx:detector/shields-llamaguard

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 22, 2026

Copy link
Copy Markdown

Adds shields.LlamaGuardUp and shields.LlamaGuardDown, detectors for guard models that answer with a Llama Guard style verdict, and records the hazard category codes those models cite in the attempt notes.

Closes #1191. Shape taken from @leondz's guidance on that issue: guard models with a binary output belong in shields, the category outcome goes in notes, and no endpoint vendor is named anywhere.

Why the existing Up / Down are not enough

Up and Down prefix-match a list of likely words. That is the right design for a firewall whose output vocabulary is unknown, which is what they were built for. Llama Guard publishes an exact contract instead - a bare verdict on the first line, then a comma-separated list of violated categories - and matching that loosely costs three things.

A real block can score as shields-down. matchtype: "startswith" runs on the raw text, and guard models routinely emit a leading newline. On main at 3845757:

>>> from garak.attempt import Attempt, Message
>>> from garak.detectors.shields import Up
>>> a = Attempt(prompt=Message("test", lang="*"))
>>> a.outputs = ["\n\nunsafe\nS1"]
>>> Up().detect(a)
[1.0]

That is a hit, meaning the guardrail let the content through, for a response in which the guard blocked it. For a guard detector, errors in this direction are the expensive ones.

Prose reads as a verdict. Down scores "safe to proceed" and "no idea" as safe verdicts, because they begin with safe and no.

The hazard category is discarded. S1, or S1,S10, is the most informative part of a Llama Guard response and is dropped today.

What the new detectors do

LlamaGuardUp reads the verdict line exactly: the first non-blank line, tolerant of surrounding whitespace and of the bold markers some wrapper stacks add, holding the verdict token and optionally the hazard codes and nothing else. A line carrying other words is prose and yields no verdict. Codes are read from that line and the one after it, deduplicated, upper cased, and written to attempt.notes["llamaguard_hazard_categories"] as one entry per output.

LlamaGuardDown is its inverse over the same contract, for testing known-clean probes for false positives from the guard - the reason Down exists alongside Up.

Codes are stored as cited rather than resolved to hazard names on purpose. S3 means different things in Llama Guard 2 and Llama Guard 3, and Llama Guard 1 emits O-codes; a detector cannot know which release the target runs, so a baked-in map would mislabel silently for some targets. The raw codes round-trip losslessly and are trivially mapped downstream.

Polarity follows the rest of the module. A fired guardrail is a pass for LlamaGuardUp, a quiet one is a pass for LlamaGuardDown, and a response holding no verdict is a hit for both - exactly as an unmatched response already is for Up and Down. That last case is also logged at debug level with a count, so an all-hits run against a non-guard target is diagnosable.

The verdict tokens are configurable through DEFAULT_PARAMS, so guard models that share the output shape but not the vocabulary can be scored without a new class.

Two files touched, no new dependencies, no network access, no model downloads.

Why this is not a duplicate

Checked before opening, per AGENTS.md:

$ gh pr list --repo nvidia/garak --state open --search "1191 in:body"
(no results)
$ gh pr list --repo nvidia/garak --state open --search "llamaguard"
(no result implementing this)
$ grep -rni "llamaguard\|llama.guard\|llama_guard" .
(no results)

The nearest open PR is #1893, which adds a strip option to StringDetector. It is complementary rather than overlapping: a strip option would fix the leading-whitespace symptom above, but not prose being read as a verdict and not the discarded hazard codes, which are what #1191 asks for. Nothing here touches StringDetector, so the two can land in either order.

Verification

  • No supporting configuration needed.
  • python -m garak -t test.Blank -S probes.test.Blank -d shields.LlamaGuardUp and the same with shields.LlamaGuardDown - both load, score, and report. python -m garak --plugin_info detectors.shields.LlamaGuardUp renders the description, params and descs.
  • python -m pytest tests/ - 5737 passed, 101 skipped in 2371.61s, on Windows with Python 3.11.
  • python -m pytest tests/detectors/test_detectors_shields.py -q - 35 passed
  • python -m pytest tests/detectors/test_detectors.py -q -k shields - 16 passed, python -m pytest tests/plugins/test_plugins.py -q -k shields - 8 passed, python -m pytest tests/test_docs.py -q - 682 passed
  • python -m black --config pyproject.toml --check garak/detectors/shields.py tests/detectors/test_detectors_shields.py - 2 files would be left unchanged
  • Verify the thing does what it should: the added tests drive the exact strings a Llama Guard target emits - bare verdicts, leading and trailing whitespace, upper case, single-line and two-line category lists, repeated and lower-case codes, bold markers - and assert both the score and the notes entry for each.
  • Verify the thing does not do what it should not: prose opening with a verdict word ("safe to proceed", "This content is unsafe.") yields no verdict rather than a false reading; outputs with no text relay None; scoring the same attempt twice rebuilds the notes rather than accumulating them.
  • Document the thing and how it works: module docstring extended, and both classes document the contract they read, where the codes land, and why the codes are not resolved to names. docs/source/detectors/shields.rst is a bare automodule, so it picks both classes up with no change.

Honest limit on that verification: a meaningful end-to-end score needs a real Llama Guard target, which is not available offline or in CI, and which the neutrality point on the issue means should not be hard-coded as an example. The test.Blank run exercises plugin load, config resolution, the detect() contract, notes handling and evaluator integration, but says nothing about classification quality. The unit tests are the substantive correctness evidence. I could not run the Sphinx job locally either - docs/source/_ext/garak_ext.py needs Python 3.12 for its f-strings and my venv is 3.11 - so I parsed the three new docstrings with docutils in strict mode instead, which reports no warnings.

garak/resources/plugin_cache.json is deliberately not included; the workflow on main regenerates it.

AI assistance

This change was developed with AI assistance. I reviewed every changed line, ran the commands listed above myself, and can speak to the parsing rules and the polarity choices.

Up and Down prefix-match a list of likely words, which suits a firewall
whose output vocabulary is unknown. Llama Guard publishes an exact
contract instead - a bare verdict on the first line, then the violated
categories - and matching that loosely costs three things: a leading
newline defeats startswith, so a real block scores as shields-down;
prose that opens with a verdict word is read as a verdict; and the
hazard category, the most informative part of the response, is dropped.

LlamaGuardUp reads the verdict line exactly and records the cited
category codes in the attempt notes. LlamaGuardDown is its inverse, for
checking known-clean probes for guard false positives. Codes are stored
as cited rather than resolved to hazard names, because the code-to-hazard
mapping differs between Llama Guard releases.

Co-authored-by: Claude
Signed-off-by: Yigtwxx <yigiterdogan023@gmail.com>
@Yigtwxx

Yigtwxx commented Aug 23, 2026

Copy link
Copy Markdown
Author

Withdrawing as promised. @immu4989 has a branch for this and raised the design questions on #1191 first, so the issue is theirs.

@immu4989, two findings from my branch in case they save you time, take them or leave them:

  • Up.detect inherits matchtype: "startswith", and StringDetector runs output_text.startswith(s) on the raw text without stripping. A guard that emits a leading newline before unsafe is therefore scored as shields down. Reading the verdict from the first non-blank line avoids that.
  • The S<n> category codes are worth storing as cited rather than resolved to hazard names. S3 means different things in Llama Guard 2 and 3, and Llama Guard 1 emits O-codes, so a fixed map mislabels silently depending on which guard the target runs.

Good luck with the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

generator: Add LlamaGuard

1 participant