diff --git a/plugins/hermes-agent/plugin/inline/adapter.py b/plugins/hermes-agent/plugin/inline/adapter.py index 37b3fc3c7..ac04caa08 100644 --- a/plugins/hermes-agent/plugin/inline/adapter.py +++ b/plugins/hermes-agent/plugin/inline/adapter.py @@ -2402,7 +2402,10 @@ async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) ) if has_command_target and not command_addressed_to_me: return - if not agent_action and await self._handle_thread_command( + # Adapter-local commands and thread creation run before core's authz, so they + # need the same default-deny sender check the button actions use. + actor_authorized = self._actor_authorized(chat_type, from_id) + if not agent_action and actor_authorized and await self._handle_thread_command( chat_id=chat_id, msg_id=msg_id, text=text, @@ -2411,7 +2414,7 @@ async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) parent_chat_id=parent_chat_id, ): return - if not agent_action and await self._handle_follow_command( + if not agent_action and actor_authorized and await self._handle_follow_command( chat_id=chat_id, msg_id=msg_id, from_id=from_id, @@ -2420,7 +2423,7 @@ async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) thread_id=thread_id, ): return - if not agent_action and await self._handle_inline_maintenance_command( + if not agent_action and actor_authorized and await self._handle_inline_maintenance_command( chat_id=chat_id, msg_id=msg_id, text=text, @@ -2474,6 +2477,7 @@ async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) if ( not edit and not agent_action + and actor_authorized and not thread_id and self._should_create_reply_thread_for_message( chat_id=chat_id, @@ -4018,14 +4022,6 @@ async def _thread_action_allowed(self, event: Dict[str, Any], state: Dict[str, A chat_type = await self._action_chat_type(event) if self._actor_authorized(chat_type, actor_id): return True - display_chat_id = self._chat_key(state.get("display_chat_id")) - target_chat_id = self._chat_key(state.get("target_chat_id")) - if chat_type and actor_id and self._allowed(chat_type, actor_id): - if chat_type == "dm": - return True - thread_id = display_chat_id if target_chat_id and display_chat_id != target_chat_id else None - if self._chat_allowed(display_chat_id or str(event.get("chatId") or ""), thread_id, target_chat_id): - return True await self._answer_action(interaction_id, "Not authorized") logger.info("[inline] blocked thread action actor=%s chat_type=%s action=%s", actor_id or "unknown", chat_type or "unknown", event.get("actionId") or "") return False diff --git a/plugins/hermes-agent/tests/adapter-python.test.ts b/plugins/hermes-agent/tests/adapter-python.test.ts index 760720d91..916337726 100644 --- a/plugins/hermes-agent/tests/adapter-python.test.ts +++ b/plugins/hermes-agent/tests/adapter-python.test.ts @@ -2054,6 +2054,7 @@ asyncio.run(assert_activated_agent_avoids_lookup()) async def assert_forced_reply_thread_creation(): adapter = InlineAdapter(PlatformConfig(extra={ **base_extra, + "allow_from": "u1", "reply_threads": "on", "require_mention": False, "channel_prompts": {"99": "Thread prompt", "10": "Parent prompt"}, @@ -2154,7 +2155,7 @@ async def assert_forced_reply_thread_creation(): asyncio.run(assert_forced_reply_thread_creation()) async def assert_default_dm_reply_thread_creation(): - adapter = InlineAdapter(PlatformConfig(extra=base_extra)) + adapter = InlineAdapter(PlatformConfig(extra={**base_extra, "allow_from": "u1"})) events = [] calls = [] @@ -2546,6 +2547,7 @@ asyncio.run(assert_observed_context_buffer()) async def assert_explicit_addressing_precedence(): adapter = InlineAdapter(PlatformConfig(extra={ **base_extra, + "allow_from": "u1", "require_mention": True, "reply_threads": False, "context_backfill": "off", @@ -2690,6 +2692,7 @@ async def assert_reply_thread_slash_command(): settings_path = Path(tmp) / "settings.json" adapter = InlineAdapter(PlatformConfig(extra={ **base_extra, + "allow_from": "u1,1600", "settings_path": str(settings_path), "require_mention": True, })) @@ -2946,6 +2949,56 @@ async def assert_reply_thread_slash_command(): asyncio.run(assert_reply_thread_slash_command()) +async def assert_local_commands_require_authorized_sender(): + """Under the default open policy an unlisted sender must not reach adapter-local + handlers before core authz: /threads falls through to Hermes, th: buttons are denied.""" + with tempfile.TemporaryDirectory() as tmp: + settings_path = Path(tmp) / "settings.json" + adapter = InlineAdapter(PlatformConfig(extra={**base_extra, "settings_path": str(settings_path)})) + events = [] + answers = [] + sidecar_calls = [] + + async def fake_handle_message(event): + events.append(event) + + async def fake_answer_action(interaction_id, toast): + answers.append((interaction_id, toast)) + + async def fake_sidecar_call(path, body): + sidecar_calls.append((path, body)) + return {"ok": True, "result": {}} + + async def fake_fetch_message(chat_id, message_id): + return {"peerId": {"type": {"oneofKind": "user", "user": {"userId": chat_id}}}} + + adapter.handle_message = fake_handle_message + adapter._answer_action = fake_answer_action + adapter._sidecar_call = fake_sidecar_call + adapter._fetch_message = fake_fetch_message + + for text in ("/threads off", "/follow", "/inline-sync"): + await adapter._dispatch_message({ + "seq": len(events) + 300, + "chatId": "20", + "message": {"id": f"stranger-{len(events)}", "chatId": "20", "fromId": "stranger", "message": text, "peerId": {"peer": {"oneofKind": "user"}}}, + }) + assert [e.text for e in events] == ["/threads off", "/follow", "/inline-sync"] + assert adapter._reply_thread_mode_for_chat("20") == "auto" + assert not settings_path.exists() + assert sidecar_calls == [] + + assert not await adapter._thread_action_allowed({ + "chatId": "20", + "messageId": "m1", + "interactionId": "stranger-thread-action", + "actorUserId": "stranger", + "actionId": "th:x:on", + }, {"display_chat_id": "20", "target_chat_id": "20"}) + assert answers[-1] == ("stranger-thread-action", "Not authorized") + +asyncio.run(assert_local_commands_require_authorized_sender()) + async def assert_new_message_delivery_dedup(): def event(seq, date, text): return {