fix(share): reject dict-valued text-blocks and dict titles (re-gate edge)

The re-gate found the always-on sanitizer still had two str()-on-dict paths:
a list text-block whose 'text' is itself a dict (via /api/session/import), and
a dict-valued session title. Both are now type-checked to str; a dict title
falls back to 'Untitled'. Regression cases added. Co-authored-by: MicoRobot.
This commit is contained in:
nesquena-hermes committed 2026-07-11 02:14:07 +00:00
1 parent 75153f5b79
commit a1312acc5d
2 files changed
+34 -2

No files matched your search

+11 -2
View File
@@ -77,7 +77,12 @@ def _share_message_text(message: dict) -> str:
# never stringify them into the public snapshot.
continue
if item.get("type") == "text":
parts.append(str(item.get("text") or ""))
# Only append genuine string text — a dict-valued "text" (possible
# via /api/session/import) must NOT be str()'d into the public
# snapshot (that would publish structured/tool payload verbatim).
_t = item.get("text")
if isinstance(_t, str):
parts.append(_t)
return "".join(parts).strip()
if isinstance(content, str):
return content.strip()
@@ -176,7 +181,11 @@ def build_share_snapshot(session) -> dict:
safe_messages.append(sanitized)
if not safe_messages:
raise ValueError("This conversation has no shareable messages yet.")
title = _force_redact_credentials(str(safe_session.get("title") or "Untitled"))
# Only accept a genuine string title — a dict-valued title (possible via
# /api/session/import) must not be str()'d into the public snapshot.
_raw_title = safe_session.get("title")
_raw_title = _raw_title if isinstance(_raw_title, str) else "Untitled"
title = _force_redact_credentials(_raw_title or "Untitled")
title = _redact_share_paths(title, redact_paths) or "Untitled"
return {
"title": title,
+23
View File
@@ -105,6 +105,8 @@ def test_share_snapshot_redaction_is_always_on_regardless_of_setting():
{"role": "system", "content": "internal system prompt stays private"},
{"role": "user", "content": "key sk-SECRET1234567890abcdef path /very/private/workspace/x.py"},
{"role": "assistant", "content": {"tool": "terminal", "output": "raw structured tool payload"}},
# dict-valued "text" inside a text block (possible via /api/session/import)
{"role": "assistant", "content": [{"type": "text", "text": {"tool": "terminal", "output": "STRUCTURED_SECRET_IN_TEXTBLOCK"}}]},
{"role": "tool", "content": "raw tool output"},
{"role": "assistant", "content": "Here is the answer."},
]
@@ -118,10 +120,31 @@ def test_share_snapshot_redaction_is_always_on_regardless_of_setting():
assert "/very/private/workspace" not in blob
# structured/dict content never stringified into the public payload
assert "raw structured tool payload" not in blob
assert "STRUCTURED_SECRET_IN_TEXTBLOCK" not in blob
# system + tool roles excluded
roles = [m["role"] for m in snap["messages"]]
assert "system" not in roles and "tool" not in roles
# the real answer is preserved
assert any("Here is the answer" in m["content"] for m in snap["messages"])
finally:
config.load_settings = _orig
def test_share_snapshot_rejects_dict_valued_title():
# A dict-valued title (possible via /api/session/import) must not be
# stringified into the public snapshot — fall back to "Untitled".
import os, tempfile
os.environ.setdefault("HERMES_WEBUI_STATE_DIR", tempfile.mkdtemp())
import api.shares as shares
class _S:
pass
s = _S()
s.title = {"leak": "STRUCTURED_TITLE_SECRET"}
s.workspace = ""
s.messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
snap = shares.build_share_snapshot(s)
import json
assert "STRUCTURED_TITLE_SECRET" not in json.dumps(snap)
assert snap["title"] == "Untitled"