Просмотр исходного кода

feat(chatto): restrict toolsets per room via CHATTO_ROOM_TOOLSETS

A room the bot answers in can currently reach every tool the platform
enables, so a public channel and a trusted one grant the same power. The
adapter now implements Hermes' toolsets_for_source hook (the same one the
webhook adapter uses for per-route tool lists), keyed on the room ID the
realtime event carried, so a thread turn inherits its room's restriction.

Entries are room_id:toolset; several entries for one room accumulate, which
keeps the syntax inside the existing comma-separated list kind instead of
inventing a nested separator. Hermes' contract is replacement, so a listed
room gets exactly what its entries name and an unlisted room returns None
to leave platform_toolsets in charge. Malformed entries fail startup — a
typo that was silently skipped would leave a room the operator meant to
restrict on the default toolsets.
Paul Klumpp 1 неделя назад
Родитель
Сommit
5dd86e72d1
4 измененных файлов с 129 добавлено и 0 удалено
  1. 59 0
      adapter.py
  2. 7 0
      platform_config.py
  3. 4 0
      plugin.yaml
  4. 59 0
      test_adapter.py

+ 59 - 0
adapter.py

@@ -58,6 +58,7 @@ from gateway.platforms.base import (
     get_inbound_media_max_bytes,
     get_inbound_media_max_bytes,
     validate_inbound_media_size,
     validate_inbound_media_size,
 )
 )
+from gateway.session import SessionSource
 
 
 # Chattolib imports (vendored)
 # Chattolib imports (vendored)
 # Using vendored chattolib from vendor/chattolib/
 # Using vendored chattolib from vendor/chattolib/
@@ -1218,6 +1219,25 @@ class ChattoAdapter(BasePlatformAdapter):
             self.chatto_config, room_id, self._room_kinds.get(room_id)
             self.chatto_config, room_id, self._room_kinds.get(room_id)
         )
         )
 
 
+    def toolsets_for_source(self, source: SessionSource) -> list[str] | None:
+        """Restrict which toolsets a turn in a listed room may use.
+
+        ``CHATTO_ROOM_TOOLSETS`` holds ``room_id:toolset`` entries. A room with
+        at least one entry *replaces* the platform's toolset list for turns in
+        that room — Hermes' contract for this hook — so the entries have to name
+        every toolset the room should keep. A room without an entry returns
+        None, leaving ``platform_toolsets`` in charge. DM rooms are looked up
+        like any other room, and a thread turn carries its room's ID, so a room
+        entry covers its threads too.
+
+        BasePlatformAdapter override
+        """
+        room_id = str(getattr(source, "chat_id", "") or "")
+        if not room_id:
+            return None
+        by_room, _malformed = _parse_room_toolsets(self.chatto_config.room_toolsets.value)
+        return by_room.get(room_id) or None
+
     async def _room_roster_context(
     async def _room_roster_context(
         self, client: ChattoClient, room_id: str
         self, client: ChattoClient, room_id: str
     ) -> _RoomRoster | None:
     ) -> _RoomRoster | None:
@@ -4165,6 +4185,17 @@ def hermes_validate_config(config: PlatformConfig) -> bool:
         )
         )
         return False
         return False
 
 
+    _by_room, malformed_toolsets = _parse_room_toolsets(
+        resolved.room_toolsets.value
+    )
+    if malformed_toolsets:
+        logger.error(
+            "Chatto: 'room_toolsets' entries must look like 'room_id:toolset' — "
+            "these could not be read: %s",
+            ", ".join(repr(entry) for entry in malformed_toolsets),
+        )
+        return False
+
     # base_url always resolves (it defaults to ChattoHQ), so the only real
     # base_url always resolves (it defaults to ChattoHQ), so the only real
     # question is whether a token came in.
     # question is whether a token came in.
     if resolved.token.value is None:
     if resolved.token.value is None:
@@ -4291,6 +4322,7 @@ _CAPABILITY_LABELS = {
     "send_typing": "typing indicators",
     "send_typing": "typing indicators",
     "create_handoff_thread": "threads",
     "create_handoff_thread": "threads",
     "create_room": "room creation",
     "create_room": "room creation",
+    "toolsets_for_source": "per-room toolsets (CHATTO_ROOM_TOOLSETS)",
 }
 }
 
 
 
 
