Skip to content

feat(library): add the HiddenLayer AI Runtime Security rail - #2575

Open
Nurozen wants to merge 28 commits into
NVIDIA-NeMo:developfrom
hiddenlayerai:feat/hiddenlayer-runtime-security-rail
Open

Nurozen wants to merge 28 commits into
NVIDIA-NeMo:developfrom
hiddenlayerai:feat/hiddenlayer-runtime-security-rail

Conversation

@Nurozen

@Nurozen Nurozen commented Nov 6, 2026 •

Copy link
Copy Markdown

Description

Adds a hiddenlayer rail to the guardrail catalog, covering all four check surfaces against HiddenLayer AI Runtime Security.

Opened first, ahead of #2496 and #2498, per Monika on the 28 Oct call: get the rail through for proof of life.

The rail sends each check in the shape NeMo hands it over: a user message, a bot message, a tool call, or a tool result with the call it answers. HiddenLayer decides by the customer's own policy what to rewrite and returns the whole rewritten message, which the rail swaps in. No per-field redaction config lives in Guardrails, which is the split Tim and I landed on during the call.

It fails open by default. A guardrail that raises on a timeout turns a transient dependency failure into a failed request for the whole application.

Tool surfaces block today, and that is the thing #2498 fixes

On a tool_call or tool_result the rail blocks where it would otherwise redact, because the engine honours a transform only on the conversational surfaces. That is the workaround I described on the call:
the agent stops instead of continuing with safe content, and the user gets nothing to act on. Once #2498 lands these two surfaces redact like the others, and the change is confined to the rail's verdict mapping.

Scanning context rather than one message is why the session identity in #2496 matters here: a prompt injection can take several turns to assemble, so HiddenLayer evaluates a window of history, and without a stable session ID the window has to be reconstructed heuristically.

About the size of this PR

The commit and file counts here cover both this PR and #2496, because a PR to this repo can only base on a branch in this repo and the two are stacked. The rail's own change is confined to nemoguardrails/library/hiddenlayer/, its docs page, its tests and its recorded-suite entry.

To see only that, compare the two branches directly:
hiddenlayerai/Guardrails@feat/request-scoped-conversation-id...feat/hiddenlayer-runtime-security-rail

The recorded suite

.agents/skills/add-library-rail landed after this rail was written. Auditing against it turned up eleven deviations, all fixed in this branch, and the recorded-suite entry it makes mandatory, which is now here: an allowed input, a blocked input, a redacted output, a failed token exchange, and a two-round-trip conversation. They replay with the network blocked, so CI needs no HiddenLayer credential.

The output case asserts a redaction rather than a block. A policy rewriting a reply is what the rail's transform path exists for, and the rewrite that comes back is worth more than a second refusal.

The conversation case runs four checks under one conversation ID. The verdicts it asserts are per turn: the session is a filtered header, so what it buys is on HiddenLayer's side, where the four checks are recorded as one conversation rather than four unrelated ones.

The project is left to the recorder's environment rather than pinned in the config, since a project ID identifies a tenant. Re-recording needs HIDDENLAYER_CLIENT_ID, HIDDENLAYER_CLIENT_SECRET and a HIDDENLAYER_PROJECT_ID whose policy enforces; a detect-only project records an allow for every case.

Related Issue(s)

Verification

make pre-commit and make test pass, rebased on current develop: 12821 passed, 201 skipped.

Six failures remain, in tests/server/test_api.py, tests/server/test_schema_utils.py and tests/integrations/langchain/llm/test_langchain_integration.py. They reproduce on a clean develop checkout in the same environment and are unrelated to this change: they reach the network and fail on a NotFoundError from the OpenAI client.

Beyond CI, the rail was run end to end against a live HiddenLayer deployment. One payload across three surfaces returned REDACT with the text rewritten on input and output, and BLOCK with the body as sent on tool_result, which is the promotion described above. A two-turn conversation under one session ID had the second turn's output redacted on the strength of the first turn's content, exercising the IDs from #2496.

Not run: a recorded suite, for the reason above.

On release timing

This does not need to block 0.25 on 11 Nov. Per the call, a partner rail does not have to ride the release cycle so long as we can build from a GitHub tag or commit rather than a published wheel, and we can. We need it demoable rather than in production.

AI Assistance

  • No AI tools were used.
  • AI tools were used; a human reviewed and can explain every change (tool: Claude Code).

Checklist

  • I've read the CONTRIBUTING guidelines.
  • This PR links to a triaged issue assigned to me.
  • My PR title follows the project commit convention.
  • I've updated the documentation if applicable.
  • I've added tests if applicable.
  • I've noted any verification beyond CI and any checks I couldn't run.
  • I did not update generated changelog files manually.
  • I addressed all CodeRabbit, Greptile, and other review comments, or replied with why no change is needed.
  • @mentions of the person or team responsible for reviewing proposed changes.

@github-actions github-actions Bot added status: needs triage New issues that have not yet been reviewed or categorized. size: XL labels Nov 6, 2026
@Nurozen
Nurozen marked this pull request as ready for review November 6, 2026 19:52
@Nurozen
Nurozen force-pushed the feat/hiddenlayer-runtime-security-rail branch from f497870 to d1c5ed2 Compare November 6, 2026 22:37
AsyncWorkQueue workers ran every item in the context they started in. A
context variable set by the caller of generate_async or check_async never
reached the rails, and one set by the first caller (whose submit starts the
workers lazily) was frozen into every later request. Each item now runs in a
copy of its submitter's context, so request-scoped context variables reach
the work and changes made by one item do not leak into the next.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
Rails that report to an external service have no way to learn which
conversation a request belongs to: IORails actions receive only the request
messages, and the context message role does not reach them.

nemoguardrails.context now holds a conversation_id_var, a conversation_id()
context manager that sets it for the calls made inside the block, and a
get_conversation_id() getter for rails and actions. It works for generate,
stream and check on both engines, is never added to the messages, and is
restored when the block exits. Concurrent requests each see their own ID.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
A chat completion that names a thread now runs its rails inside
conversation_id(thread_id), so rails and actions that report to an external
service can group the checks of one conversation with get_conversation_id().
This covers generate and stream; for streams each chunk is pulled with the ID
set, so rails that run while the response body is sent, such as streaming
output rails, see it too.

An ID the application running the server already set is kept. Requests
without a thread_id see no ID, and the Colang 2.x rejection and thread
history replay are unchanged.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
GuardrailsMiddleware now runs its input and output checks with the graph's
thread ID (runtime.execution_info.thread_id, else
runtime.config["configurable"]["thread_id"]) as the request-scoped
conversation ID, so rails that group checks by conversation see one
conversation per thread. A conversation ID the application already set is
kept, and nothing is set outside a checkpointed run.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
A rail that reports to an external service has to pair the check of what
goes to the model with the check of what comes back, and the engine is the
only thing that knows the two halves belong to the same call.

`nemoguardrails.context` now holds a `roundtrip_id_var`, a `roundtrip_id()`
context manager for callers that know where a model call starts, a
`new_roundtrip_id()` that mints one only when nothing is set, and a
`get_roundtrip_id()` getter for rails and actions. `generate_async`,
`stream_async` and the two `IORails` queue entry points open a round trip,
so a caller that runs a whole call through one of them has nothing to set.

A stream resumes in the context of whoever pulls the chunk, which for an
HTTP response body is not the caller that created it. Both IDs are
therefore captured where the stream is created and re-applied around each
pull rather than held across the yields of the generator, so a library
caller that stores an iterator and drains it elsewhere still runs its rails
with the conversation and round trip the request was made in.

An agent framework is the case the engine cannot see: `GuardrailsMiddleware`
runs the two legs of one model call as separate `check_async` calls in
separate graph nodes, so it carries the ID in the agent state and publishes
it for the duration of each check. The ID is recorded against the model call
it was opened for: the first hook to reach a call opens the round trip and
the rest join it, and an ID left behind by an earlier call is ignored. That
keeps both legs on one ID when `InputRailsMiddleware` is composed with
`OutputRailsMiddleware`, and stops a checkpointed thread reporting a turn's
response leg under the previous turn's ID. The after-model hook prefers an
ID the application set.

The new tests run real agents built with `create_agent`, which is what
exercises the state schema that carries the ID between graph nodes.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The conversation ID fell back to runtime.config["configurable"]["thread_id"]
when the runtime carried no execution info, but a LangGraph Runtime has no
config attribute, and the releases that langchain 1.0 pins have no execution
info either, so on those releases the input and output checks of a
checkpointed run reported no conversation ID at all.

The fallback now reads the thread ID of the run in progress from
langgraph.config.get_config(), which every release populates for the node
being executed, and still yields nothing outside a run.

The test that pinned the old fallback described a runtime LangGraph does not
produce; both shapes are now exercised from inside a real graph invoked on a
thread.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
thread_id was length-checked only.  It is the conversation ID of every
rail that runs for the request, and a rail that reports to an external
service puts it on the wire, where a control character or surrounding
whitespace makes the message unsendable.  A thread ID ending in a
space therefore failed every check made for that conversation, or,
with a rail configured to fail open, silently disabled it.

