Skip to content
Closed
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
18 changes: 7 additions & 11 deletions plugins/hermes-agent/plugin/inline/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion plugins/hermes-agent/tests/adapter-python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
}))
Expand Down Expand Up @@ -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 {
Expand Down