@@ -4986,6 +5018,33 @@ def _chatto_config_for_tools() -> ChattoConfiguration | None:
     return resolved
     return resolved
 
 
 
 
+def _parse_room_toolsets(
+    entries: Iterable[str],
+) -> tuple[dict[str, list[str]], list[str]]:
+    """Split ``room_id:toolset`` entries into per-room toolset lists.
+
+    Returns the mapping plus the entries that could not be read. Several
+    entries may name the same room; they accumulate in the order given, which
+    is what lets a room keep more than one toolset without inventing a nested
+    separator inside a comma-separated list. Malformed entries are reported
+    rather than raised: startup validation refuses the config, and a running
+    gateway must not die on a typo it can ignore.
+    """
+    by_room: dict[str, list[str]] = {}
+    malformed: list[str] = []
+    for raw in entries:
+        entry = str(raw).strip()
+        room_id, _, toolset = entry.partition(":")
+        room_id, toolset = room_id.strip(), toolset.strip()
+        if not room_id or not toolset:
+            malformed.append(entry)
+            continue
+        toolsets = by_room.setdefault(room_id, [])
+        if toolset not in toolsets:
+            toolsets.append(toolset)
+    return by_room, malformed
+
+
 def _room_answers_in(
 def _room_answers_in(
     cfg: ChattoConfiguration | None, room_id: str, kind: RoomKind | None
     cfg: ChattoConfiguration | None, room_id: str, kind: RoomKind | None
 ) -> bool:
 ) -> bool:

+ 7 - 0
platform_config.py

@@ -428,6 +428,13 @@ class ChattoConfiguration:
     # only inbound gate; DMs always answer.
     # only inbound gate; DMs always answer.
     require_mention_rooms = ConfigField("list")
     require_mention_rooms = ConfigField("list")
     optional_mention_rooms = ConfigField("list")
     optional_mention_rooms = ConfigField("list")
+    # Per-room toolset restriction. Entries look like "room_id:toolset"; several
+    # entries may name the same room and accumulate in the order given. A room
+    # with at least one entry REPLACES the platform's toolset list for turns in
+    # that room (Hermes' contract for toolsets_for_source), so the entries have
+    # to name everything the room keeps. A malformed entry is rejected at
+    # startup rather than guessed at.
+    room_toolsets = ConfigField("list")
     # Auto-thread: by default, Chatto creates a thread for replies to room
     # Auto-thread: by default, Chatto creates a thread for replies to room
     # messages (not DMs, not already in a thread). This keeps conversations
     # messages (not DMs, not already in a thread). This keeps conversations
     # organized in the room. Can be disabled via extra.auto_thread=false.
     # organized in the room. Can be disabled via extra.auto_thread=false.

+ 4 - 0
plugin.yaml

@@ -61,6 +61,10 @@ optional_env:
     description: "Comma-separated room IDs where the bot answers every message, addressed or not; rooms on neither this nor CHATTO_REQUIRE_MENTION_ROOMS stay silent — read-only, never seeded into context, never answered. (default: none — the listed rooms answer everything)"
     description: "Comma-separated room IDs where the bot answers every message, addressed or not; rooms on neither this nor CHATTO_REQUIRE_MENTION_ROOMS stay silent — read-only, never seeded into context, never answered. (default: none — the listed rooms answer everything)"
     prompt: "Free-response room IDs (comma-separated)"
     prompt: "Free-response room IDs (comma-separated)"
     password: false
     password: false
+  - name: CHATTO_ROOM_TOOLSETS
+    description: "Per-room toolset restriction as comma-separated room_id:toolset entries, e.g. RbmwiHk4vs9a3Ql:web,RbmwiHk4vs9a3Ql:file — several entries for one room accumulate. A room with an entry REPLACES the platform's toolset list for its turns, so list every toolset the room should keep; rooms without an entry keep the normal platform toolsets. (default: none — no room is restricted)"
+    prompt: "Per-room toolsets (room_id:toolset, comma-separated)"
+    password: false
   - name: CHATTO_REACTIONS
   - name: CHATTO_REACTIONS
     description: "Add 👀/✅/❌/🚫 reactions to messages during processing (default: true)"
     description: "Add 👀/✅/❌/🚫 reactions to messages during processing (default: true)"
     prompt: "Enable message reactions? (true/false)"
     prompt: "Enable message reactions? (true/false)"