The server now rejects a thread ID that is not printable ASCII or that
is surrounded by whitespace, so the caller learns at the boundary.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
LangGraph binds the run config to a node's context by creating the node's
task with asyncio's context argument, which arrived in Python 3.11. Below
that an async node runs without it, get_config() raises, and the fallback
that reads the thread ID from the run config yields nothing, so checks given
a runtime that carries no thread ID of its own report no conversation.

A runtime that does carry one still names the conversation on every
supported Python, and so does the run config on the sync path, which is now
pinned too. The async expectation follows what each version can deliver.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
…tors

Behavior is unchanged on every production path.

- stream_with_request_ids takes a str round-trip ID and always applies it:
  both engines pass a minted ID, so the None guard only served a test fake,
  which now passes ensure_roundtrip_id() like the engines do.
- _thread_id_from_run_config drops the isinstance guards kept from the
  getattr(runtime, "config") draft; get_config() returns a RunnableConfig
  or raises RuntimeError, which is already caught.
- _roundtrip_id_for_call returns the recorded ID directly: the before-model
  hook writes the ID and its call index together, and the ID is never empty.
- LLMRails._generate_async takes keyword-only arguments without defaults,
  since its only caller passes all five.
- The middleware API reference no longer calls OutputRailsMiddleware's
  abefore_model a no-op; it now opens the round trip for the output check.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
…nerator

stream_async(generator=...) returned the output-rail iterator directly, before
the point where the non-generator path mints a round-trip ID and captures the
conversation ID. Those rails run lazily, as the consumer pulls chunks, so they
ran under whatever context drained the response body: no round trip at all, and
either no conversation or, when the consumer had set one of its own, somebody
else's. A rail that reports to an external service filed its checks under the
wrong conversation and no one saw an error.

The branch now captures the IDs of the creating request and applies them around
each pull, the same shape the other streaming paths use, because an async
generator resumes in its consumer's context and cannot hold a token across a
yield. A round-trip ID the caller already opened is kept rather than replaced.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
@Nurozen
Nurozen force-pushed the feat/hiddenlayer-runtime-security-rail branch from d1c5ed2 to 9b3b508 Compare November 6, 2026 22:44
The thread ID validator rejected every character outside printable ASCII, so a
deployment naming threads in its own language started getting a 422 on requests
that had worked. The field had accepted any string of 16 to 255 characters.

A rail percent-encodes the ID before it goes on the wire, so a non-ASCII ID
needs no protecting. A control character or surrounding whitespace is almost
always a client defect, and is still rejected where the caller learns why.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The hook minted the round-trip ID whether or not it had an input check to pair
it with, so a middleware configured for output only started writing two keys
into the graph state on every model call and driving an event loop where it had
previously returned at once. OutputRailsMiddleware is the case that shows it.

The ID is still what pairs the two legs when both run. With no before-model leg
to pair with, the output check opens the round trip itself rather than
reporting under no ID at all.

Also imports langgraph lazily. It is not a declared dependency of this package,
and reading a LangGraph run config is the only thing that needs it.

Restores the assertions in the existing middleware tests that this had changed.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The HiddenLayer AI Runtime Security rail evaluates a guarded model call in
legs that share one round-trip ID: the input check and tool-result checks go
to the request-evaluation route, the output check and tool-call checks to the
response-evaluation route. Both carry an OpenAI Chat Completions body holding
the current turn only, and both answer 200 with the payload to forward and the
verdict in the HL-Runtime-Action and HL-Runtime-Protection-Mode headers.

Add the three modules that own that wire contract:

- _payloads builds each body, picks the route for a surface, derives the
  round-trip UUID, and assembles the headers. A conversation ID that cannot be
  sent as a header is dropped rather than truncated, because truncating can map
  two conversations onto one session.
- _token fetches and caches the OAuth2 client-credentials access token, sharing
  one in-flight fetch per credentials.
- _verdict maps the response headers onto a rail outcome, applies a redaction
  from the returned body, and ignores the body on a block.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The three actions resolve the rail's settings, post each leg to its route and
return the outcome the verdict headers describe.

Settings resolve from the config field, then the environment variable, then the
default. The client ID and secret are read from the environment only. An api_url
that is not https, outside a loopback host, is rejected at load.

