fix(gateway): honor privacy policy in busy message origins
Reuse the effective gateway config and shared session platform policy before hashing model-facing metadata. Preserve original routing state and cover enabled/disabled redaction across all busy injection routes.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user