diff --git a/gateway/config.py b/gateway/config.py index da370541bb..ff264888fb 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -809,6 +809,12 @@ def load_gateway_config() -> GatewayConfig: os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc) if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"): os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower() + # 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", {}) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index c8ee28859d..843fb78959 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1887,6 +1887,12 @@ class SlackAdapter(BasePlatformAdapter): 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(): @@ -2924,3 +2930,19 @@ class SlackAdapter(BasePlatformAdapter): if s: return {part.strip() for part in s.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() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9db661a27e..01c116336a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1100,6 +1100,14 @@ DEFAULT_CONFIG = { # 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) + "channel_prompts": {}, # Per-channel ephemeral system prompts + }, + # Discord platform settings (gateway mode) "discord": { "require_mention": True, # Require @mention to respond in server channels @@ -1138,11 +1146,6 @@ DEFAULT_CONFIG = { "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) }, - # Slack platform settings (gateway mode) - "slack": { - "channel_prompts": {}, # Per-channel ephemeral system prompts - }, - # Mattermost platform settings (gateway mode) "mattermost": { "channel_prompts": {}, # Per-channel ephemeral system prompts diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py index 892cabef88..23aa2f1545 100644 --- a/tests/gateway/test_slack_mention.py +++ b/tests/gateway/test_slack_mention.py @@ -55,7 +55,7 @@ CHANNEL_ID = "C0AQWDLHY9M" OTHER_CHANNEL_ID = "C9999999999" -def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None): +def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None, allowed_channels=None): extra = {} if require_mention is not None: extra["require_mention"] = require_mention @@ -63,6 +63,8 @@ def _make_adapter(require_mention=None, strict_mention=None, free_response_chann extra["strict_mention"] = strict_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 @@ -249,7 +251,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(): @@ -552,3 +559,131 @@ def test_mention_outside_strict_mode_still_registers_thread(): adapter._mentioned_threads.add(event_thread_ts) assert thread_ts in adapter._mentioned_threads + + +# --------------------------------------------------------------------------- +# 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