NVIDIA / NVIDIA/NeMo-Agent-Toolkit
Guardrails middleware: input rails silently skipped for chat requests using the `messages` form — only bare-string requests are guarded
Nobody has claimed this yet.
- 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(whatnat run
sends), or - a
ChatRequest/ChatRequestOrMessagecarrying the conversation as
messages(a list ofMessagemodels) withinput_message=None— what
the REST chat API (nat serve, POST/v1/chat/completions) and any front
end built onSessionManager(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 themessagesrequest 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.messagesand 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_functionsmapping 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
ChatResponseresult
(non-streaming path) has no top-level string fields (choicesis 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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