Changelog
All notable changes to this project are recorded here, in the format of Keep a Changelog. Versions follow Semantic Versioning.
cog bump writes the released sections from the commit history. Everything
below Unreleased is written by hand until it is released.
v0.1.0 - 2026-08-15
Features
- (ask) guardrails on every turn, and an honest BYOK panel - (6b32345) - HaoZeke
- (builder) context rails on by default - (cae4844) - HaoZeke
- (config) run retrieval and context flows as context rails - (be2a7f8) - HaoZeke
- (guardrails) memoize rail verdicts in process - (827e6af) - HaoZeke
- (guardrails) rationales from AprielGuard, and a live smoke target - (3c2e54f) - HaoZeke
- (guardrails) Ruby bindings for NeMo Guardrails - (0611bf8) - HaoZeke
- (stream) hand out only unseen text after a mid-stream rewrite - (6354c4f) - HaoZeke
- stream-time rails, conversation history, and encoded-injection decoding - (adeaf3e) - HaoZeke
- take the confusables table from Unicode instead of from memory - (3e86db1) - HaoZeke
- the safe prompt shape in one call - (53c4c33) - HaoZeke
- detect an injection by whether it worked, and report that it does not - (35eb30e) - HaoZeke
- strip what executes, refuse what only costs - (ebbae80) - HaoZeke
- keep the reader's own details out of the request - (43cb8ef) - HaoZeke
- read the parts of a page nobody looks at - (409a104) - HaoZeke
- a fake conversation, and a marker that proves a leak - (2f85b4b) - HaoZeke
- ask a model where the conversation is going - (bf1edef) - HaoZeke
- make the new rails reachable from the environment - (a29cb2c) - HaoZeke
- judge the sequence, not just the message - (3abbee3) - HaoZeke
- read the text an attacker meant rather than the text they wrote - (b33933c) - HaoZeke
- refuse the URLs an answer has no business emitting - (6414f8d) - HaoZeke
- check the answer while it is still arriving - (a850752) - HaoZeke
- rails for the retrieved document, not just the question and the answer - (d264329) - HaoZeke
- a rails engine, not a REST client - (71e6acf) - HaoZeke
Bug Fixes
- (guardrails) route grounding to a judge model, not the classifier - (6d68349) - HaoZeke
- (stream) hand out only text a rail has actually read - (4faaf0b) - HaoZeke
- commit the two config files git was hiding - (2bc86ec) - HaoZeke
- report the rail that ran, not the rail that was never built - (da2b3ef) - HaoZeke
- an empty dialogue is an answer, not a missing one - (9bf432d) - HaoZeke
- a base64 blob ending in + lost its last character - (6aa1207) - HaoZeke
Performance
- (ask) overlap the rails with the work they guard - (db9267a) - HaoZeke
Documentation
- (guardrails) document the grounding judge model and measured rail latency - (41ec631) - HaoZeke
- (readme) emit StreamGuard#take rather than the raw chunk - (dc893c5) - HaoZeke
- (readme) the third side, registered gateways, and the measured score - (77878d7) - HaoZeke
- publish the YARD class reference on GitHub Pages - (d9841b5) - HaoZeke
- map the rails onto the published categories, gaps included - (fc08a0e) - HaoZeke
- write down what the new rails do and what they do not - (4cce4fb) - HaoZeke
- a Diataxis tree, with the tutorial that needs no key - (167d8ba) - HaoZeke
- changelog, code of conduct, ownership, and the release path - (3d482e3) - HaoZeke
Tests
- print the injection-corpus pair from the shipped rail - (babcaa2) - HaoZeke
- the prompt shape does help, at a sample size that can tell - (93f472d) - HaoZeke
- measure the prompt itself, and report that the result is inconclusive - (2172977) - HaoZeke
- measure the trajectory judge against a live endpoint - (23ea2b8) - HaoZeke
- measure the corpus again with the encodings applied - (11f10cb) - HaoZeke
- an injection corpus, scored on both numbers at once - (4f9209d) - HaoZeke
Refactoring
- (providers) no institution's endpoint ships in the gem - (1da7dca) - HaoZeke
- rename to vangrail - (c3ed8b2) - HaoZeke
Miscellaneous
- (changelog) add the cocogitto separator so the first bump can write - (31278b1) - HaoZeke
- (deps) bump the dependencies group with 3 updates - (332a206) - dependabot
- ignore generated YARD output and local agent state - (a568819) - HaoZeke
- coverage, opt-in and guarded - (c862175) - HaoZeke
- CI, lint, and a style settled once - (4486a5b) - HaoZeke
[Unreleased]
Features
- (enforcement) Structured
Plan,Grant, andCallobjects route public tool invocation through a deny-winsReferenceMonitor. RecursiveCelllabels preserve provenance, integrity, confidentiality, and capabilities; grants constrain arguments, sinks, ordering, use counts, confirmations, transactions, conversation identity, replay, and idempotency. - (risk) Versioned
ScoreResultand optional in-process, bounded-command, and endpoint providers keep encoder, embedding, and judge runtimes outside the core gem. Missing or mismatched readers abstain. A checksum-verified joint-risk artifact reports calibrated posterior intervals, context support, threat mixtures, OOD checks, and risk-control identity. - (training) The grouped joint-risk trainer separates train, calibration, threshold, and final-test roles and atomically emits a compact artifact, split manifest, report, and model card. Threshold risk control is fitted only on its declared role.
- (evaluation) Versioned AgentDojo trace import, a checksum-pinned adaptive threat matrix, bounded external runners, and a deterministic statistical verifier retain per-case failures and abstentions while regenerating security, utility, calibration, curve, and selective-risk tables.
- (corpora) External BIPIA and jailbreak_llms payloads are pinned to repository revisions, sizes, and SHA-256 values. Cached files are reused only after verification and replacements are published atomically.
- (performance) Risk-kernel reports include raw latency, allocation, memory, startup, parity, artifact, source-identity, endpoint-traffic, and adversarial scaling measurements, with a deterministic Markdown renderer.
- (watermark)
Vangrail::WatermarkandRails::Watermark: a machine-readable mark on generated answers, as Article 50(2) of the AI Act requires. Eleven invisible bytes per paragraph, two of them fixed public magic so anybody can detect the disclosure and eight of them a truncated HMAC so the issuer can verify attribution. Code blocks are never marked. In the default rail set, last on the output side;GUARDRAILS_WATERMARK_KEYandGUARDRAILS_WATERMARK_ISSUERconfigure signing. - (rail)
Rail#incremental?, true by default, false for a rail that can only read finished text.StreamGuardruns the incremental offline rails per chunk and the rest atfinish. - (stream)
StreamGuardduplicates a rail's rewrite before appending to it. The buffer is appended to in place, and with a memoized rail the same Result goes to the next caller with the same text, so one push afterfinishwrote one turn's tokens into another turn's cached answer. - (cache) A stored result's content is frozen, so a caller that appends to a shared rewrite fails where it wrote rather than hundreds of requests later.
- (obfuscation)
:tagsand:selectorsdecode a payload carried in the tags block or in variation selectors, so an injection smuggled there is reported as a block naming the injection rather than as a rewrite naming a character class. Both read the text as it arrived, since for them the invisible characters are the message; both need a run of four or more carriers, bytes that are valid UTF-8, and eight printable characters after control bytes are dropped. What keeps the disclosure mark out of the report is the encoding test rather than the floor: itsMAGICopens with0xA1, a UTF-8 continuation byte, so a mark cannot decode as text at all. - (stream)
StreamGuardmerges the rewrite chains of both passes, so a redaction applied mid-stream is still named atfinishbeside the mark. - (engine) A blocked-after-rewrite result carries the rewriting rails' categories, and a rail is named as a rewriter only when the text changed rather than when it said it did.
- (result)
Result#rewritten_bynames every rail that changed the text, and a modified result carries their merged categories.railstill reports the last rewriter. Reporting only the last one hid a redaction behind a later disclosure mark, in the record whose purpose is to explain what was shown and why. - (obfuscation)
Obfuscation.scrub, the invisible-character strip without the rails around it, for a fetch boundary that wants a payload gone before it reaches a corpus.
Fixes
- (obfuscation)
INVISIBLEcovers the carriers it did not: the tags block at U+E0000-E007F, the combining grapheme joiner, the invisible math operators, and the directional marks the bidi set left out. Variation selectors are matched by run of two or more, which catches a byte-per-selector payload and leaves an emoji presentation selector alone.
Performance
- (linear) Normalize each input once and hash word pairs and character grams without allocating transient feature strings. The optional native kernel uses the same prepared-input and bucket contract as the Ruby implementation.
v0.3.0 - 2026-08-17
JSON CLI and loopback HTTP, optional Magnus kernel, and the
review-driven protocol fixes. Tagged v0.3.0 is the tree the
draft describes.
Added
vangrailCLI andVangrail::Serverspeak the same JSON envelope asEngine#check_*,#screen, and#assess. Other languages call this process; they do not embed MRI.- Optional
vangrail-nativegem: Magnus kernel forLinearModel#score(hashed bag and the dot product). Stemming and Unicode folding stay in Ruby. The vangrail gem still installs without a compiler.
Fixed
- CLI exit 2 on blocked, 3 on uncertain. HTTP sets
X-Vangrail-StatusandX-Vangrail-Certain, and refuses a body over 4 MiB. Front#assesswill not letcontext.priorreplace the named prior.- Default
instruction_overridematchesIgnore the previous instructions. Hiddenreads<input type="hidden" value="...">.Actions.allowed_by?requirescertain?. Remote/v1/checkskeepscertain.StreamGuarddoes not mark an uncertain prefix checked.Conversation#result_fromblocks on the policy action, not on any fire.Tools#callraises;Conversation#invokeuses#fire.Profilesubtracts deny from allow;intendandadmit?refuse a denied name.- Default input rails no longer first-block on
injection_patterns(-1.6 bits). SetGUARDRAILS_RAILS=patternsorallto install it. - A later block keeps an earlier redaction in
content, and is uncertain if a prior rail was. The engine judgesRail.usabletext, not the raw bytes. - Hidden and Obfuscation keep
certain?on a rewrite. - A missing Semantic rail names the embed gap, not a judge model.
- Stem
ignoring/overridingonto the lexicon verbs. InjectedInstructions readspromptandsystem message. ManyShot counts ChatML, JSON, and Alpaca turns after stripping tokens. Obfuscation strips soft hyphen and backspace. Jailbreak's sympathy wrapper names grandfather and parents.
v0.2.0 - 2026-08-16
The rail protocol is #decide. #call scrubs bytes first and then
delegates. Builder and Engine are separate objects. Judges that cannot
parse an answer go through unchecked. The rest of this section is what
landed between v0.1.0 and this tag.
Fixed
-
LinearModel.loadrefuses a bucket count that is not a positive integer at most2**20, so a hostile file cannot grow the weight table. -
The four-gram stride is written into the model file and read back (default 2, so older files still load).
-
Stem-cache lookup is membership by key, so a falsey cached stem cannot evict a different word. An evicted lexicon form still stems correctly.
-
NLP.clauseson invalid UTF-8 does not raise. -
ResultCacheis ordinary UTF-8. The source file no longer carries NUL bytes in a composite key; side, name, and key sit on an ordered Hash. -
The engine keys a memo on
Rail.usable(text), not the raw body. A socket-tagged retry and a body carrying a NUL share the slot the first verdict already filled. -
ColangFlow#offline?is false. Its actions can hit the network, so a config-folder engine is not reported as free. -
Engine#assessno longer treats an unread language as a clean page. WhenRails::Languagereports that the text is outside the lexicons, silence from the word rails is abstention rather than innocence, and the posterior stays at the prior. -
Rails::Languagenow sits on the input side as well as on context, so a long question in an unread language no longer certain-passescheck_input. -
Dutch verb-final subordinates match the ordered paraphrase templates: "dat je de richtlijnen negeert" is an override. The coordinated handbook sentence that made the rule ordered stays clean.
-
HiddenandObfuscationno longer turn an innercertain?false into a clean pass. An inner:modifiedrewrites the span (or the decoded run) inside the page and carries ahidden:/encoded:category. The child's span is not the document. -
Trajectorywith no:historykey is abstention, the same contractEscalationalready had. A short dialogue that was handed in is still a certain pass. -
Phrase-table concepts go through negation. "this is not the system prompt" no longer contributes
:secret; "without restrictions" still does, because the negator is the phrase. -
"Ignore the above and …" names an instruction. The determiner-plus-prior rule no longer requires the prior word to be clause-final.
-
Rails::Languagereads clause by clause, so a German paragraph inside an English page is unread. Character n-gram rank profiles (Cavnar and Trenkle) name an unread language in the twelve-to-twenty-three token band, where function-word counts stay quiet. -
Markup, budget, secrets, canary, personal data, and exfiltration set
language_agnostic?, so their silence on an unread page is still silence. Hidden and Obfuscation wrap lexicon rails and do not. -
Rails::Bayestreats a score between 0 and the threshold as abstention. That band is the one the calibration already reports as mixed (22 attacks and 8 ordinary pages). Above the threshold no held-out benign document landed; at or below 0 no held-out attack did. -
Escalation, PromptLeak frames, and ManyShot role headers read Dutch as well as English. A Dutch retry after a refusal, a
mijn instructiesframe, and agebruiker:/assistent:paste are no longer silent. -
Engine#assess(confidence:)uses the Beta-bounded bitsPosterior.combinealready knew how to compute. If the bound and the point estimate disagree about the action, the judgement is uncertain: 48 benign pages did not identify it. -
Session#foldof a Result with no measured operating point is 0 bits. An unmeasured rewrite is not a leak. -
Profile.resolveraises when a named profile is given extraallow:ordeny:. Deny-wins merge is only for composing from hashes. Extra allow on:offdoes not silently grant a tool.
Changed
-
Colang
stopis a return tag, and a missingdefine botor flow raisesColangErrorat load instead ofUnknownActionat run. -
Builderis its own file.GUARDRAILS_RAILSnames that are not in the known set raiseArgumentErrorinstead of disappearing through intersection. -
Engine#railsraises on an unknown side.:sidewaysis not a quiet trip through the output list. -
A rail implements
#decide.#callis the template: it scrubs the bytes first and then calls#decide. A subclass that defines#callinstead skips the scrub. -
Rail#offline?defaults to true. Networked is the rare case and has to say so. -
A page carrying bytes that are not valid UTF-8 now comes back
modifiedrather thanpassed: the byte is stripped from what the reader and the model are handed, which is the contract zero-width characters have always had. Anything branching onmodifiedshould know it can now mean a rewrite that is not a redaction. -
Engine#assessreads the table written byscript/measure_evidence_external.rb.script/measure_evidence.rbwritestmp/handbook_evidence.rband does not overwrite the shipped schema.script/baseline_external.rbreports its bag-of-stems classifier underbag_*keys, notbayes_*. -
The context rails catch 0 of 125 published BIPIA injections. Those attacks are off-task instructions carrying no override, disclosure, or concealment, which is a coverage gap the documentation never named rather than a detection failure; the shipped corpus scores the same rails at 60 of 60 because it was written out of the same idea of an attack.
-
Screening drops 2.32% of real documentation. The hand-written benign corpus reported 0 of 48 and could not have found this.
-
injection_patternsis anti-informative on real prompts: it fires on 5.3% of ordinary ones and 2.5% of in-the-wild jailbreaks, so a hit from it is worth −1.6 bits. -
The base rate is measured rather than assumed: zero injections in 18,258 real documents bounds it at one in 9,506 with 95% confidence.
Added
-
Profile(workspace,strict,read_only,off): named postures pinned for the conversation, copied from Grok Build's sandbox and permission model. Deny globs always win over allow and over the plan when composing from hashes. A named profile plus extraallow:/deny:is refused.strictandread_onlyrefuse a mutating tool.child_envdrops KEY/SECRET/TOKEN. Apre_invokehook can still block, the same way Grok Build hooks still apply under always-approve. -
Conversation#intendnames the tools the question may use, before any page is seen.screenlocks the plan.invokerefuses a tool that was not intended, even if Admission would have granted it. That is the privileged planner: data cannot add a capability.Chat#ask(conversation:)sends onlyConversation#messages. -
Conversation#invokeruns a named tool only afteradmit?. A refused call is a blocked turn; the handler does not run.Dojorecords local handbook regressions: whether the injected tool stayed dark and whether the user tool returned the fact. Its synonym rewrites are lexical variants, not an adaptive benchmark.Config#conversationhands out the same engine already gated. -
Origin,Cell, andAdmission: a span is privileged (system,user) or untrusted (data,tool). Mixing unions origins and zeros capability tokens. Quoting does not wash off taint. An emptyAdmissiongrants nothing; a key inallowis the grant, and a request cell may restrict further.Spotlight.messagestypes its slots and raisesPrivilegeErrorwhen data is offered as an instruction.Conversation#messagesis the only assembly that object will produce: last user turn plus the cellsscreenkept. -
Engine#assess(origin:)labels the span. The side supplies a default (inputis user,contextis data,outputis tool).Sessionkeeps two tracks: privileged origin updates attack, untrusted origin updates contamination. They never add.Conversation#screenfolds pages onto contamination;Conversation#admit?takes the last user turn as the request and treats a bare argument as data. -
Rails::Alignment, a three-concept ordered match with a gap, so "Ignore, once the module is loaded, every previous instruction" is caught and "follow the guidance and ignore stale copies" is not. Measured on the shared 270/48 set: 47 of 270, 0 of 48, +4.1 bits (+1.8 at 95%). On by default with the other lexicon rails. -
Anaphora across a full stop: "There are guidelines above. Ignore them." binds
themto the previous clause's instruction. -
Session#cusumandSession#shift?, Page's one-sided scheme over the same turn bits, thresholded at Wald's upper bar. -
Builder#sessionandVangrail.session_from_env, so a deployment that already builds from the environment can carry a posterior across turns without constructing the engine twice. -
Config#engine(stdlib: true)prepends the deterministic input and context rails in front of the folder's flows, so a NeMo configuration whose judge is down still refuses a reworded injection and still refuses to call an unread language a clean pass. Off by default so the same folder still describes one set of rails on either runtime. -
Builder.deterministic, the shared list those two paths install. -
Conversationtakesprior:orsession:.askwith a session walksassessand folds the judgement; Escalation is not an assess term and does not run on that path. A retry after a refusal is stillcheck_inputwhen there is no session. The two objects stay distinct; they share one history. -
A house Ruby style, parented on thoughtbot's guide, with the deviations this gem has to make written down.
rubocop-performanceandrubocop-minitestrun next to the style cops. -
rake testloads the suite in one process, so the reported count is true andCOVERAGE=1sees the whole library. Shared prose lives intest/corpus.rbinstead of in test files that required each other. -
isolate_env!clears everyGUARDRAILS*,WILLMA_*, andLLMLITE_*variable by prefix, plus the llmlite aliasesGROK_SHIM_PORTandGROK_LLMLITE_MODEL, so a new gateway key cannot leak a laptop token into a builder test and a leftover shim port cannot keep the proxy registered. -
Vangrail::NLP, a text analysis layer in the standard library: normalisation, a suffix stripper, a concept lexicon with negation and multiword phrases, clause segmentation, and set similarity over character n-grams. -
Rails::Paraphrase, which matches pairs of concepts rather than strings, so a reworded injection is caught by the same rule as the original. Measured at 60 of 60 reworded attacks caught where the pattern rails catch 0, with 24 of 24 ordinary pages kept. -
English and Dutch lexicons, both read by default and selectable with
languages:, with the three pieces of Dutch grammar that a naive port gets wrong: negation after the verb, a backward reference used as a noun, and verb-final subordinates. Scored on its own Dutch corpus rather than assumed to transfer. -
Rails::SimilarityandKnownAttacks, catching near copies of published attack wordings by n-gram containment, clause by clause because containment saturates over a whole page. -
Rails::Language, which reports a page in a language no lexicon here covers as passed withcertain?false. It never blocks: another language is not an attack, and the gap this closes is a clean pass that meant nothing. -
Rails::PromptLeak, redacting the sentences of an answer that reproduce the system prompt, with two thresholds because restating a rule and handing over a rule are different acts.GUARDRAILS_PROMPT_FILEnames the protected text. -
Vangrail::EmbeddingsandRails::Semantic: meaning-level comparison against the known attack wordings through any OpenAI-compatible embeddings endpoint, including a local proxy, so nothing has to leave the machine. -
Vangrail::CompletionandRails::Perplexity: the published perplexity detector for optimised gibberish, windowed so a short span is not averaged away, reporting uncertain on the many endpoints that will not score a prompt. -
script/embedding_probe.rbandscript/perplexity_probe.rb, which calibrate those two thresholds against the endpoint in use and refuse to recommend a number when the benign and attack distributions overlap. -
A labelled Dutch BSN is redacted by
Rails::PersonalData, checksum and all. The label is what makes it safe: a bare nine-digit run is a job id. -
Vangrail::Evidence,Posterior,Judgement, andEngine#assess: the rails read as evidence rather than as a switch, combined with the deployment's base rate into a probability, with each rail's contribution in bits. Silence counts, abstention contributes nothing, and rails measured to agree speak once. -
script/measure_evidence.rbwrites a handbook-corpus report (tmp/handbook_evidence.rb) from the same 270 attack and 48 benign texts, plus the correlation matrix that decides the grouping. The shippedevidence_data.rbis written byscript/measure_evidence_external.rb. -
assess(escalate: true), which stops when the remaining rails provably cannot change the action, so a networked rail is never reached on an ordinary page. -
Vangrail::Session, carrying the posterior across turns with a decay, so staged probing that no single message reveals shows up in the sequence. -
Policy.from_costs, deriving both thresholds from what a missed attack, a wrong block, and a human review each cost, instead of picking numbers. -
Engine#triage, which ranks a document set by posterior rather than partitioning it on the first objection: the doubtful page goes last in the passage list rather than away from the reader. -
Rails::LinearandVangrail::LinearModel: a logistic-regression classifier over hashed n-grams, fitted byscript/train_linear.rb. Cross-validated on in-the-wild jailbreak prompts it catches 73.7% at the rails' false-alarm rate against their 39.5%, and within two points of a published DeBERTa detector. No weights ship: a model fitted on somebody else's traffic is what this project spent a long time measuring the cost of, and pruning the fitted model small enough to ship costs 26 points of detection. -
A survey of the four detector families with the measurements attached, in
docs/orgmode/explanation/detector-models.org, including the published transformer baseline scored on the same corpus. -
Vangrail::BetaandEvidence#bits(confidence:): the regularised incomplete beta in the standard library, so a rail's evidence is what the corpus can defend rather than what it happened to produce. At 95% two shipped rails fall to zero evidence, which is a fact about 48 benign documents. -
Evidence#capability, the information-theoretic reading of an operating point at a given base rate, which is the only number in the table that changes when the deployment does. -
Session#verdict, Wald's sequential test beside the posterior, with thresholds fixed by the error rates rather than chosen. -
An external evaluation, and the scripts to reproduce it:
local_corpus,measure_false_alarms,measure_union,fetch_external,measure_external,measure_bipia_families,measure_evidence_external,baseline_external, andadjudicate. Every rail is now scored against published attacks and 18,258 real documents rather than against text this repository wrote. -
The shipped
evidence_data.rbis regenerated byscript/measure_evidence_external.rbfrom those measurements, per side, andPosteriordefaults to the Beta bound rather than the point estimate. -
Rails::Bayesandscript/train_bayes.rb: a naive Bayes classifier over word n-grams that reports a log-likelihood ratio rather than a verdict, with the score-to-evidence map fitted on held-out folds and pooled to monotone. Off by default and shipped with its cross-validated number, which is worse than the lexicon rails: 15 of 48 against their three quarters, on 48 training clauses. -
A rail that puts
bitsin its result'srawcontributes that directly to a posterior instead of being flattened to whether it blocked. -
StreamGuard#take, which hands out only the text not yet shown, so a mid-stream redaction does not reprint the prefix already on screen. -
Config#enginerunsrails.retrieval/rails.contextflows as context rails, so a NeMo folder that screens retrieved documents screens them here. -
Gem metadata: homepage, source, changelog, bug tracker, and
allowed_push_hostfor RubyGems. -
YARD class reference, built from the comments on the public objects and published at https://haozeke.github.io/vangrail/.
-
Rails as ordinary Ruby objects, each with one
callmethod, returning aResult. A regex check, a safety classifier, a Colang flow, and a call out to a NeMo Guardrails server all use the same protocol and sit in one ordered list. -
Three rail sides.
:inputreads what the user typed,:outputreads what the model wrote, and:contextreads a retrieved document before it reaches a prompt. The third is the side an attacker can usually reach without touching the application at all. -
Three result statuses:
passed,modified, andblocked. A rail can rewrite text rather than only accept or refuse it, and the engine threads a rewrite through the rails that follow. -
certain?, orthogonal to the status. A rail that is off, disabled, or unreachable returns passed withcertain?false, so an application can tell "checked and clean" from "not checked". -
Engine#screen, which runs a set of retrieved documents through the context rails and reports what survived. A poisoned document is dropped and named rather than failing the whole turn. -
Deterministic offline rails:
Rails::Pattern,Rails::Secrets(which redacts rather than blocks), andRails::InjectedInstructions. -
Model-backed rails:
Rails::GuardModelfor safety classifiers,Rails::SelfCheckfor a written policy, andRails::Groundingfor whether an answer follows from its passages. -
Spotlight, which marks retrieved text as data by delimiting, datamarking, or encoding, with a delimiter tag that is random per request. -
A Colang parser and interpreter for the documented subset, so a NeMo Guardrails configuration folder runs in this process. Anything outside the subset raises at load rather than being skipped.
-
Clientfor an existing NeMo Guardrails server, using/v1/checkswhere it exists and falling back once to a chat completion with generation off. -
A provider abstraction that prefers a local proxy, and picks the rail class from what the endpoint can actually serve.
-
An in-process result memo, bounded at 256 entries, which never stores an uncertain result.
-
An injection corpus of 60 attacks across 12 shapes and 5 positions in real documentation prose, scored together with a benign pass rate.
-
StreamGuard, which runs the deterministic output rails while an answer is still arriving. Model-backed rails still run once at the end, because a round trip per chunk turns a two second answer into a minute. -
Conversation, which holds the turns and threads them into the rail context as:history, and remembers the verdicts. A refusal is the event the next check needs most. -
Rails::Jailbreak, fingerprints for the six wrappers that circulate: an unrestricted persona, a claim that safety was disabled, a demand for two answers, a sentimental wrapper, fiction as licence, and forged authority. -
Vangrail::Confusables, a fold from the Unicode confusables data (UTS #39) with a mixed-script policy: 1645 generated entries replacing 29 hand-written ones, folding words that mix ASCII with imitators and leaving genuine non-Latin prose alone. Generated byscript/generate_confusables.rb; the gem behind it is a development dependency and the runtime stays dependency-free. -
Rails::Obfuscation, which undoes an encoding and runs other rails over the result: zero-width and bidi strips, homoglyph folding, rot13, base64, and compatibility normalisation. Scored on the injection corpus rewritten five ways: 0 of 60 with patterns alone, 60 of 60 with the decoding pass. -
Rails::Hidden, which extracts the spans of a page a reader never sees — comments, meta tags, alt and title and data attributes, script and template bodies, elements styled invisible, markdown link titles — and hands each to the deterministic rails. The largest in-the-wild survey puts roughly seven in ten indirect injections in non-rendered HTML. -
Rails::Escalation, which reads conversation history and catches a refused question asked again, or repeated refusals in a short window. -
Rails::ManyShot, which strips chat template control tokens and blocks a pasted dialogue of more than four turns. The tokens are stripped rather than refused, because a question about a chat template is a real question. -
Rails::KnownAnswer, which detects an injection by whether it worked rather than by what it said. Measured at 0 of 10 against an instruct model on a shared gateway, and shipped saying so: the technique detects total derailment, and these models are selectively persuaded instead. -
Rails::Markup, which strips script tags, frames, event handlers, active schemes, forms, and style blocks from an answer, for the common case where a client renders markdown by passing raw HTML through. -
Rails::Budget, a character limit on questions and on retrieved documents. The cost of answering is paid by whoever runs the endpoint, and a public desk gives anybody a way to spend it. -
Rails::PersonalData, which redacts a reader's own email, phone, IBAN, or card number before the question is sent anywhere. Opt-in, and careful about the trap that matters on a cluster desk:ssh you@login.example.orgis an email address by every syntactic measure. -
Rails::Canary, a marker the application puts in its own prompt. The one check here that cannot produce a false positive, and the only one that can prove a leak rather than guess at one. -
Rails::Trajectory, a judge that reads the transcript and rules on where the conversation is going. It is whatRails::Escalationcannot be, since the published multi-turn methods are built so no single turn triggers a refusal.GUARDRAILS_TRAJECTORY_EVERYtrades round trips for coverage. -
Rails::Exfiltration, an allowlist for the URLs an answer may emit. Images are held to a stricter list than links, because an image is fetched without a click, and a payload in the query is refused even on an allowlisted host. -
script/spotlight_probe.rb, which measures the prompt rather than a rail: 95 of 384 injections obeyed with a plain prompt against 68 of 384 with a fenced one carrying the hierarchy (z = 2.38, p = 0.017). Fencing helps by about a quarter in relative terms and does not prevent obedience; 17.7% still get through, and none of those injections is recognisable to a pattern. -
Spotlight.messages, the whole safe prompt shape in one call: hierarchy, marking rule, fenced passages, question. -
Spotlight::HIERARCHYandSpotlight.preamble, which state what outranks what. Marking text as data says where it came from, not what to do when the data argues with the instructions. -
GUARDRAILS_LINK_HOSTSandGUARDRAILS_IMAGE_HOSTS, pluslinksandmultiturnasGUARDRAILS_RAILSnames.
Fixed
-
StreamGuard#takehanded out the tail of the buffer that no rail had read yet, so with a long check interval a credential reached the screen and was redacted a chunk later. It now releases only the inspected prefix, which costs up to one interval of lag and is the whole point of the class. -
The engine reported the first uncertain reason it met, so a placeholder for a rail that could not be built masked the rail that ran and had its connection refused. A rail that ran now reports over one that never did.
-
The escalation rail returned an uncertain pass whenever it found no history, which made it the first uncertain result in every single-turn engine. A caller that never passes the key still gets that; one that passes an empty array gets a certain pass, because an empty dialogue is an answer.
-
A base64 run ending in
+lost its last character to a word-boundary match, and decoded to a sentence with its tail missing. -
The grounding rail was routed through a safety classifier, which answers with its own label tokens whatever it is asked, so every grounding check returned an unparsable answer. It now uses an instruct-model judge.
-
A grounding placeholder was constructed with
:groundingas a rail side. Sides are input, context, and output; a rail name is not one. -
Provider.resolveread a registry installed from the process environment, so a caller passing an environment hash could not describe a gateway in it. -
A key-file override was tried before the configured path rather than replacing it, and a pass-entry override was ignored, which left no way to run without credentials on a machine that has some.
Changed
- No institution's endpoint ships in the gem. Shared gateways are registered by the application that has one, or described entirely through environment.
Spotlight#encodeusespack('m0')rather than thebase64library, which stopped being a default gem in Ruby 3.4.