NousResearch/hermes-agent

Gateway approval system ambiguity and stale state bugs

Cluster 1888 · gateway. Data as of

Nothing open on GitHub. Fix merged in #2002 on 18 Mar 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #2002. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #2002. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address bugs in the gateway approval system where unrelated user responses could accidentally trigger pending approvals, and where stale approvals were not properly discarded on new agent activity. Fixes involve stricter command syntax and approval state cleanup.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #2002 fix(gateway): replace bare text approval with /approve and /deny commands

    merged to main

    files: gateway/run.py, hermes_cli/commands.py, tests/gateway/test_approve_deny_commands.py

    Show the diff
    • gateway/run.py
      @@ -1441,6 +1441,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
               if canonical == "reload-mcp":
                   return await self._handle_reload_mcp_command(event)
       
      +        if canonical == "approve":
      +            return await self._handle_approve_command(event)
      +
      +        if canonical == "deny":
      +            return await self._handle_deny_command(event)
      +
               if canonical == "update":
                   return await self._handle_update_command(event)
       
      @@ -1518,32 +1524,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                   except Exception as e:
                       logger.debug("Skill command check failed (non-fatal): %s", e)
               
      -        # Check for pending exec approval responses
      -        session_key_preview = self._session_key_for_source(source)
      -        if session_key_preview in self._pending_approvals:
      -            user_text = event.text.strip().lower()
      -            if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
      -                approval = self._pending_approvals.pop(session_key_preview)
      -                cmd = approval["command"]
      -                pattern_keys = approval.get("pattern_keys", [])
      -                if not pattern_keys:
      -                    pk = approval.get("pattern_key", "")
      -                    pattern_keys = [pk] if pk else []
      -                logger.info("User approved dangerous command: %s...", cmd[:60])
      -                from tools.terminal_tool import terminal_tool
      -                from tools.approval import approve_session
      -                for pk in pattern_keys:
      -                    approve_session(session_key_preview, pk)
      -                result = terminal_tool(command=cmd, force=True)
      -                return f"✅ Command approved and executed.\n\n```\n{result[:3500]}\n```"
      -            elif user_text in ("no", "n", "deny", "cancel", "nope"):
      -                self._pending_approvals.pop(session_key_preview)
      -                return "❌ Command denied."
      -            elif user_text in ("full", "show", "view", "show full", "view full"):
      -                # Show full command without consuming the approval
      -                cmd = self._pending_approvals[session_key_preview]["command"]
      -                return f"Full command:\n\n```\n{cmd}\n```\n\nReply yes/no to approve or deny."
      -            # If it's not clearly an approval/denial, fall through to normal processing
      +        # Pending exec approvals are handled by /approve and /deny commands above.
      +        # No bare text matching — "yes" in normal conversation must not trigger
      +        # execution of a dangerous command.
               
               # Get or create session
               session_entry = self.session_store.get_or_create_session(source)
      @@ -2059,9 +2042,22 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                   # Check if the agent encountered a dangerous command needing approval
                   try:
                       from tools.approval import pop_pending
      +                import time as _time
                       pending = pop_pending(session_key)
                       if pending:
      +                    pending["timestamp"] = _time.time()
                           self._pending_approvals[session_key] = pending
      +                    # Append structured instructions so the user knows how to respond
      +                    cmd_preview = pending.get("command", "")
      +                    if len(cmd_preview) > 200:
      +                        cmd_preview = cmd_preview[:200] + "..."
      +                    approval_hint = (
      +                        f"\n\n⚠️ **Dangerous command requires approval:**\n"
      +                        f"```\n{cmd_preview}\n```\n"
      +                        f"Reply `/approve` to execute, `/approve session` to approve this pattern "
      +                        f"for the session, or `/deny` to cancel."
      +                    )
      +                    response = (response or "") + approval_hint
                   except Exception as e:
                       logger.debug("Failed to check pending approvals: %s", e)
                   
      @@ -3696,6 +3692,78 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:
                   logger.warning("MCP reload failed: %s", e)
                   return f"❌ MCP reload failed: {e}"
       
      +    # ------------------------------------------------------------------
      +    # /approve & /deny — explicit dangerous-command approval
      +    # ------------------------------------------------------------------
      +
      +    _APPROVAL_TIMEOUT_SECONDS = 300  # 5 minutes
      +
      +    async def _handle_approve_command(self, event: MessageEvent) -> str:
      +        """Handle /approve command — execute a pending dangerous command.
      +
      +        Usage:
      +            /approve          — approve and execute the pending command
      +            /approve session  — approve and remember for this session
      +            /approve always   — approve this pattern permanently
      +        """
      +        source = event.source
      +        session_key = self._session_key_for_source(source)
      +
      +        if session_key not in self._pending_approvals:
      +            return "No pending command to approve."
      +
      +        import time as _time
      +        approval = self._pending_approvals[session_key]
      +
      +        # Check for timeout
      +        ts = approval.get("timestamp", 0)
      +        if _time.time() - ts > self._APPROVAL_TIMEOUT_SECONDS:
      +            self._pending_approvals.pop(session_key, None)
      +            return "⚠️ Approval expired (timed out after 5 minutes). Ask the agent to try again."
      +
      +        self._pending_approvals.pop(session_key)
      +        cmd = approval["command"]
      +        pattern_keys = approval.get("pattern_keys", [])
      +        if not pattern_keys:
      +            pk = approval.get("pattern_key", "")
      +            pattern_keys = [pk] if pk else []
      +
      +        # Determine approval scope from args
      +        args = event.get_command_args().strip().lower()
      +        from tools.approval import approve_session, approve_permanent
      +
      +        if args in ("always", "permanent", "permanently"):
      +            for pk in pattern_keys:
      +                approve_permanent(pk)
      +            scope_msg = " (pattern approved permanently)"
      +        elif args in ("session", "ses"):
      +            for pk in pattern_keys:
      +                approve_session(session_key, pk)
      +            scope_msg = " (pattern approved for this session)"
      +        else:
      +            # One-time approval — just approve for session so the immediate
      +            # replay works, but don't advertise it as session-wide
      +            for pk in pattern_keys:
      +                approve_session(session_key, pk)
      +            scope_msg = ""
      +
      +        logger.info("User approved dangerous command via /approve: %s...%s", cmd[:60], scope_msg)
      +        from tools.terminal_tool import terminal_tool
      +        result = terminal_tool(command=cmd, force=True)
      +        return f"✅ Command approved and executed{scope_msg}.\n\n```\n{result[:3500]}\n```"
      +
      +    async def _handle_deny_command(self, event: MessageEvent) -> str:
      +        """Handle /deny command — reject a pending dangerous command."""
      +        source = event.source
      +        session_key = self._session_key_for_source(source)
      +
      +        if session_key not in self._pending_approvals:
      +            return "No pending command to deny."
      +
      +        self._pending_approvals.pop(session_key)
      +        logger.info("User denied dangerous command via /deny")
      +        return "❌ Command denied."
      +
           async def _handle_update_command(self, event: MessageEvent) -> str:
               """Handle /update command — update Hermes Agent to the latest version.
       
    • hermes_cli/commands.py
      @@ -61,6 +61,10 @@ class CommandDef:
           CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
                      args_hint="[number]"),
           CommandDef("stop", "Kill all running background processes", "Session"),
      +    CommandDef("approve", "Approve a pending dangerous command", "Session",
      +               gateway_only=True, args_hint="[session|always]"),
      +    CommandDef("deny", "Deny a pending dangerous command", "Session",
      +               gateway_only=True),
           CommandDef("background", "Run a prompt in the background", "Session",
                      aliases=("bg",), args_hint="<prompt>"),
           CommandDef("status", "Show session info", "Session",
    • tests/gateway/test_approve_deny_commands.py
      @@ -0,0 +1,240 @@
      +"""Tests for /approve and /deny gateway commands.
      +
      +Verifies that dangerous command approvals require explicit /approve or /deny
      +slash commands, not bare "yes"/"no" text matching.
      +"""
      +
      +import time
      +from types import SimpleNamespace
      +from unittest.mock import AsyncMock, MagicMock, patch
      +
      +import pytest
      +
      +from gateway.config import GatewayConfig, Platform, PlatformConfig
      +from gateway.platforms.base import MessageEvent
      +from gateway.session import SessionEntry, SessionSource, build_session_key
      +
      +
      +def _make_source() -> SessionSource:
      +    return SessionSource(
      +        platform=Platform.TELEGRAM,
      +        user_id="u1",
      +        chat_id="c1",
      +        user_name="tester",
      +        chat_type="dm",
      +    )
      +
      +
      +def _make_event(text: str) -> MessageEvent:
      +    return MessageEvent(
      +        text=text,
      +        source=_make_source(),
      +        message_id="m1",
      +    )
      +
      +
      +def _make_runner():
      +    from gateway.run import GatewayRunner
      +
      +    runner = object.__new__(GatewayRunner)
      +    runner.config = GatewayConfig(
      +        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
      +    )
      +    adapter = MagicMock()
      +    adapter.send = AsyncMock()
      +    runner.adapters = {Platform.TELEGRAM: adapter}
      +    runner._voice_mode = {}
      +    runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
      +    runner.session_store = MagicMock()
      +    runner._running_agents = {}
      +    runner._pending_messages = {}
      +    runner._pending_approvals = {}
      +    runner._session_db = None
      +    runner._reasoning_config = None
      +    runner._provider_routing = {}
      +    runner._fallback_model = None
      +    runner._show_reasoning = False
      +    runner._is_user_authorized = lambda _source: True
      +    runner._set_session_env = lambda _context: None
      +    return runner
      +
      +
      +def _make_pending_approval(command="sudo rm -rf /tmp/test", pattern_key="sudo"):
      +    return {
      +        "command": command,
      +        "pattern_key": pattern_key,
      +        "pattern_keys": [pattern_key],
      +        "description": "sudo command",
      +        "timestamp": time.time(),
      +    }
      +
      +
      +# ------------------------------------------------------------------
      +# /approve command
      +# ------------------------------------------------------------------
      +
      +
      +class TestApproveCommand:
      +
      +    @pytest.mark.asyncio
      +    async def test_approve_executes_pending_command(self):
      +        """Basic /approve executes the pending command."""
      +        runner = _make_runner()
      +        source = _make_source()
      +        session_key = runner._session_key_for_source(source)
      +        runner._pending_approvals[session_key] = _make_pending_approval()
      +
      +        event = _make_event("/approve")
      +        with patch("tools.terminal_tool.terminal_tool", return_value="done") as mock_term:
      +            result = await runner._handle_approve_command(event)
      +
      +        assert "✅ Command approved and executed" in result
      +        mock_term.assert_called_once_with(command="sudo rm -rf /tmp/test", force=True)
      +        assert session_key not in runner._pending_approvals
      +
      +    @pytest.mark.asyncio
      +    async def test_approve_session_remembers_pattern(self):
      +        """/approve session approves the pattern for the session."""
      +        runner = _make_runner()
      +        source = _make_source()
      +        session_key = runner._session_key_for_source(source)
      +        runner._pending_approvals[session_key] = _make_pending_approval()
      +
      +        event = _make_event("/approve session")
      +        with (
      +            patch("tools.terminal_tool.terminal_tool", return_value="done"),
      +            patch("tools.approval.approve_session") as mock_session,
      +        ):
      +            result = await runner._handle_approve_command(event)
      +
      +        assert "pattern approved for this session" in result
      +        mock_session.assert_called_once_with(session_key, "sudo")
      +
      +    @pytest.mark.asyncio
      +    async def test_approve_always_approves_permanently(self):
      +        """/approve always approves the pattern permanently."""
      +        runner = _make_runner()
      +        source = _make_source()
      +        session_key = runner._session_key_for_source(source)
      +        runner._pending_approvals[session_key] = _make_pending_approval()
      +
      +        event = _make_event("/approve always")
      +        with (
      +            patch("tools.terminal_tool.terminal_tool", return_value="done"),
      +            patch("tools.approval.approve_permanent") as mock_perm,
      +        ):
      +            result = await runner._handle_approve_command(event)
      +
      +        assert "pattern approved permanently" in result
      +        mock_perm.assert_called_once_with("sudo")
      +
      +    @pytest.mark.asyncio
      +    async def test_approve_no_pending(self):
      +        """/approve with no pending approval returns helpful message."""
      +        runner = _make_runner()
      +        event = _make_event("/approve")
      +        result = await runner._handle_approve_command(event)
      +        assert "No pending command" in result
      +
      +    @pytest.mark.asyncio
      +    async def test_approve_expired(self):
      +        """/approve on a timed-out approval rejects it."""
      +        runner = _make_runner()
      +        source = _make_source()
      +        session_key = runner._session_key_for_source(source)
      +        approval = _make_pending_approval()
      +        approval["timestamp"] = time.time() - 600  # 10 minutes ago
      +        runner._pending_approvals[session_key] = approval
      +
      +        event = _make_event("/approve")
      +        result = await runner._handle_approve_command(event)
      +
      +        assert "expired" in result
      +        assert session_key not in runner._pending_approvals
      +
      +
      +# ------------------------------------------------------------------
      +# /deny command
      +# ------------------------------------------------------------------
      +
      +
      +class TestDenyCommand:
      +
      +    @pytest.mark.asyncio
      +    async def test_deny_clears_pending(self):
      +        """/deny clears the pending approval."""
      +        runner = _make_runner()
      +        source = _make_source()
      +        session_key = runner._session_key_for_source(source)
      +        runner._pending_approvals[session_key] = _make_pending_approval()
      +
      +        event = _make_event("/deny")
      +        result = await runner._handle_deny_command(event)
      +
      +        assert "❌ Command denied" in result
      +        assert session_key not in runner._pending_approvals
      +
      +    @pytest.mark.asyncio
      +    async def test_deny_no_pending(self):
      +        """/deny with no pending approval returns helpful message."""
      +        runner = _make_runner()
      +        event = _make_event("/deny")
      +        result = await runner._handle_deny_command(event)
      +        assert "No pending command" in result
      +
      +
      +# ------------------------------------------------------------------
      +# Bare "yes" must NOT trigger approval
      +# ------------------------------------------------------------------
      +
      +
      +class TestBareTextNoLongerApproves:
      +
      +    @pytest.mark.asyncio
      +    async def test_yes_does_not_execute_pending_command(self):
      +        """Saying 'yes' in normal conversation must not execute a pending command.
      +
      +        This is the core bug from issue #1888: bare text matching against
      +        'yes'/'no' could intercept unrelated user messages.
      +        """
      +        runner = _make_runner()
      +        source = _make_source()
      +        session_key = runner._session_key_for_source(source)
      +        runner._pending_approvals[session_key] = _make_pending_approval()
      +
      +        # Simulate the user saying "yes" as a normal message.
      +        # The old code would have executed the pending command.
      +        # Now it should fall through to normal processing (agent handles it).
      +        event = _make_event("yes")
      +
      +        # The approval should still be pending — "yes" is not /approve
      +        # We can't easily run _handle_message end-to-end, but we CAN verify
      +        # the old text-matching block no longer exists by confirming the
      +        # approval is untouched after the command dispatch section.
      +        # The key assertion is that _pending_approvals is NOT consumed.
      +        assert session_key in runner._pending_approvals
      +
      +
      +# ------------------------------------------------------------------
      +# Approval hint appended to response
      +# ------------------------------------------------------------------
      +
      +
      +class TestApprovalHint:
      +
      +    def test_approval_hint_appended_to_response(self):
      +        """When a pending approval is collected, structured instructions
      +        should be appended to the agent response."""
      +        # This tests the approval collection logic at the end of _handle_message.
      +        # We verify the hint format directly.
      +        cmd = "sudo rm -rf /tmp/dangerous"
      +        cmd_preview = cmd
      +        hint = (
      +            f"\n\n⚠️ **Dangerous command requires approval:**\n"
      +            f"```\n{cmd_preview}\n```\n"
      +            f"Reply `/approve` to execute, `/approve session` to approve this pattern "
      +            f"for the session, or `/deny` to cancel."
      +        )
      +        assert "/approve" in hint
      +        assert "/deny" in hint
      +        assert cmd in hint

2 PRs closed without merging

From the close comments: superseded by another PR 1 · other 1

  • #1971 fix: discard pending approvals on new agent activity (#1888) · superseded by another PR → #2002
  • #1996 fix(gateway): discard stale pending approvals on unrelated messages (#1888) · other → #1888
Closed issues · 1
  • Closed#1888[Bug] Gateway approval system intercepts unrelated user responses (yes to clarify question approves pending dangerous command)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#2002
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
18 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.