Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions examples/configs/human_approval_v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Human approval (Colang v2 output rail)

Example configuration for the `human_approval` library rail. Bot output that matches
configured regex patterns requires a human reviewer to reply with an approval keyword
before the original text is released.

## Usage

```colang
import core
import llm
import guardrails
import nemoguardrails.library.human_approval

flow main
activate llm continuation

flow output rails $output_text
await human approval on bot output $output_text
```

## Multi-turn behavior ([#2067](https://github.com/NVIDIA-NeMo/Guardrails/issues/2067))

This library flow **does not use `abort` on rejection** — it sets `$bot_message` to the
rejection message and completes normally so `$output_rails_in_progress` clears in
`guardrails.co`. Approval prompts therefore fire again on later turns in the same session.

Custom output rails that still call `abort` may hit [#2067](https://github.com/NVIDIA-NeMo/Guardrails/issues/2067)
(output rails skipped on subsequent turns until upstream fixes flag cleanup).
20 changes: 20 additions & 0 deletions examples/configs/human_approval_v2/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
colang_version: "2.x"

models:
- type: main
engine: ollama
model: qwen2.5:14b

rails:
config:
human_approval:
patterns:
- "delete|drop|truncate"
- "rm\\s+-rf"
- "sudo"
approval_keywords:
- "approve"
- "yes"
- "approved"
approval_message: "This action matched a restricted pattern and requires approval. Reply 'approve' to proceed."
rejection_message: "Action rejected by reviewer."
10 changes: 10 additions & 0 deletions examples/configs/human_approval_v2/flows.co
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import core
import llm
import guardrails
import nemoguardrails.library.human_approval

flow main
activate llm continuation

flow output rails $output_text
await human approval on bot output $output_text
14 changes: 14 additions & 0 deletions nemoguardrails/library/human_approval/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
66 changes: 66 additions & 0 deletions nemoguardrails/library/human_approval/actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
from typing import List, TypedDict

from nemoguardrails import RailsConfig
from nemoguardrails.actions import action

log = logging.getLogger(__name__)


class HumanApprovalResult(TypedDict):
needs_approval: bool
text: str
matched_patterns: List[str]


def _get_approval_config(config: RailsConfig):
return getattr(config.rails.config, "human_approval", None)


@action()
async def human_approval_check(text: str, config: RailsConfig) -> HumanApprovalResult:
"""Check whether the provided text matches any patterns that require human approval."""
approval_config = _get_approval_config(config)

if approval_config is None or not approval_config.compiled_patterns:
return HumanApprovalResult(needs_approval=False, text=text, matched_patterns=[])

if not text:
return HumanApprovalResult(needs_approval=False, text=text, matched_patterns=[])

matched: List[str] = []
for compiled, raw_pattern in zip(approval_config.compiled_patterns, approval_config.patterns):
if compiled.search(text):
log.info("Human approval pattern matched: %s", raw_pattern)
matched.append(raw_pattern)

if matched:
return HumanApprovalResult(needs_approval=True, text=text, matched_patterns=matched)

return HumanApprovalResult(needs_approval=False, text=text, matched_patterns=[])


@action(is_system_action=True)
async def human_approval_matches_keywords(text: str, config: RailsConfig) -> bool:
"""Return True when the text matches a configured approval keyword (case-insensitive)."""
approval_config = _get_approval_config(config)
if approval_config is None or not text:
return False

normalized = text.strip().lower()
return any(keyword.lower() == normalized for keyword in approval_config.approval_keywords)
27 changes: 27 additions & 0 deletions nemoguardrails/library/human_approval/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Example Colang 2.x config for human-in-the-loop approval on bot output.
#
# Wire the flow in your rails.co (see examples/configs/human_approval_v2/).

colang_version: "2.x"

models: []

rails:
config:
human_approval:
# Regex patterns that trigger human approval when matched in bot output.
patterns:
- "delete|drop|truncate"
- "rm\\s+-rf"
- "sudo"
- "exec|execute"

# Keywords the human can use to approve the action (case-insensitive).
approval_keywords:
- "approve"
- "yes"
- "approved"

approval_message: "This action matched a restricted pattern and requires approval. Reply 'approve' to proceed."

rejection_message: "Action rejected by reviewer."
19 changes: 19 additions & 0 deletions nemoguardrails/library/human_approval/flows.co
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

# OUTPUT RAILS (Colang 2.x)

flow human approval on bot output $output_text
"""Require human approval for bot output matching restricted patterns."""
$result = await HumanApprovalCheckAction(text=$output_text)
if $result.needs_approval
$patterns = $result.matched_patterns
bot say "{$system.config.rails.config.human_approval.approval_message} (matched: {$patterns})"
$response = await user said something
$approved = await HumanApprovalMatchesKeywordsAction(text=$response)
global $bot_message
if $approved
$bot_message = $output_text
else
$bot_message = $system.config.rails.config.human_approval.rejection_message
else
global $bot_message
$bot_message = $output_text
45 changes: 45 additions & 0 deletions nemoguardrails/rails/llm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,51 @@ class RailsConfigData(BaseModel):
description="Configuration for context bloat / context manipulation detection.",
)

human_approval: Optional["HumanApprovalConfig"] = Field(
default_factory=lambda: HumanApprovalConfig(),
description="Configuration for human-in-the-loop approval rail.",
)


class HumanApprovalConfig(BaseModel):
"""Configuration for human-in-the-loop approval rail."""

