{"repo":"NousResearch/hermes-agent","public_id":"29582","display_label":"MEDIA tag regex missing file extensions in gateway","summary":"Multiple bug reports and fix attempts address the gateway's MEDIA: tag extraction regex failing to recognize common file extensions such as .md, .html, .py, and others. A secondary concern is misalignment between the extension lists used by extract_media() and extract_local_files().","subsystem":"gateway","counts":{"members":37,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":10,"merged_prs":2,"attempt_prs":25},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34844},"resolution":{"state":"partially_resolved","by":"pr-34844"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T11:23:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07453453453453453},"fix_edges":[{"issue_number":24575,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":29582,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":30186,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":30518,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":31137,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":31560,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":32601,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":34321,"pr_number":34844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":34517,"pr_number":34844,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35474,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":37318,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42206,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":53249,"pr_number":65510,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":29582,"title":"[Bug]: WeChat gateway fails to send .html files due to MEDIA extension allowlist","url":null,"state":"closed"},{"kind":"issue","number":30186,"title":"MEDIA extraction regex missing .md extension after ea49b3862","url":null,"state":"closed"},{"kind":"issue","number":30518,"title":"[Bug]: Telegram failed to receive .html file.","url":null,"state":"closed"},{"kind":"issue","number":31137,"title":"fix(gateway): MEDIA: directive silently drops .html and other extensions due to regex/whitelist drift","url":null,"state":"closed"},{"kind":"issue","number":31560,"title":"Gateway MEDIA tags do not recognize markdown attachments","url":null,"state":"closed"},{"kind":"issue","number":32601,"title":"fix(weixin): MEDIA tag leaks for .md/.json/.yaml/.toml/.log files + no retry on session expiry","url":null,"state":"closed"},{"kind":"issue","number":34321,"title":"`extract_media()` regex missing `.md` and other text-based extensions, mismatch with `extract_local_files()`","url":null,"state":"closed"},{"kind":"issue","number":34517,"title":"MEDIA: tag silently drops .md (and other) files due to regex whitelist mismatch","url":null,"state":"closed"},{"kind":"issue","number":35474,"title":"[Bug]: Outbound MEDIA extraction misses .md/.markdown document not sent as file","url":null,"state":"closed"},{"kind":"issue","number":37318,"title":"MEDIA: tag fails silently for .md files — extract_media() regex whitelist missing common extensions","url":null,"state":"closed"},{"kind":"pr","number":29592,"title":"fix(gateway): allow html media attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":29609,"title":"fix(gateway): sync MEDIA regex extension allowlist with SUPPORTED_DOCUMENT_TYPES","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":29710,"title":"fix(gateway): include html in MEDIA: extraction allowlist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":29829,"title":"fix: add html? to MEDIA extension allowlist in extract_media","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29592","number":29592},{"kind":"pr","value":"#29710","number":29710},{"kind":"pr","value":"#29609","number":29609},{"kind":"pr","value":"#29609","number":29609}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":30192,"title":"fix(gateway): restore .md extension to MEDIA extraction allowlist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30193,"title":"fix(#30186): re-include .md in MEDIA extraction regex","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31138,"title":"fix(gateway): match extract_media extension list to extract_local_files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31150,"title":"fix(gateway): unify media extension list between extract_media and extract_local_files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31138","number":31138},{"kind":"pr","value":"#29609","number":29609},{"kind":"pr","value":"#22492","number":22492},{"kind":"pr","value":"#30588","number":30588}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":31159,"title":"fix(gateway): derive MEDIA extensions dynamically from SUPPORTED_DOCUMENT_TYPES","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31561,"title":"fix(gateway): recognize markdown media attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31754,"title":"fix(gateway): extract markdown media attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31561","number":31561},{"kind":"pr","value":"#31560","number":31560},{"kind":"pr","value":"#31561","number":31561}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31822,"title":"fix: add md, html, and htm to MEDIA: extractor and make extension matching case-insensitive","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32472,"title":"fix(gateway): derive MEDIA attachment extensions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32604,"title":"fix(gateway,weixin): extend MEDIA regex whitelist + add retry on session errors","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32611,"title":"fix(weixin): MEDIA tag regex now covers .md, .json, .yaml, .yml, .toml, .log","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32751,"title":"fix(gateway): accept text/config extensions in MEDIA tag regex","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32604","number":32604},{"kind":"pr","value":"#32604","number":32604}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33522,"title":"fix(gateway): sync MEDIA: directive extension whitelist with extract_local_files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29609","number":29609},{"kind":"pr","value":"#31138","number":31138},{"kind":"pr","value":"#32995","number":32995},{"kind":"pr","value":"#33089","number":33089},{"kind":"pr","value":"#31137","number":31137}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":34345,"title":"fix(gateway): unify extract_media + extract_local_files extension lists (#34321)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34517","number":34517}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34518,"title":"fix(gateway): align MEDIA: regex whitelist with extract_local_files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":34656,"title":"fix(gateway): unify MEDIA extraction onto one curated extension set","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34844","number":34844},{"kind":"pr","value":"#34517","number":34517}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34844,"title":"fix(gateway): unify MEDIA: extraction extension set + close the unknown-ext black hole (#34517)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","gateway/stream_consumer.py","scripts/release.py","tests/gateway/test_platform_base.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1131,6 +1131,75 @@ def _log_safe_path(path: str) -> str:\n }\n \n \n+# ---------------------------------------------------------------------------\n+# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH\n+#\n+# Both extractors that turn response text into native attachments derive their\n+# extension set from this tuple:\n+#   * ``extract_media()``       — explicit ``MEDIA:<path>`` tags\n+#   * ``extract_local_files()`` — bare absolute/home paths the agent mentions\n+#\n+# Historically these two carried independently-maintained extension lists.\n+# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while\n+# ``extract_local_files`` had a broad one. Combined with the unconditional\n+# ``MEDIA:\\\\s*\\\\S+`` cleanup at the dispatch sites, that mismatch created a\n+# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media\n+# match, got stripped from the body by the loose cleanup regex, and was then\n+# invisible to extract_local_files — the file was never delivered (issue\n+# #34517). Keeping one list eliminates the drift; building the cleanup regexes\n+# from the same set means a tag is only stripped when its extension is one we\n+# can actually deliver, so an unknown-extension path survives in the body\n+# instead of vanishing.\n+#\n+# Covers images (inline), video (inline where supported), audio (voice/audio),\n+# documents/spreadsheets/presentations (send_document), archives, and rendered\n+# web output. The dispatch partition (image vs video vs document) lives in\n+# ``gateway/run.py``.\n+# ---------------------------------------------------------------------------\n+\n+MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (\n+    # Images (embed inline)\n+    \".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".bmp\", \".tiff\", \".svg\",\n+    # Video (embed inline where supported)\n+    \".mp4\", \".mov\", \".avi\", \".mkv\", \".webm\",\n+    # Audio (delivered as voice/audio where supported)\n+    \".mp3\", \".wav\", \".ogg\", \".opus\", \".m4a\", \".flac\",\n+    # Documents (uploaded as file attachments)\n+    \".pdf\", \".docx\", \".doc\", \".odt\", \".rtf\", \".txt\", \".md\", \".epub\",\n+    # Spreadsheets / data\n+    \".xlsx\", \".xls\", \".ods\", \".csv\", \".tsv\", \".json\", \".xml\", \".yaml\", \".yml\",\n+    # Presentations\n+    \".pptx\", \".ppt\", \".odp\", \".key\",\n+    # Archives\n+    \".zip\", \".tar\", \".gz\", \".tgz\", \".bz2\", \".xz\", \".7z\", \".rar\", \".apk\", \".ipa\",\n+    # Web / rendered output\n+    \".html\", \".htm\",\n+)\n+\n+# Regex alternation fragment of bare extensions (no leading dot), e.g.\n+# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted\n+# longest-first so the alternation never matches a shorter ext as a prefix of\n+# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).\n+_MEDIA_EXT_ALTERNATION = \"|\".join(\n+    sorted((e.lstrip(\".\") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)\n+)\n+\n+# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose\n+# ``MEDIA:\\\\s*\\\\S+``, this only strips a tag whose path ends in a known\n+# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with\n+# an unknown extension is left in the text so it can still be picked up by the\n+# bare-path detector (extract_local_files) downstream rather than silently\n+# deleted. Shared by the non-streaming dispatch path and the streaming\n+# consumer so both behave identically.\n+MEDIA_TAG_CLEANUP_RE = re.compile(\n+    r'''[`\"']?MEDIA:\\s*'''\n+    r'''(?P<path>`[^`\\n]+`|\"[^\"\\n]+\"|'[^'\\n]+'|'''\n+    r'''(?:~/|/)\\S+(?:[^\\S\\n]+\\S+)*?\\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''\n+    r'''(?=[\\s`\"',;:)\\]}]|$)[`\"']?''',\n+    re.IGNORECASE,\n+)\n+\n+\n def get_document_cache_dir() -> Path:\n     \"\"\"Return the document cache directory, creating it if it doesn't exist.\"\"\"\n     DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)","@@ -2542,10 +2611,10 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n         cleaned = cleaned.replace(\"[[as_document]]\", \"\")\n         \n         # Extract MEDIA:<path> tags, allowing optional whitespace after the colon\n-        # and quoted/backticked paths for LLM-formatted outputs.\n-        media_pattern = re.compile(\n-            r'''[`\"']?MEDIA:\\s*(?P<path>`[^`\\n]+`|\"[^\"\\n]+\"|'[^'\\n]+'|(?:~/|/)\\S+(?:[^\\S\\n]+\\S+)*?\\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\\s`\"',;:)\\]}]|$))[`\"']?'''\n-        )\n+        # and quoted/backticked paths for LLM-formatted outputs. The extension\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             path = match.group(\"path\").strip()\n             if len(path) >= 2 and path[0] == path[-1] and path[0] in \"`\\\"'\":","@@ -2591,24 +2660,7 @@ def extract_local_files(content: str) -> Tuple[List[str], str]:\n             Tuple of (list of expanded file paths, cleaned text with the\n             raw path strings removed).\n         \"\"\"\n-        _LOCAL_MEDIA_EXTS = (\n-            # Images (embed inline)\n-            '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',\n-            # Video (embed inline where supported)\n-            '.mp4', '.mov', '.avi', '.mkv', '.webm',\n-            # Audio (delivered as voice/audio where supported)\n-            '.mp3', '.wav', '.ogg', '.m4a', '.flac',\n-            # Documents (uploaded as file attachments)\n-            '.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',\n-            # Spreadsheets / data\n-            '.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',\n-            # Presentations\n-            '.pptx', '.ppt', '.odp', '.key',\n-            # Archives\n-            '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',\n-            # Web / rendered output\n-            '.html', '.htm',\n-        )\n+        _LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS\n         ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)\n \n         # (?<![/:\\w.]) prevents matching inside URLs (e.g. https://…/img.png)","@@ -3729,7 +3781,12 @@ async def _stop_typing_task() -> None:\n                 # Strip any remaining internal directives from message body (fixes #1561)\n                 text_content = text_content.replace(\"[[audio_as_voice]]\", \"\").strip()\n                 text_content = text_content.replace(\"[[as_document]]\", \"\").strip()\n-                text_content = re.sub(r\"MEDIA:\\s*\\S+\", \"\", text_content).strip()\n+                # Strip only MEDIA: tags whose path has a deliverable extension\n+                # (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown\n+                # extension is intentionally left in the body so extract_local_files\n+                # below can still pick up the bare path — otherwise the file would\n+                # be silently dropped (issue #34517).\n+                text_content = MEDIA_TAG_CLEANUP_RE.sub(\"\", text_content).strip()\n                 if images:\n                     logger.info(\"[%s] extract_images found %d image(s) in response (%d chars)\", self.name, len(images), len(response))\n "]},{"file":"gateway/run.py","hunks":["@@ -11743,9 +11743,16 @@ async def _deliver_media_from_response(\n \n             from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio\n \n-            media_files, _ = adapter.extract_media(response)\n+            media_files, cleaned = adapter.extract_media(response)\n             media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)\n-            _, cleaned = adapter.extract_images(response)\n+            # Chain the cleaned text through each extractor (extract_media →\n+            # extract_images → extract_local_files) so MEDIA: tags and image URLs\n+            # are removed before the bare-path auto-detect runs. Previously the\n+            # cleaned text from extract_media was dropped (``_``) and\n+            # extract_local_files scanned text that still contained MEDIA: tags,\n+            # producing false-positive bare-path matches with the MEDIA: prefix\n+            # glued on. This matches the chain order in gateway/platforms/base.py.\n+            _, cleaned = adapter.extract_images(cleaned)\n             local_files, _ = adapter.extract_local_files(cleaned)\n             local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)\n "]},{"file":"gateway/stream_consumer.py","hunks":["@@ -26,6 +26,7 @@\n \n from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter\n from gateway.platforms.base import _custom_unit_to_cp\n+from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE\n from gateway.config import (\n     DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,\n     DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,","@@ -645,10 +646,13 @@ async def run(self) -> None:\n         except Exception as e:\n             logger.error(\"Stream consumer error: %s\", e)\n \n-    # Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).\n-    # Matches the simple cleanup regex used by the non-streaming path in\n-    # gateway/platforms/base.py for post-processing.\n-    _MEDIA_RE = re.compile(r'''[`\"']?MEDIA:\\s*\\S+[`\"']?''')\n+    # Strip MEDIA:<path> tags before display. Uses the shared anchored\n+    # MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose\n+    # path ends in a deliverable extension are removed, so an unknown-extension\n+    # path stays visible instead of being silently dropped (issue #34517).\n+    # Streaming and non-streaming paths share the same regex, so a tag is\n+    # treated identically whichever path delivered the text.\n+    _MEDIA_RE = MEDIA_TAG_CLEANUP_RE\n \n     @staticmethod\n     def _clean_for_display(text: str) -> str:"]},{"file":"scripts/release.py","hunks":["@@ -523,6 +523,8 @@\n     \"[redacted:email]\": \"JezzaHehn\",\n     \"[redacted:email]\": \"dodo-reach\",\n     \"[redacted:email]\": \"Bartok9\",\n+    \"[redacted:email]\": \"banditburai\",\n+    \"[redacted:email]\": \"Kyzcreig\",\n     \"[redacted:email]\": \"crayfish-ai\",\n     \"[redacted:email]\": \"MestreY0d4-Uninter\",\n     \"[redacted:email]\": \"Roy-oss1\","]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -362,6 +362,54 @@ def test_both_directives_can_coexist(self):\n         assert \"[[as_document]]\" not in cleaned\n \n \n+class TestMediaExtensionAllowlistParity:\n+    \"\"\"Regression coverage for issue #34517 — the MEDIA: extension black hole.\n+\n+    extract_media used to carry a narrow extension allowlist that omitted\n+    .md/.json/.yaml/.xml/.html etc., while extract_local_files had a broad one.\n+    Combined with an unconditional ``MEDIA:\\\\s*\\\\S+`` strip at the dispatch\n+    sites, an unmatched MEDIA: tag for one of those extensions was deleted from\n+    the body before extract_local_files could pick up the bare path — the file\n+    was silently dropped. Both extractors now derive from the single\n+    MEDIA_DELIVERY_EXTS source of truth, and the strip is anchored to that set.\n+    \"\"\"\n+\n+    DROPPED_BEFORE = [\"md\", \"json\", \"yaml\", \"yml\", \"xml\", \"html\", \"htm\",\n+                      \"tsv\", \"svg\"]\n+\n+    def test_previously_dropped_extensions_now_extract(self):\n+        for ext in self.DROPPED_BEFORE:\n+            path = f\"/tmp/report.{ext}\"\n+            media, _ = BasePlatformAdapter.extract_media(f\"Here: MEDIA:{path}\")\n+            assert media == [(path, False)], f\".{ext} should extract via MEDIA:\"\n+\n+    def test_extract_media_and_local_files_share_one_extension_set(self):\n+        from gateway.platforms.base import MEDIA_DELIVERY_EXTS\n+        # Both functions reference MEDIA_DELIVERY_EXTS; assert the documents\n+        # that motivated the bug are present in the shared set.\n+        for ext in (\".md\", \".json\", \".yaml\", \".yml\", \".xml\", \".html\", \".htm\"):\n+            assert ext in MEDIA_DELIVERY_EXTS\n+\n+    def test_unknown_extension_not_black_holed_by_cleanup(self):\n+        \"\"\"A MEDIA: tag with an unknown extension is NOT stripped from the\n+        body — it survives so extract_local_files can still see the bare path,\n+        rather than vanishing entirely (the core of issue #34517).\"\"\"\n+        from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE\n+        text = \"Saved to MEDIA:/tmp/data.weirdext done\"\n+        media, _ = BasePlatformAdapter.extract_media(text)\n+        assert media == []  # unknown extension is not a deliverable MEDIA tag\n+        stripped = MEDIA_TAG_CLEANUP_RE.sub(\"\", text)\n+        assert \"/tmp/data.weirdext\" in stripped  # path preserved, not dropped\n+\n+    def test_known_extension_tag_is_stripped_from_body(self):\n+        from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE\n+        text = \"Here is your report: MEDIA:/tmp/report.md\"\n+        stripped = MEDIA_TAG_CLEANUP_RE.sub(\"\", text).strip()\n+        assert \"MEDIA:\" not in stripped\n+        assert \"/tmp/report.md\" not in stripped\n+        assert \"Here is your report:\" in stripped\n+\n+\n class TestMediaDeliveryPathValidation:\n     def _patch_roots(self, monkeypatch, *roots):\n         monkeypatch.setattr(\n"]}]},{"kind":"pr","number":35477,"title":"fix(gateway): add .markdown to MEDIA_DELIVERY_EXTS for outbound document delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65510","number":65510}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37342,"title":"fix(media): add missing extensions to MEDIA delivery whitelist (#37318)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65510","number":65510}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37347,"title":"fix(gateway): build _TOOL_MEDIA_RE from shared MEDIA_EXT_ALTERNATION (#37318)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#37342","number":37342},{"kind":"pr","value":"#37318","number":37318},{"kind":"pr","value":"#29609","number":29609}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":37395,"title":"fix(gateway): extend MEDIA: regex whitelist to include common document/code extensions (#37318)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":60069,"title":"fix(gateway): allow .py/.markdown/.stl/.3mf media & document attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65510","number":65510}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65510,"title":"fix(gateway): deliver MEDIA: tags for every file type via validated egress","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"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":["@@ -1504,12 +1504,16 @@ def _log_safe_path(path: str) -> str:\n     re.IGNORECASE,\n )\n \n-# Extension-less absolute paths (e.g. Caddyfile, Dockerfile, Makefile) are\n-# intentionally excluded from MEDIA_TAG_CLEANUP_RE — they are validated and\n-# delivered via MEDIA_EXTENSIONLESS_TAG_RE so prompt-injection paths that do\n-# not exist on disk are left visible instead of silently dropped. Paths with\n-# an unknown but present extension (e.g. .weirdext) stay on the #34517\n-# bare-path fallback and are not handled here.\n+# Paths NOT covered by MEDIA_TAG_CLEANUP_RE's extension alternation — both\n+# extension-less files (Caddyfile, Dockerfile, Makefile) and files with an\n+# unknown extension (.py, .log, .weirdext, ...) — are validated and delivered\n+# via MEDIA_EXTENSIONLESS_TAG_RE. Every ``MEDIA:`` path is therefore\n+# deliverable regardless of file type (#36060): known extensions extract\n+# unconditionally via the anchored pattern above, everything else extracts\n+# only after ``validate_media_delivery_path`` accepts it (exists on disk, not\n+# under the credential/system denylist, strict-mode rules honored), so\n+# prompt-injection paths that do not validate are left visible instead of\n+# silently dropped.\n MEDIA_EXTENSIONLESS_TAG_RE = re.compile(\n     r'''[`\"']?MEDIA:\\s*'''\n     r'''(?P<path>`[^`\\n]+`|\"[^\"\\n]+\"|'[^'\\n]+'|'''","@@ -1527,8 +1531,16 @@ def _normalize_media_tag_path(raw: str) -> str:\n \n \n def _path_lacks_deliverable_extension(path: str) -> bool:\n-    \"\"\"True only when the basename has no extension (Caddyfile, Makefile, …).\"\"\"\n-    return not Path(path).suffix\n+    \"\"\"True when MEDIA_TAG_CLEANUP_RE's extension alternation does not cover\n+    ``path`` — either the basename has no extension at all (Caddyfile,\n+    Makefile, …) or the extension is not in MEDIA_DELIVERY_EXTS (.py, .log,\n+    .weirdext, …). Such paths route through the validated delivery pass\n+    (``validate_media_delivery_path``) instead of the unconditional one, so\n+    every file type is deliverable (#36060) while nonexistent / denylisted\n+    paths stay visible in the text.\n+    \"\"\"\n+    suffix = Path(path).suffix.lower()\n+    return not suffix or suffix not in MEDIA_DELIVERY_EXTS\n \n \n def _strip_media_tag_directives(text: str) -> str:"]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -631,12 +631,15 @@ def test_extract_media_and_local_files_share_one_extension_set(self):\n \n     def test_unknown_extension_not_black_holed_by_cleanup(self):\n         \"\"\"A MEDIA: tag with an unknown extension is NOT stripped from the\n-        body — it survives so extract_local_files can still see the bare path,\n-        rather than vanishing entirely (the core of issue #34517).\"\"\"\n+        body by the extension-anchored cleanup — and when the path does not\n+        validate (nonexistent file here), it is not delivered either, so the\n+        tag survives visibly instead of vanishing (the core of issue #34517).\n+        Validated unknown-extension paths DO deliver — see\n+        TestUniversalMediaEgress (#36060).\"\"\"\n         from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE\n         text = \"Saved to MEDIA:/tmp/data.weirdext done\"\n         media, _ = BasePlatformAdapter.extract_media(text)\n-        assert media == []  # unknown extension is not a deliverable MEDIA tag\n+        assert media == []  # nonexistent path fails validation, not delivered\n         stripped = MEDIA_TAG_CLEANUP_RE.sub(\"\", text)\n         assert \"/tmp/data.weirdext\" in stripped  # path preserved, not dropped\n ","@@ -712,6 +715,104 @@ def test_as_document_directive_stripped_without_media_tag(self):\n         )\n \n \n+class TestUniversalMediaEgress:\n+    \"\"\"#36060: every MEDIA: path is deliverable regardless of file type.\n+\n+    Known extensions extract unconditionally (MEDIA_TAG_CLEANUP_RE); unknown\n+    extensions and extension-less files extract via the validated pass —\n+    delivered when validate_media_delivery_path accepts them, left visible\n+    when it does not (nonexistent, denylisted).\n+    \"\"\"\n+\n+    def _patch_allow_root(self, monkeypatch, root):\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS\",\n+            (str(root),),\n+        )\n+        monkeypatch.delenv(\"HERMES_MEDIA_DELIVERY_STRICT\", raising=False)\n+\n+    @pytest.mark.parametrize(\"name\", [\n+        \"script.py\", \"server.log\", \"notes.weirdext\", \"app.ts\", \"run.sh\",\n+        \"config.toml\", \"styles.css\", \"contract.sol\",\n+    ])\n+    def test_unknown_extension_delivered_when_file_validates(\n+        self, tmp_path, monkeypatch, name,\n+    ):\n+        root = tmp_path / \"output\"\n+        root.mkdir()\n+        f = root / name\n+        f.write_text(\"content\", encoding=\"utf-8\")\n+        self._patch_allow_root(monkeypatch, root)\n+\n+        content = f\"Here you go:\\nMEDIA:{f}\\nDone.\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert len(media) == 1\n+        assert media[0][0] == str(f.resolve())\n+        assert \"MEDIA:\" not in cleaned\n+        assert \"Done.\" in cleaned\n+\n+    def test_unknown_extension_left_visible_when_not_on_disk(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        root = tmp_path / \"output\"\n+        root.mkdir()\n+        self._patch_allow_root(monkeypatch, root)\n+\n+        content = \"MEDIA:/nonexistent/script.py\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"MEDIA:/nonexistent/script.py\" in cleaned\n+\n+    def test_denylisted_paths_still_rejected_regardless_of_extension(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        # A denylisted path must not deliver even though .py/.log/etc now\n+        # route through the validated pass. _media_delivery_denied_paths()\n+        # reads _MEDIA_DELIVERY_DENIED_PREFIXES at call time, so patching the\n+        # tuple exercises the real denylist logic.\n+        secret_dir = [redacted:secret-assignment] / \"secrets\"\n+        secret_dir.mkdir()\n+        f = secret_dir / \"creds.py\"\n+        f.write_text(\"TOKEN = 'x'\", encoding=\"utf-8\")\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES\",\n+            (str(secret_dir),),\n+        )\n+        monkeypatch.delenv(\"HERMES_MEDIA_DELIVERY_STRICT\", raising=False)\n+\n+        content = f\"MEDIA:{f}\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == []\n+        assert \"MEDIA:\" in cleaned  # rejected tag stays visible\n+\n+    def test_strip_for_display_strips_validated_unknown_extension(\n+        self, tmp_path, monkeypatch,\n+    ):\n+        root = tmp_path / \"output\"\n+        root.mkdir()\n+        f = root / \"server.log\"\n+        f.write_text(\"x\", encoding=\"utf-8\")\n+        self._patch_allow_root(monkeypatch, root)\n+\n+        text = f\"MEDIA:{f}\"\n+        stripped = BasePlatformAdapter.strip_media_directives_for_display(text)\n+        assert \"MEDIA:\" not in stripped\n+\n+    def test_strip_for_display_keeps_unvalidated_unknown_extension(self):\n+        text = \"MEDIA:/nonexistent/server.log\"\n+        stripped = BasePlatformAdapter.strip_media_directives_for_display(text)\n+        assert \"MEDIA:/nonexistent/server.log\" in stripped\n+\n+    def test_known_extension_still_unconditional(self):\n+        # Known extensions keep the pre-#36060 behavior: extracted (and the\n+        # tag stripped) even when the file does not exist — downstream\n+        # delivery surfaces the failure.\n+        content = \"MEDIA:/nonexistent/report.pdf\"\n+        media, cleaned = BasePlatformAdapter.extract_media(content)\n+        assert media == [(\"/nonexistent/report.pdf\", False)]\n+        assert \"MEDIA:\" not in cleaned\n+\n+\n class TestMediaDeliveryPathValidation:\n     def _patch_roots(self, monkeypatch, *roots):\n         monkeypatch.setattr(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}