From afc7478d6ae7ba2eea5f0b1f77e99d922e080be4 Mon Sep 17 00:00:00 2001 From: Randimt Date: Mon, 1 Jun 2026 02:36:06 +0700 Subject: [PATCH] fix(gateway): allow source code/log extensions in MEDIA: delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MEDIA: extension allowlist (MEDIA_DELIVERY_EXTS, follow-up to #34517) covered docs/spreadsheets/archives/web output but not source code, build manifests, or logs. A response of 'MEDIA:/path/to/script.py' was silently dropped — extract_media() didn't match it, MEDIA_TAG_CLEANUP_RE left the tag in the body, and the file never reached the user. Same for .ts/.tsx, .go, .rs, .sh, .sol, .toml, .log, etc. — the very file types most likely to be shared in a chat workflow (code review, snippet sharing, log triage). - Extend MEDIA_DELIVERY_EXTS with source code (Python/JS/TS/shell/systems /JVM/scripting/Solidity/web), build metadata (.toml, .css), and .log. Sensitive single-purpose configs (.env, .conf, .ini) are deliberately excluded — the credential-path denylist is the runtime backstop, but keeping these out of the extension allowlist is the cheaper layer of defence against accidental delivery from non-denied locations. - Replace two duplicated extension regexes in gateway/run.py (history scan + post-run scan) with imports of _MEDIA_EXT_ALTERNATION from base.py. They were drifting from the source of truth that #34517 introduced; pulling them through the shared constant closes the gap. - Regression tests: every new extension extracts via MEDIA: tag, and .env/.conf/.ini stay out of the allowlist (anti-regression for the credential-leak guardrail). --- gateway/platforms/base.py | 18 ++++++++++- gateway/run.py | 14 ++++----- tests/gateway/test_platform_base.py | 48 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index baa25b6024efd..9974ab8911d84 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1177,7 +1177,23 @@ def _log_safe_path(path: str) -> str: # Archives ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa", # Web / rendered output - ".html", ".htm", + ".html", ".htm", ".css", + # Source code (delivered as file attachments — non-credential code review, + # snippet sharing, log triage). Sensitive config (.env, .conf, .ini) is + # intentionally excluded; the credential-path denylist in + # ``_media_delivery_denied_paths`` is a defence-in-depth backstop, but + # keeping these out of the extension allowlist also prevents accidental + # delivery from non-denied locations. + ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", + ".go", ".rs", ".sh", ".bash", ".zsh", ".fish", + ".sol", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", + ".java", ".kt", ".scala", + ".rb", ".php", ".swift", + ".lua", ".pl", ".r", + # Build / project metadata (non-credential) + ".toml", + # Logs + ".log", ) # Regex alternation fragment of bare extensions (no leading dot), e.g. diff --git a/gateway/run.py b/gateway/run.py index dbe3742ca2578..24506022030a1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17485,11 +17485,10 @@ def _clarify_callback_sync(question: str, choices) -> str: if _hm.get("role") in {"tool", "function"}: _hc = _hm.get("content", "") if "MEDIA:" in _hc: + from gateway.platforms.base import _MEDIA_EXT_ALTERNATION _TOOL_MEDIA_RE = re.compile( - r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|' - r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|' - r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|' - r'txt|csv|apk|ipa))', + r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:' + + _MEDIA_EXT_ALTERNATION + r'))', re.IGNORECASE ) for _match in _TOOL_MEDIA_RE.finditer(_hc): @@ -17811,11 +17810,10 @@ def _approval_notify_sync(approval_data: dict) -> None: if msg.get("role") in {"tool", "function"}: content = msg.get("content", "") if "MEDIA:" in content: + from gateway.platforms.base import _MEDIA_EXT_ALTERNATION _TOOL_MEDIA_RE = re.compile( - r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|' - r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|' - r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|' - r'txt|csv|apk|ipa))', + r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:' + + _MEDIA_EXT_ALTERNATION + r'))', re.IGNORECASE ) for match in _TOOL_MEDIA_RE.finditer(content): diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index c38070317a256..40ccf5d746736 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -449,6 +449,54 @@ def test_known_extension_tag_is_stripped_from_body(self): assert "Here is your report:" in stripped +class TestSourceCodeMediaDelivery: + """Source code, build manifests, and log files are deliverable as MEDIA:. + + Code review, snippet sharing, and log triage are common chat-driven + workflows; without ``.py``/``.js``/``.ts``/``.log`` etc. in the allowlist + the agent silently dropped the attachment and the user only saw the prose + around it. Sensitive single-purpose config formats (``.env``, ``.conf``, + ``.ini``) are intentionally excluded — the credential-path denylist is a + backstop, but keeping these out of the extension allowlist is the cheaper + layer of defence. + """ + + SOURCE_CODE_EXTS = [ + # Python / shell / Web + "py", "js", "ts", "tsx", "jsx", "mjs", "cjs", + "sh", "bash", "zsh", "fish", + # Systems / native + "go", "rs", "sol", "cpp", "cc", "cxx", "c", "h", "hpp", + # JVM / mobile + "java", "kt", "scala", "swift", + # Scripting + "rb", "php", "lua", "pl", "r", + # Build / metadata / web + "toml", "css", + # Logs + "log", + ] + + def test_source_code_extensions_extract_via_media_tag(self): + for ext in self.SOURCE_CODE_EXTS: + path = f"/home/u/snippet.{ext}" + media, _ = BasePlatformAdapter.extract_media(f"See MEDIA:{path}") + assert media == [(path, False)], ( + f".{ext} should extract via MEDIA: tag" + ) + + def test_sensitive_config_extensions_are_NOT_in_allowlist(self): + """``.env``/``.conf``/``.ini`` are deliberately omitted — adding them + would let a prompt-injected agent run exfiltrate credentials by + emitting ``MEDIA:~/.env`` even on hosts where the path-denylist + somehow doesn't cover the location.""" + from gateway.platforms.base import MEDIA_DELIVERY_EXTS + for ext in (".env", ".conf", ".ini"): + assert ext not in MEDIA_DELIVERY_EXTS, ( + f"{ext} must stay out of the MEDIA allowlist" + ) + + class TestMediaDeliveryPathValidation: def _patch_roots(self, monkeypatch, *roots): monkeypatch.setattr(