# NousResearch/hermes-agent · Gateway MEDIA: extraction attaching files from serialized tool results

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #36283. Verify it addresses your case.
- **Recommended:** read PR #36283 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #34375: Gateway MEDIA extraction can attach stale files from serialized tool/search-result text

## Prior & competing PRs

- **#36283: fix(gateway): skip MEDIA: inside serialized JSON string values (#34388)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/base.py`, `tests/gateway/test_platform_base.py`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -2579,6 +2579,46 @@ def filter_local_delivery_paths(file_paths) -> List[str]:
                     logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw))
             return safe_paths
     
    +    @staticmethod
    +    def _mask_json_string_media(content: str) -> str:
    +        """Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON
    +        string *value* so they are never delivered as real attachments.
    +
    +        Serialized tool results frequently embed a previous reply's text, e.g.::
    +
    +            {"result": "MEDIA:/Users/x/.hermes/media/generated/stale.png"}
    +
    +        Here the ``MEDIA:`` is part of stored text, not an outbound directive,
    +        but the bare-path branch of ``MEDIA_TAG_CLEANUP_RE`` would still match it
    +        and re-deliver a stale file. (Regression report #34375.)
    +
    +        The discriminator is precise so legitimate tags are untouched:
    +
    +        * Only spans opened by a JSON value-context quote (``:``, ``,``, ``{`` or
    +          ``[`` immediately before the ``"``) are considered.
    +        * Within such a span, only a ``MEDIA:`` followed by a **bare** path
    +          (``/``, ``~/`` or ``X:\\``) is masked. A ``MEDIA:"..."`` quoted-path
    +          tag — a real LLM output format the extractor supports — is not bare and
    +          is left alone.
    +        * Tags at line start, after prose whitespace, or indented are outside any
    +          JSON value span and are never affected.
    +
    +        Offsets are preserved (matched chars replaced with spaces, newlines kept)
    +        so downstream match positions stay valid.
    +        """
    +        if '"' not in content or "MEDIA:" not in content:
    +            return content
    +        chars = list(content)
    +        # JSON value-context string: a quote preceded by : , { or [ (optional ws),
    +        # capturing the (escape-aware) string body up to the closing quote.
    +        for m in re.finditer(r'(?<=[:,{\[])\s*"((?:[^"\\\n]|\\.)*)"', content):
    +            seg = m.group(1)
    +            if re.search(r'MEDIA:\s*(?:~/|/|[A-Za-z]:[/\\])', seg):
    +                for i in range(m.start(1), m.end(1)):
    +                    if chars[i] != '\n':
    +                        chars[i] = ' '
    +        return ''.join(chars)
    +
         @staticmethod
         def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
             """
    ```

    ```diff
    @@ -2621,7 +2661,10 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
             # set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
             # MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
             media_pattern = MEDIA_TAG_CLEANUP_RE
    -        for match in media_pattern.finditer(content):
    +        # Mask MEDIA: embedded inside serialized JSON string values so stale
    +        # paths from stored tool results are never re-delivered (#34375).
    +        scan_content = BasePlatformAdapter._mask_json_string_media(content)
    +        for match in media_pattern.finditer(scan_content):
                 path = match.group("path").strip()
                 if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
                     path = path[1:-1].strip()
    ```

    ```diff
    @@ -2634,10 +2677,22 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
                         # and dropping every other attachment in the response.
                         continue
     
    -        # Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
    +        # Remove the delivered MEDIA tags from the user-visible text. Mask
    +        # ``cleaned`` (same length, so offsets line up with it), find the real
    +        # tag spans there, and delete those spans from the *unmasked* ``cleaned``.
    +        # This strips real tags while leaving JSON-embedded MEDIA: text intact —
    +        # it is stored data, not a directive, and must read back verbatim
    +        # (#34375). Masking ``cleaned`` (not ``content``) keeps offsets valid
    +        # after the [[audio_as_voice]] / [[as_document]] directives are removed.
             if media:
    -            cleaned = media_pattern.sub('', cleaned)
    -            cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
    +            masked_cleaned = BasePlatformAdapter._mask_json_string_media(cleaned)
    +            spans = [m.span() for m in media_pattern.finditer(masked_cleaned)]
    +            if spans:
    +                chars = list(cleaned)
    +                for start, end in sorted(spans, reverse=True):
    +                    del chars[start:end]
    +                cleaned = "".join(chars)
    +                cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
             
             return media, cleaned
     
    ```
  - `tests/gateway/test_platform_base.py`

    ```diff
    @@ -401,6 +401,93 @@ def test_relative_path_still_ignored(self):
             assert media == []
     
     
    +class TestMediaInsideSerializedJson:
    +    """Regression coverage for #34375 — MEDIA: embedded in serialized JSON
    +    string values (e.g. a stored previous reply inside a tool result) must not
    +    be re-delivered as a real attachment, while legitimate MEDIA: tags in prose,
    +    at line start, indented, or as quoted-path tags keep working.
    +    """
    +
    +    def test_media_in_json_value_not_extracted(self):
    +        content = '{"result": "MEDIA:/tmp/stale.png"}'
    +        media, _ = BasePlatformAdapter.extract_media(content)
    +        assert media == [], f"JSON value MEDIA: leaked: {media}"
    +
    +    def test_media_in_pretty_json_value_not_extracted(self):
    +        content = '{\n  "tool_result": "MEDIA:/var/old.jpg"\n}'
    +        media, _ = BasePlatformAdapter.extract_media(content)
    +        assert media == [], f"pretty JSON MEDIA: leaked: {media}"
    +
    +    def test_media_in_json_array_not_extracted(self):
    +        content = '["MEDIA:/a/b.png", "other"]'
    +        media, _ = BasePlatformAdapter.extract_media(content)
    +        assert media == [], f"JSON array MEDIA: leaked: {media}"
    +
    +    def test_media_in_nested_json_value_not_extracted(self):
    +        content = '{"a":{"b":"see MEDIA:/x/y.pdf here"}}'
    +        media, _ = BasePlatformAdapter.extract_media(content)
    +        assert media == [], f"nested JSON MEDIA: leaked: {media}"
    +
    +    def test_media_in_embedded_serialized_reply_not_extracted(self):
    +        """A serialized tool result that embeds a prior reply's MEDIA: tag."""
    +        content = (
    +            '{"content":"previous reply MEDIA:/Users/ex/.hermes/media/'
    +            'generated/stale.png and more text"}'
    +        )
    +        media, _ = BasePlatformAdapter.extract_media(content)
    +        assert media == [], f"embedded serialized reply leaked: {media}"
    +
    +    # --- Legitimate tags must still extract (no regression vs line-start anchor) ---
    +
    +    def test_media_at_line_start_still_extracted(self):
    +        media, _ = BasePlatformAdapter.extract_media("MEDIA:/real/file.png")
    +        assert len(media) == 1 and media[0][0] == "/real/file.png"
    +
    +    def test_media_after_prose_same_line_still_extracted(self):
    +        media, _ = BasePlatformAdapter.extract_media(
    +            "Here is your file: MEDIA:/out/report.pdf"
    +        )
    +        assert len(media) == 1 and media[0][0] == "/out/report.pdf"
    +
    +    def test_media_indented_still_extracted(self):
    +        media, _ = BasePlatformAdapter.extract_media("  MEDIA:/tmp/x.png")
    +        assert len(media) == 1 and media[0][0] == "/tmp/x.png"
    +
    +    def test_quoted_path_media_still_extracted(self):
    +        """MEDIA:"..." quoted-path form (a real LLM output) is not JSON-masked."""
    +        media, _ = BasePlatformAdapter.extract_media(
    +            'MEDIA:"/path/with space/file.png"'
    +        )
    +        assert len(media) == 1 and media[0][0] == "/path/with space/file.png"
    +
    +    def test_tts_two_line_still_extracted(self):
    +        media, _ = BasePlatformAdapter.extract_media(
    +            "[[audio_as_voice]]\nMEDIA:/tmp/v.ogg"
    +        )
    +        assert len(media) == 1 and media[0][0] == "/tmp/v.ogg"
    +        assert media[0][1] is True  # voice flag
    +
    +    # --- cleaned-text invariants: real tags stripped, JSON data kept verbatim ---
    +
    +    def test_json_embedded_media_kept_verbatim_in_cleaned_text(self):
    +        """A real tag is delivered+stripped; a JSON-embedded MEDIA: stays as
    +        literal text (stored data must read back unchanged)."""
    +        content = 'MEDIA:/real/r.png\nlog: {"old":"MEDIA:/stale/s.png"}'
    +        media, cleaned = BasePlatformAdapter.extract_media(content)
    +        assert [p for p, _ in media] == ["/real/r.png"]
    +        # The JSON-embedded path must survive verbatim — not blanked to spaces.
    +        assert '{"old":"MEDIA:/stale/s.png"}' in cleaned
    +
    +    def test_cleaned_text_after_directive_not_truncated(self):
    +        """Stripping a tag preceded by a [[as_document]] directive must not
    +        shift offsets and chop the path or trailing text."""
    +        content = "See [[as_document]] MEDIA:/d/report.pdf now"
    +        media, cleaned = BasePlatformAdapter.extract_media(content)
    +        assert [p for p, _ in media] == ["/d/report.pdf"]
    +        assert "MEDIA:" not in cleaned          # real tag removed
    +        assert cleaned.endswith("now")          # trailing text intact (not chopped)
    +
    +
     class TestMediaExtensionAllowlistParity:
         """Regression coverage for issue #34517 — the MEDIA: extension black hole.
     
    ```
- **#34388: fix(gateway): require line-start boundary for MEDIA: extraction to prevent stale attachments from serialized tool results**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#36283`, `#34375`

## Fix status

- Merged to main in PR #36283 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-01`.
- Subsystem: `gateway`
