diff --git a/gateway/run_busy.py b/gateway/run_busy.py index 54d613efe0..d9d0440b7e 100644 --- a/gateway/run_busy.py +++ b/gateway/run_busy.py @@ -353,6 +353,19 @@ class GatewayBusySessionMixin: "source_message_id": source.message_id, } origin = {key: value for key, value in origin.items() if value not in (None, "")} + from gateway.run import _load_gateway_config + from gateway.session import _hash_chat_id, _hash_id, _hash_sender_id, _should_redact_pii + + redact_pii = bool((_load_gateway_config().get("privacy") or {}).get("redact_pii", False)) + if _should_redact_pii(source.platform, redact_pii): + # Only the model-facing copy changes; event/source remain valid routing state. + hashers = { + "user_id": _hash_sender_id, "user_id_alt": _hash_sender_id, + "chat_id": _hash_chat_id, "chat_id_alt": _hash_chat_id, + "parent_chat_id": _hash_chat_id, + } + origin = {key: (value if key in ("platform", "chat_type") else + hashers.get(key, _hash_id)(value)) for key, value in origin.items()} # JSON preserves identifiers exactly (including colons/whitespace) instead of # normalizing them into another destination. Escape marker delimiters too. encoded = json.dumps(origin, ensure_ascii=True).replace("[", "\\u005b").replace("]", "\\u005d") diff --git a/gateway/session.py b/gateway/session.py index 17f3587122..d102e59388 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -189,6 +189,18 @@ _PII_SAFE_PLATFORMS = frozenset({ }) +def _should_redact_pii(platform: Platform, enabled: bool) -> bool: + """Keep model-visible identifiers usable on platforms requiring raw mentions.""" + if not enabled or platform in _PII_SAFE_PLATFORMS: + return enabled + try: + from gateway.platform_registry import platform_registry + entry = platform_registry.get(platform.value) + return bool(entry and entry.pii_safe) + except Exception: + return False + + def _slack_tools_loaded() -> bool: """True iff the agent will actually have Slack tools this session. @@ -357,13 +369,7 @@ def build_session_context_prompt(context: SessionContext, *, redact_pii: bool = user/chat IDs become deterministic hashes for the LLM only; routing keeps the originals. """ src = context.source - if redact_pii and src.platform not in _PII_SAFE_PLATFORMS: - try: - from gateway.platform_registry import platform_registry - entry = platform_registry.get(src.platform.value) - redact_pii = bool(entry and entry.pii_safe) - except Exception: - redact_pii = False + redact_pii = _should_redact_pii(src.platform, redact_pii) def _chat_label(chat_id: str) -> str: return _hash_chat_id(chat_id) if redact_pii else chat_id diff --git a/tests/gateway/test_busy_steer_origin.py b/tests/gateway/test_busy_steer_origin.py index 8705fe6ff4..403e92396c 100644 --- a/tests/gateway/test_busy_steer_origin.py +++ b/tests/gateway/test_busy_steer_origin.py @@ -12,14 +12,24 @@ from gateway.session import SessionSource @pytest.mark.asyncio @pytest.mark.parametrize("route", ["explicit", "priority", "normal", "redirect", "priority_redirect"]) -async def test_busy_injection_preserves_original_routing_fields(route): +@pytest.mark.parametrize("platform", [Platform.TELEGRAM, Platform.SIGNAL, Platform.WHATSAPP, Platform.DISCORD]) +@pytest.mark.parametrize("redact_pii", [False, True]) +async def test_busy_injection_preserves_original_routing_fields(route, platform, redact_pii, tmp_path, monkeypatch): + from dataclasses import asdict + from gateway.session import _hash_chat_id, _hash_id, _hash_sender_id + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + f"privacy:\n redact_pii: {str(redact_pii).lower()}\n", encoding="utf-8", + ) runner = GatewayRunner(config=GatewayConfig()) source = SessionSource( - platform=Platform.TELEGRAM, chat_id="chat", thread_id="thread", user_id="user", + platform=platform, chat_id="+15551230001", thread_id="thread", user_id="+15551230002", chat_type="group", scope_id="scope", profile="profile", parent_chat_id="parent", chat_id_alt="chat-alt", user_id_alt="user-alt", prospective_thread_id="future-thread", message_id="source-message", ) + original_source = asdict(source) event = MessageEvent(text="/steer request" if route == "explicit" else "request", source=source, message_id="message") class Receiver: @@ -44,12 +54,21 @@ async def test_busy_injection_preserves_original_routing_fields(route): await runner._resolve_busy_steer_or_redirect(event, "key", "interrupt" if route == "redirect" else "steer", receiver) assert receiver.payload.endswith("\n\nrequest") origin = json.loads(receiver.payload.splitlines()[1]) - assert {key: origin[key] for key in ("platform", "chat_id", "thread_id", "user_id", "message_id")} == { - "platform": "telegram", "chat_id": "chat", "thread_id": "thread", "user_id": "user", "message_id": "message", - } - for field in ("chat_type", "scope_id", "profile", "parent_chat_id", "chat_id_alt", "user_id_alt", "prospective_thread_id"): - assert origin[field] == getattr(source, field) - assert origin["source_message_id"] == source.message_id + expected = {key: original_source[key] for key in ( + "chat_id", "thread_id", "user_id", "chat_type", "scope_id", "profile", + "parent_chat_id", "chat_id_alt", "user_id_alt", "prospective_thread_id", + )} + expected.update(platform=platform.value, message_id=event.message_id, source_message_id=source.message_id) + if redact_pii and platform != Platform.DISCORD: + for key, value in expected.items(): + if key in ("platform", "chat_type"): + continue + hasher = (_hash_sender_id if key in ("user_id", "user_id_alt") else + _hash_chat_id if key in ("chat_id", "chat_id_alt", "parent_chat_id") else _hash_id) + expected[key] = hasher(value) + assert origin[key] != value + assert origin == expected + assert asdict(source) == original_source assert event.text == ("/steer request" if route == "explicit" else "request") diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 4cfe6f58ea..b792b962aa 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -400,7 +400,7 @@ By default, messaging a busy agent redirects its active turn (a running foregrou - `queue` — follow-up messages wait and run as the next turn after the current task finishes. - `steer` — follow-up messages are injected into the current run via `/steer`, arriving at the agent after the next tool call. No interrupt, no new turn. Falls back to `queue` behavior if the agent hasn't started yet. -Gateway steers (including explicit `/steer`) and active-turn redirects carry the requesting event's available platform, chat, thread, sender, message, profile, and scope identifiers as per-message JSON context. This preserves the original identifiers without changing the session's system prompt or choosing a fallback reply destination. The context is routing data, not authorization or a guarantee of automatic delivery. +Gateway steers (including explicit `/steer`) and active-turn redirects carry the requesting event's available platform, chat, thread, sender, message, profile, and scope identifiers as per-message JSON context. With `privacy.redact_pii: true`, identifiers in this model-visible context are hashed on supported platforms, including alternate and parent identifiers; the original event identifiers remain internal for routing. Otherwise identifiers are preserved exactly. Neither mode changes the session's system prompt or chooses a fallback reply destination. The context is routing data, not authorization or a guarantee of automatic delivery. ```yaml display: