diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index af74265d9a1..660be2e92d5 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1510,11 +1510,20 @@ _MEDIA_EXT_ALTERNATION = "|".join( # consumer so both behave identically. # Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute), # ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632). +# Emphasis tolerance: models routinely wrap the tag in Markdown emphasis +# (``**MEDIA:/x.pdf**``, ``*MEDIA:/x.pdf*``, ``_MEDIA:/x.pdf_``) when they +# present a file to the user. The old single-quote anchor (``[`"']?``) and the +# closing lookahead (which lacked ``*``/``_``) failed to match such tags, so the +# file was silently never delivered and the literal ``MEDIA:`` text leaked into +# the chat. Allow a short run of emphasis/quote markers on both sides so the tag +# is recognised regardless of cosmetic Markdown. Code-block / inline-code / +# blockquote contexts are still neutralised earlier by ``_mask_protected_spans`` +# (#35695), so example tags remain non-deliverable. MEDIA_TAG_CLEANUP_RE = re.compile( - r'''[`"']?MEDIA:\s*''' + r'''[`"'*_]{0,3}MEDIA:\s*''' r'''(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|''' r'''(?:~/|/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))''' - r'''(?=[\s`"',;:)\]}\[]|$)[`"']?''', + r'''(?=[\s`"'*_,;:)\]}\[]|$)[`"'*_]{0,3}''', re.IGNORECASE, ) @@ -1529,10 +1538,10 @@ MEDIA_TAG_CLEANUP_RE = re.compile( # prompt-injection paths that do not validate are left visible instead of # silently dropped. MEDIA_EXTENSIONLESS_TAG_RE = re.compile( - r'''[`"']?MEDIA:\s*''' + r'''[`"'*_]{0,3}MEDIA:\s*''' r'''(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|''' r'''(?:~/|/|[A-Za-z]:[/\\])[^\s\n`"']+)''' - r'''[`"']?\s*''', + r'''[`"'*_]{0,3}\s*''', re.IGNORECASE, ) diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 0f1d5c9901d..7b4170f457a 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -529,6 +529,51 @@ class TestExtractMedia: assert [p for p, _ in media] == ["/r/a.png"] assert "`MEDIA:/ex/b.png`" in cleaned + # --- Markdown emphasis wrapping tolerance --- + # Models routinely present a file as **MEDIA:/path** / *MEDIA:/path* / + # _MEDIA:/path_. The old pattern only tolerated a single quote/backtick, so + # the emphasis prevented the match and the file was silently never + # delivered (the literal MEDIA: text leaked into the chat instead). + + def test_media_bold_wrapped_extracted(self): + media, cleaned = BasePlatformAdapter.extract_media( + "**MEDIA:/home/u/report.pptx**" + ) + assert media == [("/home/u/report.pptx", False)] + assert "MEDIA:" not in cleaned + + def test_media_italic_asterisk_extracted(self): + media, _ = BasePlatformAdapter.extract_media("*MEDIA:/home/u/report.pdf*") + assert media == [("/home/u/report.pdf", False)] + + def test_media_italic_underscore_extracted(self): + media, _ = BasePlatformAdapter.extract_media("_MEDIA:/home/u/report.pdf_") + assert media == [("/home/u/report.pdf", False)] + + def test_media_bold_mid_prose_extracted_and_stripped(self): + media, cleaned = BasePlatformAdapter.extract_media( + "Voici votre fichier **MEDIA:/tmp/r.pdf** bonne lecture" + ) + assert media == [("/tmp/r.pdf", False)] + assert "MEDIA:" not in cleaned + assert "bonne lecture" in cleaned + + def test_media_bold_wrapped_html_extracted(self): + # .html is a recognised extension; emphasis was the only blocker. + media, _ = BasePlatformAdapter.extract_media("**MEDIA:/srv/page.html**") + assert media == [("/srv/page.html", False)] + + def test_media_underscore_in_filename_unaffected(self): + # Emphasis tolerance must not eat a legitimate '_' inside the path. + media, _ = BasePlatformAdapter.extract_media("MEDIA:/tmp/my_report_v2.pptx") + assert media == [("/tmp/my_report_v2.pptx", False)] + + def test_media_bold_relative_path_still_ignored(self): + # The absolute-path anchor must still reject relative paths even when + # wrapped in emphasis. + media, _ = BasePlatformAdapter.extract_media("**MEDIA:report.html**") + assert media == [] + class TestMediaInsideSerializedJson: """Regression coverage for #34375 — MEDIA: embedded in serialized JSON