Skip to content
Open
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
33 changes: 20 additions & 13 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2190,6 +2190,7 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor
build_session_key,
is_shared_multi_user_session,
neutralize_untrusted_inline_text,
shared_participant_label,
)
from gateway.delivery import (
DeliveryRouter,
Expand Down Expand Up @@ -12836,26 +12837,32 @@ async def _prepare_inbound_message_text(
group_sessions_per_user=_group_sessions_per_user,
thread_sessions_per_user=_thread_sessions_per_user,
)
if _is_shared_multi_user and source.user_name:
if _is_shared_multi_user:
# source.user_name is the platform display name — attacker-
# influenceable on any platform that lets participants set their
# own name. Neutralize embedded newlines/control chars before
# interpolating it into every message in the shared session, or
# a hostile name can masquerade as a fake markdown section
# (mirrors the same field's treatment in
# build_session_context_prompt via _format_untrusted_prompt_value).
_safe_user_name = neutralize_untrusted_inline_text(source.user_name)
# On Slack, expose the current author's verifiable user ID next to
# the display name (#17916): "mention me again" requests need a
# trusted `<@U...>` target for the CURRENT speaker — display names
# are ambiguous and historical mentions may point at someone else.
# The user_id comes from the Slack event envelope (not
# user-editable text), so it does not need neutralization.
if source.platform == Platform.SLACK and source.user_id:
_safe_user_name = (
f"{_safe_user_name} | Slack user <@{source.user_id}>"
)
message_text = f"[{_safe_user_name}] {message_text}"
# Senders with no display name at all still need a STABLE,
# non-identifying label so the model can tell participants apart
# (shared_participant_label falls back to a hashed user id).
_sender_label = shared_participant_label(source)

if _sender_label:
_safe_user_name = neutralize_untrusted_inline_text(_sender_label)
# On Slack, expose the current author's verifiable user ID next to
# the display name (#17916): "mention me again" requests need a
# trusted `<@U...>` target for the CURRENT speaker — display names
# are ambiguous and historical mentions may point at someone else.
# The user_id comes from the Slack event envelope (not
# user-editable text), so it does not need neutralization.
if source.platform == Platform.SLACK and source.user_id:
_safe_user_name = (
f"{_safe_user_name} | Slack user <@{source.user_id}>"
)
message_text = f"[{_safe_user_name}] {message_text}"

# Prepend channel context from history backfill (if any). This
# happens after sender-prefix so the prefix only applies to the
Expand Down
29 changes: 27 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ def _hash_chat_id(value: str) -> str:
return _hash_id(value)


def _sanitize_participant_label(value: str) -> str:
"""Make a user-controlled display name safe inside ``[label]`` prefixes.

Only whitespace collapsing and bracket neutralization happen here — length
clamping is the caller's job (``neutralize_untrusted_inline_text`` already
applies the shared prompt-metadata cap), so a long-but-legitimate name is
not truncated twice with two different limits.
"""
collapsed = " ".join(str(value or "").split())
return collapsed.replace("[", "(").replace("]", ")").strip()


from .config import (
Platform,
GatewayConfig,
Expand Down Expand Up @@ -292,7 +304,20 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
auto_thread_created=bool(data.get("auto_thread_created", False)),
auto_thread_initial_name=data.get("auto_thread_initial_name"),
)



def shared_participant_label(source: SessionSource) -> Optional[str]:
"""Return the stable label used to disambiguate speakers in shared sessions."""
display_name = _sanitize_participant_label(source.user_name or "")
if display_name:
return display_name

for raw_id in (source.user_id_alt, source.user_id):
raw = str(raw_id or "").strip()
if raw:
return _hash_sender_id(raw)

return None


@dataclass
Expand Down Expand Up @@ -549,7 +574,7 @@ def build_session_context_prompt(
session_label = "Multi-user thread" if context.source.thread_id else "Multi-user session"
lines.append(
f"**Session type:** {session_label} — messages are prefixed "
"with [sender name]. Multiple users may participate."
"with [sender label]. Multiple users may participate."
)
elif context.source.user_name:
lines.append(
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@
"saeed919@pm.me": "falasi",
"chrisdlc119@outlook.com": "chdlc",
"omar@techdeveloper.site": "nycomar",
"omar@kostudios.io": "OmarB97",
"qiyin.zuo@pcitc.com": "qiyin-code",
"mr.aashiz@gmail.com": "aashizpoudel",
"adityargadgil@gmail.com": "AdityaRajeshGadgil",
Expand Down
4 changes: 2 additions & 2 deletions tests/gateway/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ def test_multi_user_thread_prompt(self):
prompt = build_session_context_prompt(ctx)

assert "Multi-user thread" in prompt
assert "[sender name]" in prompt
assert "[sender label]" in prompt
# Should NOT show a specific **User:** line (would bust cache)
assert "**User:** Alice" not in prompt

Expand Down Expand Up @@ -591,7 +591,7 @@ def test_shared_non_thread_group_prompt_hides_single_user(self):
prompt = build_session_context_prompt(ctx)

assert "Multi-user session" in prompt
assert "[sender name]" in prompt
assert "[sender label]" in prompt
assert "**User:** Alice" not in prompt

def test_dm_thread_shows_user_not_multi(self):
Expand Down
82 changes: 81 additions & 1 deletion tests/gateway/test_shared_group_sender_prefix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from gateway.session import SessionSource, shared_participant_label


def _make_runner(config: GatewayConfig) -> GatewayRunner:
Expand Down Expand Up @@ -43,6 +43,86 @@ async def test_preprocess_prefixes_sender_for_shared_non_thread_group_session():
assert result == "[Alice] hello"


@pytest.mark.asyncio
async def test_preprocess_uses_stable_participant_label_without_display_name():
runner = _make_runner(
GatewayConfig(
platforms={
Platform.WEBHOOK: PlatformConfig(enabled=True, token="fake"),
},
group_sessions_per_user=False,
)
)
source = SessionSource(
platform=Platform.WEBHOOK,
chat_id="room-ops",
chat_name="Ops Room",
chat_type="group",
user_id="opaque-user-1",
)
event = MessageEvent(text="hello", source=source)

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=[],
)

label = shared_participant_label(source)
assert label
assert label.startswith("user_")
assert "opaque-user-1" not in label
assert result == f"[{label}] hello"


@pytest.mark.asyncio
async def test_preprocess_sanitizes_display_name_for_shared_prefix():
runner = _make_runner(
GatewayConfig(
platforms={
Platform.WEBHOOK: PlatformConfig(enabled=True, token="fake"),
},
group_sessions_per_user=False,
)
)
source = SessionSource(
platform=Platform.WEBHOOK,
chat_id="room-ops",
chat_name="Ops Room",
chat_type="group",
user_name=" Alice\n[ops]\tlead ",
)
event = MessageEvent(text="hello", source=source)

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=[],
)

assert shared_participant_label(source) == "Alice (ops) lead"
assert result == "[Alice (ops) lead] hello"


def test_shared_participant_labels_distinguish_multiple_unnamed_senders():
first = SessionSource(
platform=Platform.WEBHOOK,
chat_id="room-ops",
chat_name="Ops Room",
chat_type="group",
user_id="human-a",
)
second = SessionSource(
platform=Platform.WEBHOOK,
chat_id="room-ops",
chat_name="Ops Room",
chat_type="group",
user_id="human-b",
)

assert shared_participant_label(first) != shared_participant_label(second)


@pytest.mark.asyncio
async def test_preprocess_keeps_plain_text_for_default_group_sessions():
runner = _make_runner(
Expand Down
35 changes: 35 additions & 0 deletions tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,41 @@ def test_write_json(capture):
assert json.loads(buf.getvalue()) == {"test": True}


def test_session_event_transport_can_fan_out_to_sidecar_listener(server):
from tui_gateway.transport import TeeTransport

class _CaptureTransport:
def __init__(self):
self.frames = []

def write(self, obj):
self.frames.append(obj)
return True

def close(self):
pass

primary = _CaptureTransport()
sidecar = _CaptureTransport()
sid = "runtime-fanout"
server._sessions[sid] = {"transport": TeeTransport(primary, sidecar)}

server._emit("message.delta", sid, {"text": "hello"})

assert primary.frames == [
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "message.delta",
"session_id": sid,
"payload": {"text": "hello"},
},
}
]
assert sidecar.frames == primary.frames


def test_write_json_broken_pipe(server):
class _Broken:
def write(self, _): raise BrokenPipeError
Expand Down
Loading