Failures fall into three classes. A config defect raises whatever fail_open
says. A transport failure, which includes a 200 that carries no action to apply,
is what fail_open covers. Everything else is a verdict and is applied: an
evaluation HiddenLayer could not complete arrives as a 200 whose action its
deployment's fail setting chose, and a redaction the rail cannot carry out
blocks.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The manifest declares four surfaces. Input and output run on both engines and
can rewrite the message; the tool-call and tool-result surfaces are IORails
only, as every per-tool surface is.

Register the rail in the catalog, the telemetry feature list, the engine
support matrix and the config schema snapshot, and document the two routes, the
session ID as the only source of conversation history, and the protection modes
that tell a policy's own action apart from one the integration or Monitor Mode
changed.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
A tool result content list made only of non-text parts, such as an MCP
resource or an image, joined to the empty string, so the HiddenLayer tool
result check sent an empty tool message and evaluated nothing. Send such a
list as its JSON encoding instead, as untyped parts already were.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The session-header guard accepted any ASCII ID without a NUL, so a CR, LF
or edge whitespace reached the HL-Runtime-Session-Id header and httpx
refused to serialize the request: every HiddenLayer check on that
conversation failed in transport, allowing its traffic under fail_open and
blocking it otherwise. Drop an ID outside the header field-value set
through the same one-shot warning as an over-long one.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
_roundtrip_id() falls back to the request ID, which lives in a
process-global context variable that another test may have left set:
tests/guardrails/test_telemetry.py makes reset_request_id raise on
purpose, so traced_request leaves one behind. The case that asserts a
fresh uuid4 per leg then saw a request ID and derived the same uuid5
twice. Pin the variable for the module instead of reading whatever the
process is carrying.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The rail's comments, docstrings and docs called each check of a model
call a "leg", a term HiddenLayer's API does not use. Say what the API
says: a model call is one round-trip under `HL-Roundtrip-Id`, holding
its request evaluations (the input check and tool-result checks) and
its response evaluations (tool-call checks and the output check).

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The rail built OpenAI Chat Completions requests and chat.completion
responses around every check, so a change in how HiddenLayer models an
interaction landed in the integration rather than in the service. Each
body now carries the check as the library holds it, named by a `check`
field: a user message, a bot message, a tool call, or a tool result with
the call it answers. The call-ID synthesizer, the tool-name resolver,
the arguments encoder and the content-part flattener are gone, along
with the `unknown` model placeholder; a 200 comes back in the same shape,
with `user_message` or `bot_message` replaced on REDACT.

Validation that belongs to the service moves there. The session ID is
percent-encoded and sent as the conversation names it, with no `nemo:`
namespace and no byte or character checks, because HiddenLayer scopes it
by tenant, integration and project. The round-trip ID is sent as the
framework holds it rather than mapped onto a UUIDv5.

The token path keeps a token until it expires and refreshes once on a
401. The single-flight fetch registry, its per-event-loop keys and the
compare-and-invalidate it needed are removed: they bought one saved
fetch on a cold start, and nothing in the library offers auth plumbing
to replace them with.

The declared tool is now sent with a tool-call check as well as a
tool-result check, so a policy reads what the model was offered on both.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
Under fail_open a rejected check is a turn nothing evaluated, so the rail
now avoids sending one where it can.

An empty user or bot message is allowed without a call. The contract gives
user_message and bot_message a minimum length of one, so an empty check is
a 422, and an empty turn holds nothing to evaluate in any case. The
generate path had no such guard, where check() already skips its input
rails with no user content.

The two ID bounds degrade gracefully again, as they did before the
round-trip and session IDs were sent as the framework holds them. A
round-trip ID over 256 bytes is mapped onto a UUID derived from it, so a
model call's checks still pair under one ID; a conversation ID over 2048
bytes is dropped with one warning, so the checks are evaluated without
history rather than under a truncated ID two conversations could share.
Losing an over-long ID costs the history, not the evaluation.

The failure-handling docs said fail_open covered transport failures only,
and two lines earlier described it allowing a check the server rejected.
It covers any call that ends without a verdict, whatever ended it.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
…erate path

A user turn whose content is a list of content parts goes out as the
framework holds it rather than flattened by the rail: HiddenLayer reads
the parts and owns the translation.

The IORails suite drives the real path against the recording stub, so the
case asserts the body that left the rail, not a unit boundary.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
… can

The rail sends the round-trip ID as the framework holds it, and mapped
only an over-long one onto a derived UUID. Two other values end the same
way, in a check that never reaches HiddenLayer and a turn nothing
evaluated.

