Skip to content

Guides

Porting the Guardrails Hub validators

[guardrails-ai/guardrails-hub-monorepo](https://github.com/guardrails-ai/guardrails-hub-monorepo) ships 65 validators. This is what happened to each of them. Every decision below i

guardrails-ai/guardrails-hub-monorepo ships 65 validators. This is what happened to each of them. Every decision below is the one the code implements.

Thirty-one validators became nine detectors. Twenty-five are already answered by a detector that exists. Nine are not built, and each says what it would need.

Of those nine, six need a local generative model that does not exist yet, two are the Llama Guard and ShieldGemma retrains, and one is the vendor half of valid_address. Nothing is left that is declined for want of effort alone.

That last group used to say "declined". It changed on 2026-08-11, when the constraints that blocked most of them stopped being prohibitions. A validator that needs a network round trip or a generative model is now a thing to build and tag, not a thing to refuse. See the packages section of CLAUDE.md: what a detector needs is declared in Spec.requires, and registry.deployment_notes tells the caller at policy load.

What the port was for

The brief was "improved for all languages". An English-only port of an English-only validator is not worth the maintenance, because it is what the original already is. So the improvement had to be something a 26-language deployment can measure, and in every case it turned out to be the same thing: these validators match text against strings, and each does it in a way that is correct in English and wrong somewhere in Europe.

src/flowx_border/detectors/multilingual.py fixes that once for all five, and tests/test_multilingual.py reproduces each upstream behaviour as the thing that must not happen again:

  • str.lower() is not str.casefold(). German Straße and STRASSE are one word and lower leaves them as two. Greek is narrower than it first looks: Python's lower does implement the final-sigma rule, so it gets ΛΑΘΟΣ right, but it does not unify ς with σ, so the medial spelling any non-Greek keyboard produces stays a different string. ban_list uses lower.
  • Romanian ș is two characters. U+0219 with a comma below and U+015F with a cedilla are both in daily use for one letter, because a generation of software emitted the Turkish form. NFC does not unify them. Unmerged, a Romanian term list matches roughly half of Romanian text.
  • Turkish İ casefolds to two characters, i plus a combining dot above, so casefolding alone does not make İSTANBUL match istanbul.
  • Zero-width characters are an evasion, not a typo. No upstream validator here drops them, so ac<U+200B>me defeats every one of them.
  • Length is graphemes, not code points, and the Croatian titlecase digraphs are neither upper nor lower. Both are in output_format, and both are wrong in the obvious implementation.

Spans are reported into the caller's original string, not the folded one, because the engine redacts spans without checking them and a span off by one redacts the wrong characters. No port carries over fuzzy matching: edit distance one absorbs typos in English and merges real unrelated words in Romanian, Polish and Finnish.

What each detector costs

Six of the seven are rules rather than models, so they sit at T1 with a 5 ms budget rather than the 75 ms an encoder-backed detector carries, and none of them has weights. Measured p95 at the reference input: banned_terms 0.23, system_prompt_leakage 0.36, markup_injection 0.23, internal_domains 0.23, output_format 0.02 and sql_injection 0.22 ms. tests/test_budgets.py asserts them.

Five of the seven are in CORE and work on a machine that has never downloaded a model and has no network. Two are not, and they are the worked examples of the packaging:

  • sql_injection needs the sqlglot parser, so it declares requires={"dependency"}, ships in the sql extra, and is absent from the registry rather than degraded to a pass when that extra is not installed.
  • url_reachability makes an HTTP request, so it declares requires={"network"} and is T3 rather than T1. Its budget is the odd one in the whole table: it is a deadline the detector enforces on itself rather than a figure somebody measured, because it depends on a network the library does not control. The deadline is total across every URL in a scan, not per request, since per-request timeouts multiply by the number of links and a model can emit fifty.

Either one produces a line from registry.deployment_notes when a policy enables it, and both are disabled in the shipped policies.

Reason codes

reasonmeaning
coveredan existing detector already answers this question
llmneeds a generative model to make the judgement, and no detector here answers the same question. Permitted since 2026-08-11, when the constraints were lifted, and not yet built: there is no published model for it. Filed by what this library can answer rather than by how upstream implements it, so nine validators that call an LLM upstream are listed as covered instead
retrainkept as a capability, dropped as a port: the upstream weights are unusable here and the intent is to train our own
vendoris a wrapper around one commercial service rather than a check. Porting it would mean shipping that vendor relationship, its credential and its terms, inside a library, and the thing being sent is customer data

Ported

Thirty-one validators, nine detectors. Two collapses do most of the work.

ban_list, contains_string, competitor_check, mentions_drugs and sky_validator are one mechanism with a different list baked in, and the list in every case is the deployer's data rather than the library's. valid_json through quotes_price are sixteen packages that each hard-code one shape assertion, and the assertion is the deployer's too.

No wordlist and no shape ships with this library. banned_terms, internal_domains and output_format are disabled in policies/default.yaml, and enabled without configuration they report terms_not_configured, domains_not_configured and format_not_configured at action log rather than reporting a clean scan.

output_format is the one detector here that answers no security question, and it says so in its own docstring. It exists so that sixteen shape validators have one destination instead of sixteen.

hub validatordetectorwhat changed
ban_listbanned_termsthe base case. Its fuzzy spaceless matching is not carried over, see the module docstring for the four bugs that come with it.
competitor_checkbanned_termsthe list half only. Its spaCy named-entity pass does not come along: that is an English NER model, and this project's entity extraction is piiguard.
contains_stringbanned_termsthe same mechanism without word boundaries, which is the whole_words: false option.
mentions_drugsbanned_termsmechanism only. Its English drug list is not shipped, because a drug list in 26 languages that nobody here can review is worse than no list.
sky_validatorbanned_termsthe term half only. It is one customer's brand check, and the sentiment half of it is not a term list.
internal_domainsinternal_domainskept, with host boundaries on both sides and internationalised domain spellings added.
valid_open_api_specjson_schemageneralised. Upstream validates against one schema; this validates against whichever schema the policy carries, and pointing it at the OpenAPI meta-schema is the original.
web_sanitizationmarkup_injectionrewritten from bleach.clean(x) != x, which reports an attack in any text containing a bare <, > or &.
cucumber_expression_matchoutput_formatregex. The cucumber expression grammar is not carried over: it is a test-fixture DSL, and the shape it expresses is a regex here.
ends_withoutput_formatends_with, and starts_with with it.
has_urloutput_formaturl: required, the same option.
lowercaseoutput_formatcase: lower, asked as text == text.lower(). The obvious formulation, no character is uppercase, passes the Croatian titlecase digraphs.
one_lineoutput_formatone_line: true.
quotes_priceoutput_formatregex. A price assertion is a pattern, not a feature.
reading_leveloutput_formatmax_lix. Its Flesch-Kincaid counts syllables by English rules and does not survive the trip, so LIX is used instead: sentence length plus the share of long words, computable identically in all 26. The scale is not comparable between languages, which is why the threshold has no default.
reading_timeoutput_formatmax_reading_seconds, with words_per_minute as an option rather than a constant. Upstream bakes in an English silent-reading rate and applies it to every language.
regex_matchoutput_formatregex, as a full match rather than a search.
similar_to_previous_valuesoutput_formatchoices with choices_similarity. Upstream compares with sentence-transformer embeddings; a ratio over the folded strings answers the same question for the case it is used for, and needs no model.
two_wordsoutput_formatmax_words: 2 with min_words: 2.
uppercaseoutput_formatcase: upper, as above.
valid_choicesoutput_formatchoices, matched on folded text so a Romanian choice accepts either spelling of its diacritic.
valid_htmloutput_formathtml: true, which counts unclosed tags. html.parser never fails on its own, so parsing alone would be a no-op.
valid_jsonoutput_formatjson: true.
valid_lengthoutput_formatmax_length and min_length, counted in graphemes rather than code points so one visible string is one length in every language.
valid_rangeoutput_formatnumeric_range, accepting a comma decimal separator, which is correct in most of the 26.
valid_urloutput_formaturl: required.
redundant_sentencesrepetitionthe two dependencies are gone: stdlib difflib replaces thefuzz and multilingual.sentences replaces nltk, which is what keeps the detector in CORE.
exclude_sql_predicatessql_injectioninverted into an allowlist of statement kinds. A denylist of SQL statement types is a list somebody has to keep complete, and the consequence of missing one is a statement that runs.
valid_sqlsql_injectionthe parse half. Reported as sql_unparseable rather than as its own detector, because whether generated SQL parses and whether it does more than was asked are the same question with one parser behind it.
extracted_summary_sentences_matchsummary_supportthe hub validator asks an LLM whether each summary sentence appears in the source. difflib answers the same question, so the port is a rule detector with no weights. It measures overlap rather than entailment and says so in its own docstring, which matters because the detector that judges support here is groundedness and its model does not yet do it.
detect_system_prompt_leakagesystem_prompt_leakagerewritten from whole-string similarity to containment. The original passes a long answer that quotes the prompt verbatim.
endpoint_is_reachableurl_reachabilitywith a deadline, a refusal to request private addresses, and 3xx counted as reachable. Upstream has no timeout at all, fetches whatever the model emitted from inside your network, and reports a redirect as unreachable.

Not ported

gap marks the ones worth building. Eight of the nine are marked, and the shape of what is left changed on 2026-08-11: everything blocked by a rule has been built, and what remains is blocked by a model that does not exist.

The reason column doubles as the requirement each would declare in Spec.requires if it were built, so this table is also the backlog, sorted by what each item would cost a caller to enable. Three left it the same day by being built: exclude_sql_predicates and valid_sql are sql_injection, and endpoint_is_reachable is url_reachability. Between them they are the two detectors outside CORE, and the shape of what the rest of this list looks like once it is done.

valid_address is the one entry declined on grounds other than effort, and half of it is now built as postal_code. It is a wrapper around Google's Address Validation API. The network call is not the problem; url_reachability makes one. The problem is that it needs a paid credential, that the credential cannot live in a policy because policies are reviewable documents that get hashed, and that the payload is a customer's postal address going to a named third party under that third party's terms. A library whose pii detector exists to stop personal data leaving should not ship a detector that posts it somewhere. If you want the check, the vendor relationship already exists in your code and the call belongs there. The local alternative that would fit here is a per-country postcode and address-shape check, which is a data task across the 26 and a different detector from this one.

hub validatorreasondetailgap
bert_toxiccoveredtoxicity.no
bias_checkcoveredbias.no
detect_jailbreakcoveredinjection.no
detect_piicoveredpii. Presidio is not the engine here.no
detect_prompt_injectioncoveredinjection. Upstream calls OpenAI through the Rebuff library to answer it; the encoder here answers the same question locally.no
gibberish_textcoveredgibberish.no
guardrails_piicoveredpii.no
nsfw_textcoverednsfw.no
politeness_checkcoveredpoliteness. Upstream calls a model through litellm.no
presidio_gliner_piicoveredpii.no
profanity_freecoveredtoxicity for the model-backed answer, banned_terms for a list you supply. Its own backend, alt-profanity-check, is an English model, so porting it would add a 26-language claim it cannot support.no
prompt_injection_detectorcoveredinjection. Upstream scores the prompt with a second model.no
provenance_embeddingscoveredgroundedness.no
provenance_llmcoveredgroundedness. Upstream calls a model through litellm to compare an answer with its sources.no
provenance_nlicoveredgroundedness.no
qa_relevance_llm_evalcoveredtopic_scope. Upstream asks the model whether its own answer was relevant, which is a model grading itself.no
relevancy_evaluatorcoveredtopic_scope. Upstream calls a model.no
responsiveness_checkcoveredpoliteness. Its description is the same as politeness_check's, and so is its implementation.no
restricttotopiccoveredtopic_scope.no
secrets_presentcoveredsecrets, which additionally carries credential keywords in all 26 languages so its entropy rule fires outside English.no
sensitive_topicscoveredtopic_scope.no
similar_to_documentcoveredgroundedness.no
toxic_languagecoveredtoxicity.no
toxic_language_llmcoveredtoxicity. Upstream asks a model for the same seven categories the classifier here scores.no
unusual_promptcoveredinjection. Upstream asks a model whether the prompt is tricky.no
llm_criticllmgrades the output with a second model.yes
logic_checkllmasks a model to find logical fallacies.yes
response_evaluatorllmcalls a model through litellm.yes
saliency_checkllmcalls a model through litellm.yes
wiki_provenancellmcalls a model and fetches Wikipedia while scanning.yes
llamaguard_7bretrainLlama Guard is a generative model under a Meta community licence, which is not Apache-2.0 compatible, and the current 12B cannot meet a CPU budget. The capability is wanted: the plan is our own smaller model rather than a port. An Apache-2.0 alternative, gpt-oss-safeguard, was evaluated on 2026-08-12 and declined for the detector at 20B while being adopted as a corpus labeller. See the note in the document about what that settles.yes
shieldgemma_2bretrainShieldGemma is 2B under the Gemma Terms of Use, which is not Apache-2.0. Same disposition as llamaguard_7b: the capability is wanted, the weights are not usable here. Closed on 2026-08-12 as a duplicate of moderation, which is already catalogued for this capability as a retrain on a small Apache-2.0 base. See docs/proposed-detectors.md.yes
valid_addressvendorthe vendor half is declined and the local half is built. It wraps Google's Address Validation API, which needs a paid credential, cannot carry that credential in a policy because policies are reviewable documents that get hashed, and sends a customer's postal address to a named third party under that party's terms. A library whose pii detector exists to stop personal data leaving should not ship one that posts it somewhere. What the check can answer without a vendor is now postal_code: whether a code is well formed for the countries the product serves, and whether it falls inside a published province or department range. Whether the address exists still needs a postal authority's database and is still not answered here.no

The two moderation models

llamaguard_7b and shieldgemma_2b are the only entries whose value is entirely in model weights this project cannot ship. Llama Guard is 7B under the Llama Community Licence and ShieldGemma is 2B under the Gemma Terms of Use; neither is Apache-2.0 compatible, and porting the code without the weights would be shipping a shell.

The decision on 2026-08-11 was to keep the capability and train our own on a smaller Qwen base. Two things to get right when that happens, and neither is a reason not to:

  • Pin decoding. A generative detector declares requires={"llm"}, and entry 6 in CLAUDE.md still holds: greedy decoding and a fixed seed, or the same input yields two verdicts and the evidence record stops being evidence.
  • Give it a budget it can meet. The encoder detectors here are 278M and cost about 151 ms at the reference input, against a 225 ms budget. A 1.6B generative pass on CPU is far past the 300 ms T3 ceiling, so it needs its own tier, its own budget, or requires={"gpu"}. This said 51 ms until 2026-08-14, which was the withdrawn INT8 export's figure and made the gap look three times more forgiving than it is.

A classification head on that base avoids both and answers the same question. That is a training decision rather than a library one, so it is recorded here rather than resolved here.

What a complete detector set would contain

This port answered "which hub validators are worth having". It did not answer "what is missing from the detector set as a whole", which comes after the migration.

The raw material is assembled: the gap = yes rows above, the UNSUPPORTED table in src/flowx_border/adapters/llm_guard_compat.py, and the three detectors that ship unavailable for want of published weights (injection, regulated_advice, groundedness).

InvisibleText left that list on 2026-08-11 by being built. It was not a hub validator, so it appears in none of the tables above, but it was the clearest gap either inventory named and it is now invisible_text: bidirectional controls, tag characters and zero-width characters, at T0, on both sides. It is the only detector whose case for T0 rests on the language list rather than on cost, since all 26 supported languages are left to right and a bidirectional override therefore has no typographic purpose in any text this library claims to support.

This page is generated from docs/porting-guardrails-validators.md in the library repository. Read it as markdown, or edit it at the source.