mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(whatsapp_cloud): gate interactive taps on DM allowlist
This commit is contained in:
parent
ac705b52c9
commit
f96b2e6ef7
2 changed files with 97 additions and 0 deletions
|
|
@ -387,6 +387,20 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
return True
|
||||
return super()._open_dm_opted_in()
|
||||
|
||||
def _is_interactive_sender_authorized(self, sender_id: str) -> bool:
|
||||
"""Authorize inbound button/list taps before running resolvers.
|
||||
|
||||
Interactive replies bypass the normal ``_build_message_event_from_cloud``
|
||||
path (which calls ``_should_process_message``), so approval /
|
||||
slash-confirm / clarify taps must re-check DM policy here. Uses the
|
||||
strict ``_is_dm_allowed`` gate (not intake/pairing) so a stale prompt
|
||||
cannot be answered after the sender is removed from the allowlist.
|
||||
"""
|
||||
principal = str(sender_id or "").strip()
|
||||
if not principal:
|
||||
return False
|
||||
return self._is_dm_allowed(principal)
|
||||
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
if not check_whatsapp_cloud_requirements():
|
||||
|
|
@ -1635,6 +1649,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
if not button_id:
|
||||
return False
|
||||
|
||||
sender_id = str(raw_message.get("from") or "").strip()
|
||||
if not self._is_interactive_sender_authorized(sender_id):
|
||||
logger.warning(
|
||||
"[whatsapp_cloud] Rejected unauthorized interactive tap "
|
||||
"from %s (button_id=%r)",
|
||||
sender_id or "<unknown>",
|
||||
button_id,
|
||||
)
|
||||
# Claim the webhook entry so the tap is not re-dispatched as
|
||||
# plain text (which could re-enter the agent loop).
|
||||
return True
|
||||
|
||||
# Clarify: cl:<clarify_id>:<idx|other>
|
||||
if button_id.startswith("cl:"):
|
||||
parts = button_id.split(":", 2)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@ def _make_adapter(**overrides):
|
|||
return adapter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authorized_interactive_env(monkeypatch):
|
||||
"""``dm_policy: open`` requires an explicit allow-all opt-in on main."""
|
||||
monkeypatch.setenv("WHATSAPP_ALLOW_ALL_USERS", "true")
|
||||
|
||||
|
||||
def _mock_httpx_response(status_code: int, json_body: dict):
|
||||
"""Build an httpx-Response-like mock the adapter's ``send`` will accept."""
|
||||
resp = MagicMock()
|
||||
|
|
@ -1790,6 +1796,7 @@ class TestSendSlashConfirmButtons:
|
|||
assert adapter._slash_confirm_state["cf-9"] == "sess-sc-1"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("authorized_interactive_env")
|
||||
class TestDispatchInteractiveReplyClarify:
|
||||
"""Inbound side: button-tap → clarify resolver."""
|
||||
|
||||
|
|
@ -1930,6 +1937,7 @@ class TestDispatchInteractiveReplyClarify:
|
|||
assert handled is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("authorized_interactive_env")
|
||||
class TestDispatchInteractiveReplyApproval:
|
||||
"""Inbound side: approval-tap → resolve_gateway_approval."""
|
||||
|
||||
|
|
@ -1995,6 +2003,7 @@ class TestDispatchInteractiveReplyApproval:
|
|||
assert "Denied" in confirm_payload["text"]["body"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("authorized_interactive_env")
|
||||
class TestDispatchInteractiveReplySlashConfirm:
|
||||
"""Inbound side: slash-confirm-tap → tools.slash_confirm.resolve."""
|
||||
|
||||
|
|
@ -2038,6 +2047,68 @@ class TestDispatchInteractiveReplySlashConfirm:
|
|||
assert "MCP reloaded" in reply_payload["text"]["body"]
|
||||
|
||||
|
||||
class TestDispatchInteractiveReplyAuthorization:
|
||||
"""Interactive taps must honor the same DM allowlist as text intake."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_tap_denied_when_sender_not_allowlisted(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
_dm_policy="allowlist",
|
||||
_allow_from={"19998887777"},
|
||||
)
|
||||
adapter._exec_approval_state["app1"] = "sess-app-1"
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"tools.approval.resolve_gateway_approval",
|
||||
lambda session_key, choice: calls.append((session_key, choice)) or 1,
|
||||
)
|
||||
|
||||
raw = {
|
||||
"from": "15551234567",
|
||||
"type": "interactive",
|
||||
"interactive": {
|
||||
"type": "button_reply",
|
||||
"button_reply": {"id": "appr:app1:approve", "title": "Approve"},
|
||||
},
|
||||
}
|
||||
handled = await adapter._dispatch_interactive_reply(raw, {})
|
||||
|
||||
assert handled is True
|
||||
assert calls == []
|
||||
assert adapter._exec_approval_state["app1"] == "sess-app-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_tap_allowed_when_sender_allowlisted(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
_dm_policy="allowlist",
|
||||
_allow_from={"15551234567"},
|
||||
)
|
||||
adapter._exec_approval_state["app1"] = "sess-app-1"
|
||||
adapter._http_client = MagicMock()
|
||||
adapter._http_client.post = AsyncMock(
|
||||
return_value=_mock_httpx_response(200, {"messages": [{"id": "x"}]})
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"tools.approval.resolve_gateway_approval",
|
||||
lambda session_key, choice: calls.append((session_key, choice)) or 1,
|
||||
)
|
||||
|
||||
raw = {
|
||||
"from": "15551234567",
|
||||
"type": "interactive",
|
||||
"interactive": {
|
||||
"type": "button_reply",
|
||||
"button_reply": {"id": "appr:app1:approve", "title": "Approve"},
|
||||
},
|
||||
}
|
||||
handled = await adapter._dispatch_interactive_reply(raw, {})
|
||||
|
||||
assert handled is True
|
||||
assert calls == [("sess-app-1", "approve")]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("authorized_interactive_env")
|
||||
class TestInteractiveReplyEndToEnd:
|
||||
"""Integration: `_build_message_event_from_cloud` must SHORT-CIRCUIT
|
||||
on a recognized interactive reply and NOT also produce a fresh
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue