From 832befaa21843be7e4f1053fb5a8151e926bb32c Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:50:12 +0530 Subject: [PATCH] fix(claude): reject mismatched self and scope ids A scoped channel accepted --self without comparing it to the scope frame's agent_person_id, so a wrong value could drop another member's posts and push the agent's own. Fail with exit 2 before binding or handling events when the ids differ; an unscoped stream still uses --self alone. Add regression tests for a matching id that drops the agent's own post and a mismatching id that ends the channel without fetching a message. Refs #254. Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- src/pepper_cli/claude_channel.py | 10 ++++++++-- tests/test_claude_channel.py | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/pepper_cli/claude_channel.py b/src/pepper_cli/claude_channel.py index 2e117f2..fea234d 100644 --- a/src/pepper_cli/claude_channel.py +++ b/src/pepper_cli/claude_channel.py @@ -801,6 +801,14 @@ def _accept_scope(self, frame: api_client.FeedFrame) -> None: raise self._ended( "the cursor file belongs to a different watch", stop.code ) from stop + if self.self_id is None: + self.self_id = seen.agent_person_id + elif not _same_id(self.self_id, seen.agent_person_id): + raise self._ended( + f"--self {sanitize_line(self.self_id)} does not match the scoped agent " + f"person id {sanitize_line(seen.agent_person_id)}", + 2, + ) self.bound = seen self.allowed_rooms.add(seen.room_id) if not _same_id(seen.room_id, seen.requested_room_id): @@ -809,8 +817,6 @@ def _accept_scope(self, frame: api_client.FeedFrame) -> None: f"following {seen.room_id}.", soft_wrap=True, ) - if self.self_id is None: - self.self_id = seen.agent_person_id elif not ( _same_id(seen.room_id, self.bound.room_id) and _same_id(seen.topic_id, self.bound.topic_id) diff --git a/tests/test_claude_channel.py b/tests/test_claude_channel.py index ad7032f..6e04d95 100644 --- a/tests/test_claude_channel.py +++ b/tests/test_claude_channel.py @@ -864,6 +864,40 @@ def test_self_given_at_start_is_the_self_id_and_an_unknown_one_is_omitted(monkey assert "self_id" not in unknown.notifications[0]["meta"] +def test_self_matching_the_scope_drops_the_agents_own_post(monkeypatch, tmp_path): + cursor_file = _scoped_cursor_file(tmp_path / "cursor", 3) + fake = _Fake(_CONNECTED + _scope() + _event(4, author=_AGENT) + _cursor(5)) + + result = run_channel( + monkeypatch, + fake, + ["--room", _ROOM, "--self", _AGENT, "--cursor-file", str(cursor_file)], + ) + + assert result.notifications == [] + assert not [r for r in fake.requests if r.url.path.endswith("/messages")] + assert json.loads(cursor_file.read_text())["cursor"] == 5 + + +def test_a_scoped_self_that_does_not_match_the_scope_ends_the_channel(monkeypatch): + fake = _Fake(_CONNECTED + _scope() + _event(7)) + + result = run_channel( + monkeypatch, + fake, + ["--room", _ROOM, "--self", _BOB], + script=_ended, + ) + + (note,) = result.notifications + assert note["meta"] == {"kind": "ended"} + assert "--self" in note["content"] + assert _BOB in note["content"] + assert _AGENT in note["content"] + assert result.code == 2 + assert not [r for r in fake.requests if r.url.path.endswith("/messages")] + + def test_an_unreadable_message_still_carries_the_self_id(): _, meta = claude_channel.message_event( info=claude_channel.TopicInfo(_ROOM, "Kitchen", "Dinner"),