Files
hermes-agent/tests/gateway/test_decline_fallback_suppression.py
teknium1 799d85bed2 test(gateway): stubs and monkeypatches follow the _delivery_adapter_for seam
Every test double that stood in for `_adapter_for_source` was a delivery stand-in. Part of #88715 (phase 4).
2026-09-19 01:28:23 -07:00

367 lines
12 KiB
Python

"""Caller-level fallback suppression — the branches that actually stop the leak.
Review round 3 deleted BOTH production branches these cover and every existing
test still passed (36 and 38 respectively). The suites exercised
`_approval_send_outcome` and `RelayAdapter` but never drove the real callers,
so nothing observed whether a text send followed a decline — which is the
entire security property.
Every case here pairs a DECLINE with an ordinary-FAILURE control. Without the
control a test cannot distinguish "suppressed the fallback" from "never had a
fallback": a caller that always stays silent would pass the decline case.
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
import pytest
from gateway.platforms.base import SendResult
from gateway.relay.egress import EGRESS_DECLINE_CODE
CODE_ONLY_DECLINE: Dict[str, Any] = {"success": False, "code": EGRESS_DECLINE_CODE}
# ── exec approval ───────────────────────────────────────────────────────────
class _Adapter:
"""Records every plain text send the caller attempts."""
typed_command_prefix = "/"
def __init__(self, approval_result: SendResult) -> None:
self._approval_result = approval_result
self.text_sends: List[str] = []
def pause_typing_for_chat(self, chat_id: str) -> None:
return None
def resume_typing_for_chat(self, chat_id: str) -> None:
return None
async def send_exec_approval(self, *a: Any, **k: Any) -> SendResult:
return self._approval_result
async def send(self, chat_id: str, message: str, **k: Any) -> SendResult:
self.text_sends.append(message)
return SendResult(success=True, message_id="m1")
def _runner(adapter: _Adapter):
"""A TurnRunner driven through its real _approval_notify_sync."""
from gateway.run_turn_runner import TurnRunner
runner = object.__new__(TurnRunner)
ctx = SimpleNamespace(
_status_adapter=adapter,
_status_chat_id="C1",
_status_thread_metadata={},
_session_key="sk1",
session_key="sk1",
source=SimpleNamespace(chat_id="C1", platform="discord", session_key="sk1"),
)
runner._ctx = ctx
class _Fut:
def __init__(self, result): self._r = result
def result(self, timeout=None): return self._r
runner._schedule = lambda coro, _label: _Fut(asyncio.run(coro))
runner._close_native_stream_boundary = lambda _why: None
return runner
APPROVAL = {"command": "rm -rf /", "description": "danger", "pattern_key": "k"}
def test_exec_approval_decline_does_not_send_the_text_fallback():
from gateway.run_turn_runner import _ExecApprovalDeclined
adapter = _Adapter(
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
)
runner = _runner(adapter)
# Raises (not returns) so `_await_gateway_decision`'s notify-failure path
# drops the CENTRAL approval entry and unblocks the waiting tool.
with pytest.raises(_ExecApprovalDeclined):
runner._approval_notify_sync(dict(APPROVAL))
assert adapter.text_sends == []
def test_exec_approval_ORDINARY_failure_still_falls_back_to_text():
"""The control. An ordinary failure MUST still reach the user."""
adapter = _Adapter(SendResult(success=False, error="slack 500"))
runner = _runner(adapter)
runner._approval_notify_sync(dict(APPROVAL))
assert len(adapter.text_sends) == 1
assert "rm -rf /" in adapter.text_sends[0]
# ── slash confirm ───────────────────────────────────────────────────────────
class _ConfirmAdapter:
def __init__(self, result: SendResult) -> None:
self._result = result
async def send_slash_confirm(self, **k: Any) -> SendResult:
return self._result
def _busy(adapter: _ConfirmAdapter):
from gateway.run_busy import GatewayBusySessionMixin
busy = object.__new__(GatewayBusySessionMixin)
busy._delivery_adapter_for = lambda _s: adapter
busy._thread_metadata_for_source = lambda _s, _a: {}
busy._reply_anchor_for_event = lambda _e: None
busy._session_key_for_source = lambda _s: "sk1"
return busy
def _event():
return SimpleNamespace(
source=SimpleNamespace(chat_id="C1", platform="discord", user_id="u1")
)
def _run_confirm(busy) -> Optional[str]:
return asyncio.run(
busy._request_slash_confirm(
event=_event(),
command="/wipe",
title="Confirm",
message="really wipe?",
handler=lambda choice: "done",
)
)
def test_slash_confirm_decline_returns_none_and_clears_state():
from tools import slash_confirm as mod
adapter = _ConfirmAdapter(
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
)
reply = _run_confirm(_busy(adapter))
# None = "no text ack": returning the message would post the prompt as
# plain text into the chat the connector just refused.
assert reply is None
# And the registration must not survive a card that never rendered.
assert mod.get_pending("sk1") in (None, {}, [])
def test_slash_confirm_ORDINARY_failure_returns_the_text_fallback():
"""The control. A broken card lane must still ask the user."""
adapter = _ConfirmAdapter(SendResult(success=False, error="discord 500"))
reply = _run_confirm(_busy(adapter))
assert reply == "really wipe?"
# ── task-card progress ──────────────────────────────────────────────────────
#
# Round 4 finding: the round-3 fix added the production branch AND a test, but
# the test stopped at RelayAdapter — it proved raw_response is carried and
# never called the caller that owns the security property. Deleting the real
# branch left 30 tests green. Same lesson as #7, one lane over: a component in
# the path proves nothing until a case drives THE CALLER.
class _CardAdapter:
def __init__(self, progress_result: SendResult) -> None:
self._progress_result = progress_result
self.fallbacks: List[str] = []
async def send_native_task_card_progress(self, **k: Any) -> SendResult:
return self._progress_result
def _card_runner(adapter: _CardAdapter):
from gateway.run_turn_runner import TurnRunner
runner = object.__new__(TurnRunner)
runner._ctx = SimpleNamespace(
source=SimpleNamespace(chat_id="C1", platform="slack"),
_progress_reply_to=None,
_progress_metadata={},
)
async def _fallback(st):
adapter.fallbacks.append("fallback")
runner._task_card_send_or_edit_fallback = _fallback
return runner
def _card_state(adapter: _CardAdapter):
return SimpleNamespace(
tasks=[{"text": "step"}],
native_failed=False,
publication_suppressed=False,
visible_tasks=lambda: [{"text": "step"}],
fallback_text=lambda: "step",
adapter=adapter,
)
def test_declined_task_card_progress_does_not_send_the_text_fallback():
adapter = _CardAdapter(
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
)
runner = _card_runner(adapter)
st = _card_state(adapter)
asyncio.run(runner._task_card_publish(st))
assert adapter.fallbacks == []
assert st.native_failed is True
def test_ORDINARY_task_card_failure_still_sends_the_text_fallback():
"""Control: a genuinely broken card lane must still reach the user."""
adapter = _CardAdapter(SendResult(success=False, error="slack 500"))
runner = _card_runner(adapter)
st = _card_state(adapter)
asyncio.run(runner._task_card_publish(st))
assert adapter.fallbacks == ["fallback"]
assert st.native_failed is True
def test_task_card_decline_suppression_persists_across_updates():
"""R5-4: my round-4 fix suppressed exactly ONE update.
It set `native_failed`, which the entry gate already uses for an ordinary
broken lane — so the NEXT progress event skipped the decline branch and
went straight to the text fallback. Measured: [] then ['send'].
A refusal does not expire after one tick.
"""
adapter = _CardAdapter(
SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
)
runner = _card_runner(adapter)
st = _card_state(adapter)
asyncio.run(runner._task_card_publish(st))
asyncio.run(runner._task_card_publish(st))
asyncio.run(runner._task_card_publish(st))
assert adapter.fallbacks == []
def test_ORDINARY_failure_still_falls_back_on_every_later_update():
"""Control: a broken card lane must keep reaching the user each update."""
adapter = _CardAdapter(SendResult(success=False, error="slack 500"))
runner = _card_runner(adapter)
st = _card_state(adapter)
asyncio.run(runner._task_card_publish(st))
asyncio.run(runner._task_card_publish(st))
assert len(adapter.fallbacks) == 2
# ── the edit lane (round 6): one dropped raw_response, three leaks ──────────
class _EditAdapter:
"""Records every op; the edit is refused with a code-only decline."""
def __init__(self) -> None:
self.ops: List[str] = []
@staticmethod
def extract_media(text):
return [], text
async def edit_message(self, **k: Any) -> SendResult:
self.ops.append("edit")
return SendResult(
success=False, error="declined", raw_response=CODE_ONLY_DECLINE
)
async def send(self, *a: Any, **k: Any) -> SendResult:
self.ops.append("send")
return SendResult(success=True, message_id="m1")
def test_queued_reconcile_decline_does_not_fall_back_to_a_send():
"""R6-3: `_deliver_queued_first_response` re-sent the WHOLE response.
Its reconcile-by-edit falls through to `adapter.send` on any failure, so a
refused edit delivered the full text to the refused chat. Measured:
[('edit', 'SECRET'), ('send', 'SECRET')].
"""
from gateway.run_notifications import GatewayNotificationsMixin
adapter = _EditAdapter()
mixin = object.__new__(GatewayNotificationsMixin)
source = SimpleNamespace(chat_id="C1", platform="discord")
consumer = SimpleNamespace(message_id="m0", _turn_split_delivery=False)
asyncio.run(
mixin._deliver_queued_first_response(
"SECRET", source, adapter, stream_consumer=consumer, deliver_media=False
)
)
assert adapter.ops == ["edit"]
def test_queued_reconcile_ORDINARY_edit_failure_still_sends():
"""Control: a genuinely un-editable message must still be delivered."""
from gateway.run_notifications import GatewayNotificationsMixin
class _Ordinary(_EditAdapter):
async def edit_message(self, **k: Any) -> SendResult:
self.ops.append("edit")
return SendResult(success=False, error="message too old")
adapter = _Ordinary()
mixin = object.__new__(GatewayNotificationsMixin)
source = SimpleNamespace(chat_id="C1", platform="discord")
consumer = SimpleNamespace(message_id="m0", _turn_split_delivery=False)
asyncio.run(
mixin._deliver_queued_first_response(
"SECRET", source, adapter, stream_consumer=consumer, deliver_media=False
)
)
assert adapter.ops == ["edit", "send"]
def test_task_card_fallback_edit_decline_does_not_send_progress_text():
"""R6-4: R5-4 covered the native card, not the editable-text fallback."""
adapter = _EditAdapter()
runner = object.__new__(__import__("gateway.run_turn_runner", fromlist=["x"]).TurnRunner)
runner._ctx = SimpleNamespace(
source=SimpleNamespace(chat_id="C1", platform="slack"),
_progress_metadata={},
)
sent = []
runner._send_progress_text = lambda st, text: sent.append(text)
st = SimpleNamespace(
fallback_msg_id="m0",
fallback_text=lambda: "task text",
adapter=adapter,
publication_suppressed=False,
)
asyncio.run(runner._task_card_send_or_edit_fallback(st))
assert adapter.ops == ["edit"]
assert sent == []
assert st.publication_suppressed is True