+ 59 - 0
test_adapter.py

@@ -494,6 +494,11 @@ class TestRegistration:
         with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
         with patch.object(ChattoAdapter, "send_video", BasePlatformAdapter.send_video):
             assert "video" not in _capabilities()
             assert "video" not in _capabilities()
 
 
+    def test_per_room_toolsets_are_announced(self):
+        """The banner is the only place a startup tells an operator that a
+        per-room restriction exists at all."""
+        assert any("per-room toolsets" in line for line in _capabilities())
+
     def test_readme_yaml_example_is_shaped_like_a_config_hermes_reads(self):
     def test_readme_yaml_example_is_shaped_like_a_config_hermes_reads(self):
         """The example is what users paste into ~/.hermes/config.yaml. Two ways
         """The example is what users paste into ~/.hermes/config.yaml. Two ways
         it silently did nothing: `platforms:` nested under `gateway:` (only the
         it silently did nothing: `platforms:` nested under `gateway:` (only the
@@ -617,6 +622,16 @@ class TestValidateConfigGates:
         )
         )
         assert validate_config(cfg) is True
         assert validate_config(cfg) is True
 
 
+    def test_a_malformed_room_toolset_entry_is_rejected(self):
+        """An entry that names a restriction but cannot be read must not leave
+        the room silently on the platform default — that fails open."""
+        cfg = self._cfg(room_toolsets=["room-1:web", "room-2"])
+        assert validate_config(cfg) is False
+
+    def test_well_formed_room_toolsets_are_accepted(self):
+        cfg = self._cfg(room_toolsets=["room-1:web", "room-1:file"])
+        assert validate_config(cfg) is True
+
     @pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
     @pytest.mark.parametrize("key", ["require_mention_rooms", "optional_mention_rooms"])
     def test_known_keys_do_not_warn(self, key, caplog):
     def test_known_keys_do_not_warn(self, key, caplog):
         cfg = self._cfg(**{key: ["room-1"]})
         cfg = self._cfg(**{key: ["room-1"]})
@@ -662,6 +677,50 @@ class TestValidateConfigGates:
         assert not [m for m in caplog.messages if "matches no config field" in m]
         assert not [m for m in caplog.messages if "matches no config field" in m]
 
 
 
 
+# -- Per-room toolsets --
+
+
+class TestRoomToolsets:
+    """``CHATTO_ROOM_TOOLSETS`` replaces the platform's toolset list for a room.
+
+    Hermes' contract for ``toolsets_for_source`` is *replacement*, not
+    subtraction, so the answer is exactly what the entries name.
+    """
+
+    def _source(self, chat_id="room-1", chat_type="channel"):
+        from gateway.session import SessionSource
+
+        return SessionSource(
+            platform=Platform(ChattoConstants.PLATFORM_NAME),
+            chat_id=chat_id,
+            chat_type=chat_type,
+        )
+
+    def test_a_listed_room_gets_exactly_the_listed_toolsets(self):
+        adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:file"])
+        assert adapter.toolsets_for_source(self._source()) == ["web", "file"]
+
+    def test_an_unlisted_room_keeps_the_platform_default(self):
+        adapter = _make_adapter(room_toolsets=["room-1:web"])
+        assert adapter.toolsets_for_source(self._source(chat_id="room-2")) is None
+
+    def test_a_thread_turn_is_governed_by_its_room(self):
+        """A thread's source carries the room as ``chat_id`` and the thread
+        separately, so the room's restriction has to reach its threads."""
+        adapter = _make_adapter(room_toolsets=["room-1:web"])
+        assert adapter.toolsets_for_source(self._source(chat_type="thread")) == [
+            "web"
+        ]
+
+    def test_a_duplicate_toolset_is_listed_once(self):
+        adapter = _make_adapter(room_toolsets=["room-1:web", "room-1:web"])
+        assert adapter.toolsets_for_source(self._source()) == ["web"]
+
+    def test_no_configuration_means_no_override(self):
+        adapter = _make_adapter()
+        assert adapter.toolsets_for_source(self._source()) is None
+
+
 # -- Send functionality --
 # -- Send functionality --