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
6 changes: 6 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,12 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
ac = slack_cfg.get("allowed_channels")
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)

# Discord settings β†’ env vars (env vars take precedence)
discord_cfg = yaml_cfg.get("discord", {})
Expand Down
22 changes: 22 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,12 @@ async def _handle_slack_message(self, event: dict) -> None:
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)

if not is_dm and bot_uid:
# Check allowed channels β€” if set, only respond in these channels (whitelist)
allowed_channels = self._slack_allowed_channels()
if allowed_channels and channel_id not in allowed_channels:
logger.debug("[Slack] Ignoring message in non-allowed channel: %s", channel_id)
return

if channel_id in self._slack_free_response_channels():
pass # Free-response channel β€” always process
elif not self._slack_require_mention():
Expand Down Expand Up @@ -1693,3 +1699,19 @@ def _slack_free_response_channels(self) -> set:
if isinstance(raw, str) and raw.strip():
return {part.strip() for part in raw.split(",") if part.strip()}
return set()

def _slack_allowed_channels(self) -> set:
"""Return the whitelist of channel IDs the bot will respond in.

When non-empty, messages from channels NOT in this set are silently
ignored β€” even if the bot is @mentioned. DMs are never filtered.
Empty set means no restriction (fully backward compatible).
"""
raw = self.config.extra.get("allowed_channels")
if raw is None:
raw = os.getenv("SLACK_ALLOWED_CHANNELS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
if isinstance(raw, str) and raw.strip():
return {part.strip() for part in raw.split(",") if part.strip()}
return set()
7 changes: 7 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,13 @@ def _ensure_hermes_home_managed(home: Path):
# Empty string means use server-local time.
"timezone": "",

# Slack platform settings (gateway mode)
"slack": {
"require_mention": True, # Require @mention to respond in channels
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
},

# Discord platform settings (gateway mode)
"discord": {
"require_mention": True, # Require @mention to respond in server channels
Expand Down
139 changes: 137 additions & 2 deletions tests/gateway/test_slack_mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@ def _ensure_slack_mock():
OTHER_CHANNEL_ID = "C9999999999"


def _make_adapter(require_mention=None, free_response_channels=None):
def _make_adapter(require_mention=None, free_response_channels=None, allowed_channels=None):
extra = {}
if require_mention is not None:
extra["require_mention"] = require_mention
if free_response_channels is not None:
extra["free_response_channels"] = free_response_channels
if allowed_channels is not None:
extra["allowed_channels"] = allowed_channels

adapter = object.__new__(SlackAdapter)
adapter.platform = Platform.SLACK
Expand Down Expand Up @@ -188,7 +190,12 @@ def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
text = f"<@{bot_uid}> {text}"
is_mentioned = bot_uid and f"<@{bot_uid}>" in text

if not is_dm:
if not is_dm and bot_uid:
# allowed_channels check (whitelist β€” must pass before other gating)
allowed = adapter._slack_allowed_channels()
if allowed and channel_id not in allowed:
return False

if channel_id in adapter._slack_free_response_channels():
return True
elif not adapter._slack_require_mention():
Expand Down Expand Up @@ -310,3 +317,131 @@ def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
import os as _os
assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"


# ---------------------------------------------------------------------------
# Tests: _slack_allowed_channels
# ---------------------------------------------------------------------------

def test_allowed_channels_default_empty(monkeypatch):
monkeypatch.delenv("SLACK_ALLOWED_CHANNELS", raising=False)
adapter = _make_adapter()
assert adapter._slack_allowed_channels() == set()


def test_allowed_channels_list():
adapter = _make_adapter(allowed_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
result = adapter._slack_allowed_channels()
assert CHANNEL_ID in result
assert OTHER_CHANNEL_ID in result


def test_allowed_channels_csv_string():
adapter = _make_adapter(allowed_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
result = adapter._slack_allowed_channels()
assert CHANNEL_ID in result
assert OTHER_CHANNEL_ID in result


def test_allowed_channels_empty_string():
adapter = _make_adapter(allowed_channels="")
assert adapter._slack_allowed_channels() == set()


def test_allowed_channels_env_var_fallback(monkeypatch):
monkeypatch.setenv("SLACK_ALLOWED_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
adapter = _make_adapter() # no config value β†’ falls back to env
result = adapter._slack_allowed_channels()
assert CHANNEL_ID in result
assert OTHER_CHANNEL_ID in result


# ---------------------------------------------------------------------------
# Tests: allowed_channels gating integration
# ---------------------------------------------------------------------------

def test_allowed_channels_blocks_non_whitelisted_channel():
"""Messages in channels not in allowed_channels are silently ignored."""
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False


def test_allowed_channels_permits_whitelisted_channel():
"""Messages in the allowed channel are processed normally."""
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
assert _would_process(adapter, channel_id=CHANNEL_ID, mentioned=True) is True


def test_allowed_channels_empty_no_restriction():
"""Empty allowed_channels imposes no restriction (fully backward compatible)."""
adapter = _make_adapter(allowed_channels="")
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, mentioned=True) is True


def test_allowed_channels_blocks_even_when_mentioned():
"""Whitelist takes precedence β€” @mention in a non-allowed channel is ignored."""
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, mentioned=True) is False


def test_allowed_channels_dm_unaffected():
"""DMs bypass the allowed_channels check entirely."""
adapter = _make_adapter(allowed_channels=[CHANNEL_ID])
# DM channel IDs typically start with D; the check is guarded by `not is_dm`
assert _would_process(adapter, is_dm=True, channel_id="DDMCHANNEL") is True


def test_allowed_channels_env_var_blocks_channel(monkeypatch):
"""SLACK_ALLOWED_CHANNELS env var (no config) also gates messages."""
monkeypatch.setenv("SLACK_ALLOWED_CHANNELS", CHANNEL_ID)
adapter = _make_adapter() # no config value β†’ falls back to env
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
assert _would_process(adapter, channel_id=CHANNEL_ID, mentioned=True) is True


# ---------------------------------------------------------------------------
# Tests: config bridging for allowed_channels
# ---------------------------------------------------------------------------

def test_config_bridges_slack_allowed_channels(monkeypatch, tmp_path):
from gateway.config import load_gateway_config

hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" allowed_channels:\n"
f" - {CHANNEL_ID}\n"
f" - {OTHER_CHANNEL_ID}\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SLACK_ALLOWED_CHANNELS", raising=False)

load_gateway_config()

import os as _os
assert _os.environ["SLACK_ALLOWED_CHANNELS"] == f"{CHANNEL_ID},{OTHER_CHANNEL_ID}"


def test_config_bridges_slack_allowed_channels_env_takes_precedence(monkeypatch, tmp_path):
"""Env var set before load_gateway_config() should not be overwritten."""
from gateway.config import load_gateway_config

hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
f" allowed_channels: {CHANNEL_ID}\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("SLACK_ALLOWED_CHANNELS", OTHER_CHANNEL_ID) # already set

load_gateway_config()

import os as _os
# env var must not be overwritten by config.yaml
assert _os.environ["SLACK_ALLOWED_CHANNELS"] == OTHER_CHANNEL_ID