patterns: List[str] = Field(
default_factory=list,
description="Regex patterns that trigger human approval when matched.",
)
approval_keywords: List[str] = Field(
default=["approve", "yes", "approved"],
description="Keywords the human can use to approve the action.",
)
approval_message: str = Field(
default="This action matched a restricted pattern and requires approval. Reply 'approve' to proceed.",
description="Message shown to the human when approval is required.",
)
rejection_message: str = Field(
default="Action rejected by reviewer.",
description="Message shown when the human rejects the action.",
)

_compiled_patterns: List["re.Pattern[str]"] = PrivateAttr(default_factory=list)

@model_validator(mode="after")
def compile_patterns(self) -> "HumanApprovalConfig":
"""Pre-compile regex patterns at config load time."""
compiled = []
for i, pattern in enumerate(self.patterns):
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as e:
raise ValueError(f"Invalid regex pattern at index {i} ({pattern!r}): {e}") from e
object.__setattr__(self, "_compiled_patterns", compiled)
return self

@property
def compiled_patterns(self) -> List["re.Pattern[str]"]:
"""Return the pre-compiled regex patterns."""
return self._compiled_patterns


class Rails(BaseModel):
"""Configuration of specific rails."""
Expand Down
149 changes: 149 additions & 0 deletions tests/test_human_approval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from unittest.mock import MagicMock

import pytest

from nemoguardrails.library.human_approval.actions import (
human_approval_check,
human_approval_matches_keywords,
)
from nemoguardrails.rails.llm.config import HumanApprovalConfig, RailsConfigData


def _make_config(patterns=None, **overrides):
config = MagicMock()
kwargs = {}
if patterns is not None:
kwargs["patterns"] = patterns
kwargs.update(overrides)
config.rails.config = RailsConfigData(human_approval=HumanApprovalConfig(**kwargs))
return config


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------


class TestConfigDefaults:
def test_default_values(self):
cfg = HumanApprovalConfig()
assert cfg.patterns == []
assert cfg.approval_keywords == ["approve", "yes", "approved"]
assert "approval" in cfg.approval_message
assert "rejected" in cfg.rejection_message.lower()

def test_custom_patterns(self):
cfg = HumanApprovalConfig(patterns=["delete", "drop"])
assert len(cfg.compiled_patterns) == 2

def test_invalid_regex_raises(self):
with pytest.raises(ValueError, match="Invalid regex"):
HumanApprovalConfig(patterns=["[invalid"])

def test_registered_in_rails_config_data(self):
data = RailsConfigData()
assert data.human_approval is not None


# ---------------------------------------------------------------------------
# Pattern matching
# ---------------------------------------------------------------------------


class TestPatternMatching:
@pytest.mark.asyncio
async def test_matches_pattern(self):
config = _make_config(patterns=["delete|drop"])
result = await human_approval_check("DROP TABLE users", config)
assert result["needs_approval"] is True
assert "delete|drop" in result["matched_patterns"]

@pytest.mark.asyncio
async def test_no_match(self):
config = _make_config(patterns=["delete|drop"])
result = await human_approval_check("SELECT * FROM users", config)
assert result["needs_approval"] is False
assert result["matched_patterns"] == []

@pytest.mark.asyncio
async def test_case_insensitive(self):
config = _make_config(patterns=["sudo"])
result = await human_approval_check("SUDO rm -rf /", config)
assert result["needs_approval"] is True

@pytest.mark.asyncio
async def test_multiple_patterns_matched(self):
config = _make_config(patterns=["delete", "sudo"])
result = await human_approval_check("sudo delete everything", config)
assert result["needs_approval"] is True
assert len(result["matched_patterns"]) == 2

@pytest.mark.asyncio
async def test_empty_patterns(self):
config = _make_config(patterns=[])
result = await human_approval_check("DROP TABLE users", config)
assert result["needs_approval"] is False

@pytest.mark.asyncio
async def test_empty_text(self):
config = _make_config(patterns=["delete"])
result = await human_approval_check("", config)
assert result["needs_approval"] is False

@pytest.mark.asyncio
async def test_no_config(self):
config = MagicMock()
config.rails.config = RailsConfigData()
result = await human_approval_check("DROP TABLE users", config)
assert result["needs_approval"] is False

@pytest.mark.asyncio
async def test_text_returned_unchanged(self):
config = _make_config(patterns=["delete"])
text = "delete this record"
result = await human_approval_check(text, config)
assert result["text"] == text

@pytest.mark.asyncio
async def test_regex_pattern(self):
config = _make_config(patterns=[r"rm\s+-rf"])
result = await human_approval_check("rm -rf /home", config)
assert result["needs_approval"] is True

@pytest.mark.asyncio
async def test_regex_no_match(self):
config = _make_config(patterns=[r"rm\s+-rf"])
result = await human_approval_check("remove files", config)
assert result["needs_approval"] is False


class TestApprovalKeywords:
@pytest.mark.asyncio
async def test_matches_approve_keyword(self):
config = _make_config()
assert await human_approval_matches_keywords("approve", config) is True

@pytest.mark.asyncio
async def test_case_insensitive(self):
config = _make_config()
assert await human_approval_matches_keywords("YES", config) is True

@pytest.mark.asyncio
async def test_rejects_unknown_response(self):
config = _make_config()
assert await human_approval_matches_keywords("no", config) is False
Loading