diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d3c935733e6b..f899f49b93da 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1504,12 +1504,16 @@ MEDIA_TAG_CLEANUP_RE = re.compile( re.IGNORECASE, ) -# Extension-less absolute paths (e.g. Caddyfile, Dockerfile, Makefile) are -# intentionally excluded from MEDIA_TAG_CLEANUP_RE — they are validated and -# delivered via MEDIA_EXTENSIONLESS_TAG_RE so prompt-injection paths that do -# not exist on disk are left visible instead of silently dropped. Paths with -# an unknown but present extension (e.g. .weirdext) stay on the #34517 -# bare-path fallback and are not handled here. +# Paths NOT covered by MEDIA_TAG_CLEANUP_RE's extension alternation — both +# extension-less files (Caddyfile, Dockerfile, Makefile) and files with an +# unknown extension (.py, .log, .weirdext, ...) — are validated and delivered +# via MEDIA_EXTENSIONLESS_TAG_RE. Every ``MEDIA:`` path is therefore +# deliverable regardless of file type (#36060): known extensions extract +# unconditionally via the anchored pattern above, everything else extracts +# only after ``validate_media_delivery_path`` accepts it (exists on disk, not +# under the credential/system denylist, strict-mode rules honored), so +# prompt-injection paths that do not validate are left visible instead of +# silently dropped. MEDIA_EXTENSIONLESS_TAG_RE = re.compile( r'''[`"']?MEDIA:\s*''' r'''(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|''' @@ -1527,8 +1531,16 @@ def _normalize_media_tag_path(raw: str) -> str: def _path_lacks_deliverable_extension(path: str) -> bool: - """True only when the basename has no extension (Caddyfile, Makefile, …).""" - return not Path(path).suffix + """True when MEDIA_TAG_CLEANUP_RE's extension alternation does not cover + ``path`` — either the basename has no extension at all (Caddyfile, + Makefile, …) or the extension is not in MEDIA_DELIVERY_EXTS (.py, .log, + .weirdext, …). Such paths route through the validated delivery pass + (``validate_media_delivery_path``) instead of the unconditional one, so + every file type is deliverable (#36060) while nonexistent / denylisted + paths stay visible in the text. + """ + suffix = Path(path).suffix.lower() + return not suffix or suffix not in MEDIA_DELIVERY_EXTS def _strip_media_tag_directives(text: str) -> str: diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 42e750403597..95b62512c26a 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -631,12 +631,15 @@ class TestMediaExtensionAllowlistParity: def test_unknown_extension_not_black_holed_by_cleanup(self): """A MEDIA: tag with an unknown extension is NOT stripped from the - body — it survives so extract_local_files can still see the bare path, - rather than vanishing entirely (the core of issue #34517).""" + body by the extension-anchored cleanup — and when the path does not + validate (nonexistent file here), it is not delivered either, so the + tag survives visibly instead of vanishing (the core of issue #34517). + Validated unknown-extension paths DO deliver — see + TestUniversalMediaEgress (#36060).""" from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE text = "Saved to MEDIA:/tmp/data.weirdext done" media, _ = BasePlatformAdapter.extract_media(text) - assert media == [] # unknown extension is not a deliverable MEDIA tag + assert media == [] # nonexistent path fails validation, not delivered stripped = MEDIA_TAG_CLEANUP_RE.sub("", text) assert "/tmp/data.weirdext" in stripped # path preserved, not dropped @@ -712,6 +715,104 @@ class TestExtensionlessMediaDelivery: ) +class TestUniversalMediaEgress: + """#36060: every MEDIA: path is deliverable regardless of file type. + + Known extensions extract unconditionally (MEDIA_TAG_CLEANUP_RE); unknown + extensions and extension-less files extract via the validated pass — + delivered when validate_media_delivery_path accepts them, left visible + when it does not (nonexistent, denylisted). + """ + + def _patch_allow_root(self, monkeypatch, root): + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (str(root),), + ) + monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False) + + @pytest.mark.parametrize("name", [ + "script.py", "server.log", "notes.weirdext", "app.ts", "run.sh", + "config.toml", "styles.css", "contract.sol", + ]) + def test_unknown_extension_delivered_when_file_validates( + self, tmp_path, monkeypatch, name, + ): + root = tmp_path / "output" + root.mkdir() + f = root / name + f.write_text("content", encoding="utf-8") + self._patch_allow_root(monkeypatch, root) + + content = f"Here you go:\nMEDIA:{f}\nDone." + media, cleaned = BasePlatformAdapter.extract_media(content) + assert len(media) == 1 + assert media[0][0] == str(f.resolve()) + assert "MEDIA:" not in cleaned + assert "Done." in cleaned + + def test_unknown_extension_left_visible_when_not_on_disk( + self, tmp_path, monkeypatch, + ): + root = tmp_path / "output" + root.mkdir() + self._patch_allow_root(monkeypatch, root) + + content = "MEDIA:/nonexistent/script.py" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [] + assert "MEDIA:/nonexistent/script.py" in cleaned + + def test_denylisted_paths_still_rejected_regardless_of_extension( + self, tmp_path, monkeypatch, + ): + # A denylisted path must not deliver even though .py/.log/etc now + # route through the validated pass. _media_delivery_denied_paths() + # reads _MEDIA_DELIVERY_DENIED_PREFIXES at call time, so patching the + # tuple exercises the real denylist logic. + secret_dir = tmp_path / "secrets" + secret_dir.mkdir() + f = secret_dir / "creds.py" + f.write_text("TOKEN = 'x'", encoding="utf-8") + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(secret_dir),), + ) + monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False) + + content = f"MEDIA:{f}" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [] + assert "MEDIA:" in cleaned # rejected tag stays visible + + def test_strip_for_display_strips_validated_unknown_extension( + self, tmp_path, monkeypatch, + ): + root = tmp_path / "output" + root.mkdir() + f = root / "server.log" + f.write_text("x", encoding="utf-8") + self._patch_allow_root(monkeypatch, root) + + text = f"MEDIA:{f}" + stripped = BasePlatformAdapter.strip_media_directives_for_display(text) + assert "MEDIA:" not in stripped + + def test_strip_for_display_keeps_unvalidated_unknown_extension(self): + text = "MEDIA:/nonexistent/server.log" + stripped = BasePlatformAdapter.strip_media_directives_for_display(text) + assert "MEDIA:/nonexistent/server.log" in stripped + + def test_known_extension_still_unconditional(self): + # Known extensions keep the pre-#36060 behavior: extracted (and the + # tag stripped) even when the file does not exist — downstream + # delivery surfaces the failure. + content = "MEDIA:/nonexistent/report.pdf" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [("/nonexistent/report.pdf", False)] + assert "MEDIA:" not in cleaned + + class TestMediaDeliveryPathValidation: def _patch_roots(self, monkeypatch, *roots): monkeypatch.setattr(