NVIDIA / NVIDIA/NeMo-Agent-Toolkit

Guardrails middleware: input rails silently skipped for chat requests using the `messages` form — only bare-string requests are guarded

Open
#2,229 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug Needs Triage
Dominant language
Python
Stars
2.6k
Forks
762
Avg merge
21h 28m
Merged PRs (30d)
27

Description

Version

1.9.0 (nvidia-nat-security==1.9.0, nemoguardrails==0.21.0)

Which installation method(s) does this occur on?

PyPi

Describe the bug.

The guardrails middleware never runs input rails when the workflow is
invoked with the standard chat request form. NAT chat workflows accept
either:

  • a bare string / ChatRequestOrMessage.input_message (what nat run
    sends), or
  • a ChatRequest/ChatRequestOrMessage carrying the conversation as
    messages (a list of Message models) with input_message=None — what
    the REST chat API (nat serve, POST /v1/chat/completions) and any front
    end built on SessionManager (TUIs, etc.) send.

The middleware's default target extraction
(GuardrailsMiddleware._iter_default_targets,
packages/nvidia_nat_security/src/nat/plugins/security/middleware/guardrails/nemo_guardrails_middleware.py:593)
only guards top-level string fields (or lists of strings) of the
boundary value. For the messages form, messages is a list of Message
Pydantic objects and input_message is None, so no target is found and
the input rail is silently skipped — no error, no log. Off-topic
requests reach the agent unguarded while the same request sent as a bare
string is correctly blocked.

Observed: with a self check input rail that blocks messages mentioning
"trump":

  • "tell me about trump" (string form) → blocked: I'm sorry, I can't respond to that.
  • ChatRequest(messages=[Message(content="tell me about trump", role="user")])
    (messages form) → not blocked, the workflow executed and answered.

Expected: the input rail must fire for both forms — the messages form
is the primary wire format for NAT chat front ends. As shipped, guardrails
appear fully configured but input rails only protect string invocations,
which no production front end uses.

Minimum reproducible example
pip install "nvidia-nat[langchain,guardrails]==1.9.0" langchain-openai
export OPENAI_API_KEY=sk-...


`repro_workflow.py` — a minimal workflow with react_agent-shaped
inputs/outputs:


from collections.abc import AsyncGenerator

from nat.builder.function_info import FunctionInfo
from nat.cli.register_workflow import register_function
from nat.data_models.api_server import ChatRequestOrMessage, ChatResponse, ChatResponseChunk
from nat.data_models.function import FunctionBaseConfig


def _turn_text(msg: ChatRequestOrMessage) -> str:
    text = getattr(msg, "input_message", None)
    if not text:
        text = msg.messages[-1].content
    return str(text)


class EchoConfig(FunctionBaseConfig, name="echo"):
    description: str = "Echo workflow (react_agent-shaped inputs/outputs)"


@register_function(config_type=EchoConfig)
async def echo_workflow(config: EchoConfig, builder):
    async def _single_fn(msg: ChatRequestOrMessage) -> ChatResponse | str:
        return f"ECHO: {_turn_text(msg)}"

    async def _stream_fn(msg: ChatRequestOrMessage) -> AsyncGenerator[ChatResponseChunk]:
        for token in _turn_text(msg).split(" "):
            yield ChatResponseChunk.create_streaming_chunk(token + " ", id_="echo")

    yield FunctionInfo.create(single_fn=_single_fn, stream_fn=_stream_fn, description=config.description)


`repro-config.yml`:


middleware:
  rails:
    _type: guardrails
    guardrails:
      models:
        - type: main
          engine: openai
          model: gpt-4o-mini
          parameters:
            api_key: ${OPENAI_API_KEY}
      colang_version: "1.0"
      rails:
        input:
          flows:
            - self check input
      prompts:
        - task: self_check_input
          content: |
            Your task is to check if the user message below should be blocked.

            Rule: block the message if it mentions the word "trump",
            otherwise allow it.

            User message: "{{ user_input }}"

            Should the user message be blocked? Answer with exactly one
            word, "Yes" or "No", and nothing else.
          output_parser: is_content_safe
          max_tokens: 3

workflow:
  _type: echo
  middleware:
    - rails


`repro.py`:


import asyncio

import repro_workflow  # noqa: F401  (registers the "echo" workflow type)

from nat.builder.workflow_builder import WorkflowBuilder
from nat.data_models.api_server import ChatRequest, Message, UserMessageContentRoleType
from nat.runtime.loader import load_config
from nat.runtime.session import SessionManager

BLOCKED_INPUT = "tell me about trump"
SAFE_INPUT = "tell me a joke"


async def turn(sm, request, conversation_id, stream=False):
    async with sm.session(user_id=None, conversation_id=conversation_id) as session:
        async with session.run(request) as runner:
            if stream:
                chunks = [str(c) async for c in runner.result_stream(to_type=str)]
                return "".join(chunks)
            return await runner.result(to_type=str)


async def main():
    config = load_config("repro-config.yml")
    async with WorkflowBuilder.from_config(config) as builder:
        sm = await SessionManager.create(config=config, shared_builder=builder)

        out = await turn(sm, BLOCKED_INPUT, "repro-string")
        print(f"[1] string form, should be BLOCKED -> {out!r}\n")

        request = ChatRequest(
            messages=[Message(content=BLOCKED_INPUT, role=UserMessageContentRoleType.USER)]
        )
        out = await turn(sm, request, "repro-messages")
        print(f"[2] messages form, should be BLOCKED -> {out!r}\n")

        out = await turn(sm, SAFE_INPUT, "repro-control")
        print(f"[3] control (string form, safe input) -> {out!r}")

        out = await turn(sm, SAFE_INPUT, "repro-stream", stream=True)
        print(f"[4] stream output (should be plain text) ->\n{out[:200]!r}")


asyncio.run(main())


Run:


python repro.py


Expected: `[1]` and `[2]` both blocked. Actual: `[2]` (and the messages form
in general) executes the workflow unguarded.
Relevant log output
Click here to see error details

========================================================================
BUG 1: input rail skipped for the messages request form

[1] string form, should be BLOCKED -> "I'm sorry, I can't respond to that."

[2] messages form, should be BLOCKED -> 'ECHO: tell me about trump'

[3] control (string form, safe input) -> 'ECHO: tell me a joke'

Other/Misc.

Root cause: _iter_default_targets
(packages/nvidia_nat_security/src/nat/plugins/security/middleware/guardrails/nemo_guardrails_middleware.py:593)
iterates model_fields and only yields fields that are str or
list[str]. ChatRequestOrMessage.messages is list[Message]
(BaseModel instances), so nothing is guarded.

Notes for a fix:

  • The default extraction (or pre_invoke) could descend into
    ChatRequestOrMessage.messages / ChatRequest.messages and guard the
    last user message — the current turn's input — since re-guarding the
    whole history is both wasteful and wrong (one historical off-topic
    message would block every later turn).
  • The workflow_functions mapping form
    (GuardrailFunctionFields, e.g. messages: [content]) cannot express
    "last element of a list": dotted paths fan out over all elements of a
    list field, so it re-evaluates the entire conversation history in
    separate rail calls.
  • The same blind spot affects the output side: a ChatResponse result
    (non-streaming path) has no top-level string fields (choices is a list
    of models), so output rails are skipped there too for the messages form.

We currently work around this downstream by subclassing
GuardrailsMiddleware and overriding _iter_default_targets to guard the
last user message of the messages form.

Code of Conduct
  • I agree to follow the NeMo Agent Toolkit Code of Conduct
  • I have searched the open bugs and have found no duplicates for this bug report

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/nvidia_nat_security/src/nat/plugins/security/middleware/guardrails/nemo_guardrails_middleware.py at _iter_default_targets and trace how pre_invoke handles ChatRequestOrMessage values. Reproduce the issue with repro.py, then verify that input rails inspect the last user message in messages requests and that non-streaming ChatResponse results are not silently skipped by output rails.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.