{"repo":"NousResearch/hermes-agent","public_id":"34375","display_label":"Gateway MEDIA: extraction attaching files from serialized tool results","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":36283,"merged_at":"2026-06-01T06:51:42+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-01T06:51:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34375,"pr_number":36283,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-01T06:51:42+00:00"}],"members":[{"kind":"issue","number":34375,"title":"Gateway MEDIA extraction can attach stale files from serialized tool/search-result text","url":null,"state":"closed"},{"kind":"pr","number":34388,"title":"fix(gateway): require line-start boundary for MEDIA: extraction to prevent stale attachments from serialized tool results","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36283","number":36283},{"kind":"pr","value":"#34375","number":34375}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":36283,"title":"fix(gateway): skip MEDIA: inside serialized JSON string values (#34388)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/gateway/test_platform_base.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -2579,6 +2579,46 @@ def filter_local_delivery_paths(file_paths) -> List[str]:\n                 logger.warning(\"Skipping unsafe local file path: %s\", _log_safe_path(raw))\n         return safe_paths\n \n+    @staticmethod\n+    def _mask_json_string_media(content: str) -> str:\n+        \"\"\"Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON\n+        string *value* so they are never delivered as real attachments.\n+\n+        Serialized tool results frequently embed a previous reply's text, e.g.::\n+\n+            {\"result\": \"MEDIA:/Users/x/.hermes/media/generated/stale.png\"}\n+\n+        Here the ``MEDIA:`` is part of stored text, not an outbound directive,\n+        but the bare-path branch of ``MEDIA_TAG_CLEANUP_RE`` would still match it\n+        and re-deliver a stale file. (Regression report #34375.)\n+\n+        The discriminator is precise so legitimate tags are untouched:\n+\n+        * Only spans opened by a JSON value-context quote (``:``, ``,``, ``{`` or\n+          ``[`` immediately before the ``\"``) are considered.\n+        * Within such a span, only a ``MEDIA:`` followed by a **bare** path\n+          (``/``, ``~/`` or ``X:\\\\``) is masked. A ``MEDIA:\"...\"`` quoted-path\n+          tag — a real LLM output format the extractor supports — is not bare and\n+          is left alone.\n+        * Tags at line start, after prose whitespace, or indented are outside any\n+          JSON value span and are never affected.\n+\n+        Offsets are preserved (matched chars replaced with spaces, newlines kept)\n+        so downstream match positions stay valid.\n+        \"\"\"\n+        if '\"' not in content or \"MEDIA:\" not in content:\n+            return content\n+        chars = list(content)\n+        # JSON value-context string: a quote preceded by : , { or [ (optional ws),\n+        # capturing the (escape-aware) string body up to the closing quote.\n+        for m in re.finditer(r'(?<=[:,{\\[])\\s*\"((?:[^\"\\\\\\n]|\\\\.)*)\"', content):\n+            seg = m.group(1)\n+            if re.search(r'MEDIA:\\s*(?:~/|/|[A-Za-z]:[/\\\\])', seg):\n+                for i in range(m.start(1), m.end(1)):\n+                    if chars[i] != '\\n':\n+                        chars[i] = ' '\n+        return ''.join(chars)\n+\n     @staticmethod\n     def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n         \"\"\"","@@ -2621,7 +2661,10 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n         # set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into\n         # MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.\n         media_pattern = MEDIA_TAG_CLEANUP_RE\n-        for match in media_pattern.finditer(content):\n+        # Mask MEDIA: embedded inside serialized JSON string values so stale\n+        # paths from stored tool results are never re-delivered (#34375).\n+        scan_content = BasePlatformAdapter._mask_json_string_media(content)\n+        for match in media_pattern.finditer(scan_content):\n             path = match.group(\"path\").strip()\n             if len(path) >= 2 and path[0] == path[-1] and path[0] in \"`\\\"'\":\n                 path = path[1:-1].strip()","@@ -2634,10 +2677,22 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n                     # and dropping every other attachment in the response.\n                     continue\n \n-        # Remove MEDIA tags from content (including surrounding quote/backtick wrappers)\n+        # Remove the delivered MEDIA tags from the user-visible text. Mask\n+        # ``cleaned`` (same length, so offsets line up with it), find the real\n+        # tag spans there, and delete those spans from the *unmasked* ``cleaned``.\n+        # This strips real tags while leaving JSON-embedded MEDIA: text intact —\n+        # it is stored data, not a directive, and must read back verbatim\n+        # (#34375). Masking ``cleaned`` (not ``content``) keeps offsets valid\n+        # after the [[audio_as_voice]] / [[as_document]] directives are removed.\n         if media:\n-            cleaned = media_pattern.sub('', cleaned)\n-            cleaned = re.sub(r'\\n{3,}', '\\n\\n', cleaned).strip()\n+            masked_cleaned = BasePlatformAdapter._mask_json_string_media(cleaned)\n+            spans = [m.span() for m in media_pattern.finditer(masked_cleaned)]\n+            if spans:\n+                chars = list(cleaned)\n+                for start, end in sorted(spans, reverse=True):\n+                    del chars[start:end]\n+                cleaned = \"\".join(chars)\n+                cleaned = re.sub(r'\\n{3,}', '\\n\\n', cleaned).strip()\n         \n         return media, cleaned\n "]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -401,6 +401,93 @@ def test_relative_path_still_ignored(self):\n         assert media == []\n \n \n+class TestMediaInsideSerializedJson:\n+    \"\"\"Regression coverage for #34375 — MEDIA: embedded in serialized JSON\n+    string values (e.g. a stored previous reply inside a tool result) must not\n+    be re-delivered as a real attachment, while legitimate MEDIA: tags in prose,\n+    at line start, indented, or as quoted-path tags keep working.\n+    \"\"\"\n+\n+    def test_media_in_json_value_not_extracted(self):\n+        content = '{\"result\": \"MEDIA:/tmp/stale.png\"}'\n+        media, _ = BasePlatformAdapter.extract_media(content)\n+        assert media == [], f\"JSON value MEDIA: leaked: {media}\"\n+\n+    def test_media_in_pretty_json_value_not_extracted(self):\n+        content = '{\\n  \"tool_result\": \"MEDIA:/var/old.jpg\"\\n}'\n+        media, _ = BasePlatformAdapter.extract_media(content)\n+        assert media == [], f\"pretty JSON MEDIA: leaked: {media}\"\n+\n+    def test_media_in_json_array_not_extracted(self):\n+        content = '[\"MEDIA:/a/b.png\", \"other\"]'\n+        media, _ = BasePlatformAdapter.extract_media(content)\n+        assert media == [], f\"JSON array MEDIA: leaked: {media}\"\n+\n+    def test_media_in_nested_json_value_not_extracted(self):\n+        content = '{\"a\":{\"b\":\"see MEDIA:/x/y.pdf here\"}}'\n+        media, _ = BasePlatformAdapter.extract_media(content)\n+        assert media == [], f\"nested JSON MEDIA: leaked: {media}\"\n+\n+    def test_media_in_embedded_serialized_reply_not_extracted(self):\n+        \"\"\"A serialized tool result that embeds a prior reply's MEDIA: tag.\"\"\"\n+        content = (\n+            '{\"content\":\"previous reply MEDIA:/Users/ex/.hermes/media/'\n+            'generated/stale.png and more text\"}'\n+        )\n+        media, _ = BasePlatformAdapter.extract_media(content)\n+        assert media == [], f\"embedded serialized reply leaked: {media}\"\n+\n+    # --- Legitimate tags must still extract (no regression vs line-start anchor) ---\n+\n+    def test_media_at_line_start_still_extracted(self):\n+        media, _ = BasePlatformAdapter.extract_media(\"MEDIA:/real/file.png\")\n+        assert len(media) == 1 and media[0][0] == \"/real/file.png\"\n+\n+    def test_media_after_prose_same_line_still_extracted(self):\n+        media, _ = BasePlatformAdapter.extract_media(\n+            \"Here is your file: MEDIA:/out/report.pdf\"\n+        )\n+        assert len(media) == 1 and media[0][0] == \"/out/report.pdf\"\n+\n+    def test_media_indented_still_extracted(self):\n+        media, _ = BasePlatformAdapter.extract_media(\"  MEDIA:/tmp/x.png\")\n+        assert len(media) == 1 and media[0][0] == \"/tmp/x.png\"\n+\n+    def test_quoted_path_media_still_extracted(self):\n+        \"\"\"MEDIA:\"...\" quoted-path form (a real LLM output) is not JSON-masked.\"\"\"\n+        media, _ = BasePlatformAdapter.extract_media(\n+            'MEDIA:\"/path/with space/file.png\"'\n+        )\n+        assert len(media) == 1 and media[0][0] == \"/path/with space/file.png\"\n+\n+    def test_tts_two_line_still_extracted(self):\n+        media, _ = BasePlatformAdapter.extract_media(\n+            \"[[audio_as_voice]]\\nMEDIA:/tmp/v.ogg\"\n+        )\n+        assert len(media) == 1 and media[0][0] == \"/tmp/v.ogg\"\n+        assert media[0][1] is True  # voice flag\n+\n+    # --- cleaned-text invariants: real tags stripped, JSON data kept verbatim ---\n+\n+    def test_json_embedded_media_kept_verbatim_in_cleaned_text(self):\n+        \"\"\"A real tag is delivered+stripped; a JSON-embedded MEDIA: stays as\n+        literal text (stored data must read back unchanged).\"\"\"\n+        content = 'MEDIA:/real/r.png\\nlog: {\"old\":\"MEDIA:/stale/s.png\"}'\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert [p for p, _ in media] == [\"/real/r.png\"]\n+        # The JSON-embedded path must survive verbatim — not blanked to spaces.\n+        assert '{\"old\":\"MEDIA:/stale/s.png\"}' in cleaned\n+\n+    def test_cleaned_text_after_directive_not_truncated(self):\n+        \"\"\"Stripping a tag preceded by a [[as_document]] directive must not\n+        shift offsets and chop the path or trailing text.\"\"\"\n+        content = \"See [[as_document]] MEDIA:/d/report.pdf now\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert [p for p, _ in media] == [\"/d/report.pdf\"]\n+        assert \"MEDIA:\" not in cleaned          # real tag removed\n+        assert cleaned.endswith(\"now\")          # trailing text intact (not chopped)\n+\n+\n class TestMediaExtensionAllowlistParity:\n     \"\"\"Regression coverage for issue #34517 — the MEDIA: extension black hole.\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}