diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 9971399c3d96..5f6f0b2213e6 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1096,12 +1096,15 @@ def _media_delivery_denied_paths() -> List[Path]: # Bitwarden Secrets Manager plaintext disk cache. os.path.join("cache", "bws_cache.json"), ) - # Directory trees whose every child is credential material. (MCP OAuth - # tokens under mcp-tokens/ are handled by the sibling targeted PR #37222; - # session/kanban SQLite stores by #41071 — kept out of this diff to avoid - # overlap.) + # Directory trees whose every child is credential material. ``mcp-tokens/`` + # holds per-server MCP OAuth tokens; it mirrors the read guard + # (get_read_block_error) and the write guard (is_write_denied) in + # agent/file_safety.py, both of which already block the whole tree, so the + # delivery side must not trail them. (Session/kanban SQLite stores are + # handled by the sibling PR #41071 — kept out of this diff to avoid overlap.) _ROOT_CREDENTIAL_DIRS = ( "pairing", + "mcp-tokens", ) for hermes_root in (_HERMES_HOME, _HERMES_ROOT): for rel in _ROOT_CREDENTIAL_FILES: diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 60b69e000be7..7dc0173b1947 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -795,6 +795,29 @@ def test_recency_trust_denies_system_paths_even_when_fresh(self, tmp_path, monke assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None + def test_recency_trust_denies_mcp_tokens_even_when_fresh(self, tmp_path, monkeypatch): + """MCP OAuth tokens under ~/.hermes/mcp-tokens/ are rewritten in place + on refresh, so their mtime is ~now on every turn — exactly the case the + recency window would otherwise re-trust (cf. the google_token.json + re-send exploit). The credential denylist must win over recency trust. + """ + self._patch_roots(monkeypatch) + monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False) + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1") + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600") + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + mcp_tokens = hermes_dir / "mcp-tokens" + mcp_tokens.mkdir(parents=True) + token = mcp_tokens / "github.json" + token.write_text('{"access_token": "gho_***"}') # mtime = now -> "recent" + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(token)) is None + def test_recency_trust_allows_pdf_in_project_dir(self, tmp_path, monkeypatch): """The motivating case: agent produces a PDF in a project directory. @@ -1027,6 +1050,28 @@ def test_denylist_blocks_pairing_directory_contents(self, tmp_path, monkeypatch) assert BasePlatformAdapter.validate_media_delivery_path(str(token)) is None + def test_denylist_blocks_mcp_tokens_directory_contents(self, tmp_path, monkeypatch): + """Files under ~/.hermes/mcp-tokens/ hold per-server MCP OAuth tokens + and must not be deliverable. The read guard (get_read_block_error) and + the write guard (is_write_denied) in agent/file_safety.py already block + this tree; the delivery denylist mirrors them so an + ``MEDIA:~/.hermes/mcp-tokens/github.json`` tag can't exfiltrate the + token JSON to a chat platform as a native attachment. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + mcp_tokens = hermes_dir / "mcp-tokens" + mcp_tokens.mkdir(parents=True) + token = mcp_tokens / "github.json" + token.write_text('{"access_token": "gho_***", "refresh_token": "ghr_***"}') + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(token)) is None + def test_hermes_cache_still_delivers_under_denied_home(self, tmp_path, monkeypatch): """The targeted credential denylist must not break legitimate cache deliveries: a generated artifact under the allowlisted cache root is