The nil UUID is a 422: every consumer downstream reads it as "no round
trip", so the contract rejects it rather than record one. A caller
passing it got a rejected check on every surface.

A non-ASCII ID never leaves the client at all. httpx encodes a header
value as ASCII, so it raises before the request goes out, which is a
transport failure and so fails open. The pre-reshape UUID mapping
covered this incidentally; sending the ID as held reopened it.

All three are now one question, whether the ID can be sent as held, and
one answer, the UUIDv5 already derived for the over-long case. The
mapping is deterministic, so a model call's checks still pair, and each
reason warns once on its own.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
A 401 dropped the cached token unconditionally. Two callers holding the same
rejected token would both invalidate: the first refreshes the cache, the
second then discards that fresh token and fetches again, and every concurrent
401 costs another round trip. Drop the entry only when it still holds the
token that was rejected.

Also point the catalog link at the rendered Fern route. The destination used
the source directory name, but docs/index.yml places the page under the
Third-Party APIs section, whose slug is third-party, so the published page is
at /configure-guardrails/guardrail-catalog/third-party/hiddenlayer.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
…y-rail

The rail predates the add-library-rail and review-library-rail skills. This
closes the deviations an audit against them turned up.

HTTP composition now goes through ensure_retrying_http_client and http_call
rather than wrapping an injected client by hand, so an injected client is never
taken ownership of and only what the factory builds is closed. The hand-rolled
one-shot connection retry is gone: resilience belongs in the RetryPolicy.

That costs a behaviour the policy cannot express. retry_transport_errors covers
timeouts and connection errors together, and a rail runs inline, so retrying
either would spend another full request timeout before the caller hears
anything. Transport errors are declared off and a connection error now resolves
to the fail setting on the first failure.

Errors are rooted at HiddenLayerRailError in errs.py, and a failed check raises
HiddenLayerCheckError rather than a bare RuntimeError. _payloads.py is now
request.py, the name the skill's layout table uses.

fail_open's description said it covered transport failures only, while the code
fails open on any non-success status, including a 401 or 403 from a revoked
key. An operator reading the config deserves to know that.

Adds the recorded-suite entry the skill makes mandatory for a library rail,
with its two config dirs and the credentials fixture. The cassettes are not
recorded here: verdicts come from project policy, so recording needs a tenant
with fixed policies rather than a key alone.

HL- joins the filtered header prefixes. HL-Project-Id and HL-Runtime-Session-Id
identify a tenant and a conversation, and neither belongs in a committed
cassette. VCR matches on method, scheme, host, port, path, query and body, so
filtering headers does not affect replay.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
… header

An `hl-` prefix also matched the response, so a recorded cassette lost
`HL-Runtime-Action`: the verdict the cassette exists to replay. Naming the
request headers keeps the tenant, project and conversation identifiers out of a
committed cassette while the verdict stays in it.

Found by recording against a live deployment, where every block case replayed
as an unusable response.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
The rail imported _header from nemoguardrails.http.retry, reaching across a
package boundary for a private symbol. The lookup is four lines and the rail
already carries its own copy for the token response.

The fail-closed path also logged nothing before raising, so an operator running
fail-closed had no record of what failed. Every other vendor rail logs at error
level first.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
@Nurozen
Nurozen force-pushed the feat/hiddenlayer-runtime-security-rail branch from 9b3b508 to fbfe35a Compare November 7, 2026 04:47
Records the cassettes the suite was added without: an allowed input, a blocked
input, a redacted output, a failed token exchange, and a two-round-trip
conversation. They replay with the network blocked.

The output case checks a redaction rather than a block. A policy rewriting a
reply is what the rail's transform path exists for, and asserting the rewrite
that comes back is worth more than a second refusal.

The conversation case runs four checks under one conversation ID. The verdicts
it asserts are per turn: the session itself is a filtered header, so what it
buys is on the service's side, where the four checks are recorded as one
conversation rather than four unrelated ones.

The project is left to the recorder's environment rather than pinned in the
config, since a project ID identifies a tenant. Re-recording needs one whose
policy enforces; a detect-only project records an allow for every case.

Signed-off-by: Justin Brooks <justin.lance.brooks@gmail.com>
@Nurozen

Nurozen commented Nov 7, 2026 •

Copy link
Copy Markdown
Author

@tgasser-nv, per our conversation in HiddenLayer meeting

This branch has not been deployed

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

Labels

size: XL status: needs triage New issues that have not yet been reviewed or categorized.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: add a HiddenLayer AI Runtime Security rail to the guardrail catalog

1 participant