Gateway MEDIA: extraction attaching files from serialized tool results
Cluster 34375 · gateway. Data as of
Nothing open on GitHub. Fix merged in #36283 on 1 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #36283. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
The members concern the gateway extracting MEDIA: references from serialized tool or search-result text, which can attach stale files. Two members are pull requests that propose different approaches: skipping MEDIA: inside serialized JSON string values, and requiring a line-start boundary.
Open issues0
Open PRs0
Fixes on record
-
#36283 fix(gateway): skip MEDIA: inside serialized JSON string values (#34388)
files:
gateway/platforms/base.py,tests/gateway/test_platform_base.pyShow the diff
-
gateway/platforms/base.py@@ -2579,6 +2579,46 @@ def filter_local_delivery_paths(file_paths) -> List[str]: logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw)) return safe_paths + @staticmethod + def _mask_json_string_media(content: str) -> str: + """Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON + string *value* so they are never delivered as real attachments. + + Serialized tool results frequently embed a previous reply's text, e.g.:: + + {"result": "MEDIA:/Users/x/.hermes/media/generated/stale.png"} + + Here the ``MEDIA:`` is part of stored text, not an outbound directive, + but the bare-path branch of ``MEDIA_TAG_CLEANUP_RE`` would still match it + and re-deliver a stale file. (Regression report #34375.) + + The discriminator is precise so legitimate tags are untouched: + + * Only spans opened by a JSON value-context quote (``:``, ``,``, ``{`` or + ``[`` immediately before the ``"``) are considered. + * Within such a span, only a ``MEDIA:`` followed by a **bare** path + (``/``, ``~/`` or ``X:\\``) is masked. A ``MEDIA:"..."`` quoted-path + tag — a real LLM output format the extractor supports — is not bare and + is left alone. + * Tags at line start, after prose whitespace, or indented are outside any + JSON value span and are never affected. + + Offsets are preserved (matched chars replaced with spaces, newlines kept) + so downstream match positions stay valid. + """ + if '"' not in content or "MEDIA:" not in content: + return content + chars = list(content) + # JSON value-context string: a quote preceded by : , { or [ (optional ws), + # capturing the (escape-aware) string body up to the closing quote. + for m in re.finditer(r'(?<=[:,{\[])\s*"((?:[^"\\\n]|\\.)*)"', content): + seg = m.group(1) + if re.search(r'MEDIA:\s*(?:~/|/|[A-Za-z]:[/\\])', seg): + for i in range(m.start(1), m.end(1)): + if chars[i] != '\n': + chars[i] = ' ' + return ''.join(chars) + @staticmethod def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: """ @@ -2621,7 +2661,10 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: # set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into # MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files. media_pattern = MEDIA_TAG_CLEANUP_RE - for match in media_pattern.finditer(content): + # Mask MEDIA: embedded inside serialized JSON string values so stale + # paths from stored tool results are never re-delivered (#34375). + scan_content = BasePlatformAdapter._mask_json_string_media(content) + for match in media_pattern.finditer(scan_content): path = match.group("path").strip() if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'": path = path[1:-1].strip() @@ -2634,10 +2677,22 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: # and dropping every other attachment in the response. continue - # Remove MEDIA tags from content (including surrounding quote/backtick wrappers) + # Remove the delivered MEDIA tags from the user-visible text. Mask + # ``cleaned`` (same length, so offsets line up with it), find the real + # tag spans there, and delete those spans from the *unmasked* ``cleaned``. + # This strips real tags while leaving JSON-embedded MEDIA: text intact — + # it is stored data, not a directive, and must read back verbatim + # (#34375). Masking ``cleaned`` (not ``content``) keeps offsets valid + # after the [[audio_as_voice]] / [[as_document]] directives are removed. if media: - cleaned = media_pattern.sub('', cleaned) - cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() + masked_cleaned = BasePlatformAdapter._mask_json_string_media(cleaned) + spans = [m.span() for m in media_pattern.finditer(masked_cleaned)] + if spans: + chars = list(cleaned) + for start, end in sorted(spans, reverse=True): + del chars[start:end] + cleaned = "".join(chars) + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() return media, cleaned -
tests/gateway/test_platform_base.py@@ -401,6 +401,93 @@ def test_relative_path_still_ignored(self): assert media == [] +class TestMediaInsideSerializedJson: + """Regression coverage for #34375 — MEDIA: embedded in serialized JSON + string values (e.g. a stored previous reply inside a tool result) must not + be re-delivered as a real attachment, while legitimate MEDIA: tags in prose, + at line start, indented, or as quoted-path tags keep working. + """ + + def test_media_in_json_value_not_extracted(self): + content = '{"result": "MEDIA:/tmp/stale.png"}' + media, _ = BasePlatformAdapter.extract_media(content) + assert media == [], f"JSON value MEDIA: leaked: {media}" + + def test_media_in_pretty_json_value_not_extracted(self): + content = '{\n "tool_result": "MEDIA:/var/old.jpg"\n}' + media, _ = BasePlatformAdapter.extract_media(content) + assert media == [], f"pretty JSON MEDIA: leaked: {media}" + + def test_media_in_json_array_not_extracted(self): + content = '["MEDIA:/a/b.png", "other"]' + media, _ = BasePlatformAdapter.extract_media(content) + assert media == [], f"JSON array MEDIA: leaked: {media}" + + def test_media_in_nested_json_value_not_extracted(self): + content = '{"a":{"b":"see MEDIA:/x/y.pdf here"}}' + media, _ = BasePlatformAdapter.extract_media(content) + assert media == [], f"nested JSON MEDIA: leaked: {media}" + + def test_media_in_embedded_serialized_reply_not_extracted(self): + """A serialized tool result that embeds a prior reply's MEDIA: tag.""" + content = ( + '{"content":"previous reply MEDIA:/Users/ex/.hermes/media/' + 'generated/stale.png and more text"}' + ) + media, _ = BasePlatformAdapter.extract_media(content) + assert media == [], f"embedded serialized reply leaked: {media}" + + # --- Legitimate tags must still extract (no regression vs line-start anchor) --- + + def test_media_at_line_start_still_extracted(self): + media, _ = BasePlatformAdapter.extract_media("MEDIA:/real/file.png") + assert len(media) == 1 and media[0][0] == "/real/file.png" + + def test_media_after_prose_same_line_still_extracted(self): + media, _ = BasePlatformAdapter.extract_media( + "Here is your file: MEDIA:/out/report.pdf" + ) + assert len(media) == 1 and media[0][0] == "/out/report.pdf" + + def test_media_indented_still_extracted(self): + media, _ = BasePlatformAdapter.extract_media(" MEDIA:/tmp/x.png") + assert len(media) == 1 and media[0][0] == "/tmp/x.png" + + def test_quoted_path_media_still_extracted(self): + """MEDIA:"..." quoted-path form (a real LLM output) is not JSON-masked.""" + media, _ = BasePlatformAdapter.extract_media( + 'MEDIA:"/path/with space/file.png"' + ) + assert len(media) == 1 and media[0][0] == "/path/with space/file.png" + + def test_tts_two_line_still_extracted(self): + media, _ = BasePlatformAdapter.extract_media( + "[[audio_as_voice]]\nMEDIA:/tmp/v.ogg" + ) + assert len(media) == 1 and media[0][0] == "/tmp/v.ogg" + assert media[0][1] is True # voice flag + + # --- cleaned-text invariants: real tags stripped, JSON data kept verbatim --- + + def test_json_embedded_media_kept_verbatim_in_cleaned_text(self): + """A real tag is delivered+stripped; a JSON-embedded MEDIA: stays as + literal text (stored data must read back unchanged).""" + content = 'MEDIA:/real/r.png\nlog: {"old":"MEDIA:/stale/s.png"}' + media, cleaned = BasePlatformAdapter.extract_media(content) + assert [p for p, _ in media] == ["/real/r.png"] + # The JSON-embedded path must survive verbatim — not blanked to spaces. + assert '{"old":"MEDIA:/stale/s.png"}' in cleaned + + def test_cleaned_text_after_directive_not_truncated(self): + """Stripping a tag preceded by a [[as_document]] directive must not + shift offsets and chop the path or trailing text.""" + content = "See [[as_document]] MEDIA:/d/report.pdf now" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert [p for p, _ in media] == ["/d/report.pdf"] + assert "MEDIA:" not in cleaned # real tag removed + assert cleaned.endswith("now") # trailing text intact (not chopped) + + class TestMediaExtensionAllowlistParity: """Regression coverage for issue #34517 — the MEDIA: extension black hole.
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#34388 fix(gateway): require line-start boundary for MEDIA: extraction to prevent stale attachments from serialized tool results · superseded by another PR →
#36283,#34375
Closed issues · 1
- Closed#34375Gateway MEDIA extraction can attach stale files from serialized tool/search-result text
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #36283
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 1 Jun 2026