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:
Teknium
2026-09-07 03:09:52 -07:00
parent 97fb4756fc
commit 7d50f99fbb
4 changed files with 54 additions and 16 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -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: