From 42d017469996dfea8e4526b60e878ff6feb52ce5 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:18:03 -0700 Subject: [PATCH] fix(security): denylist ~/.hermes/mcp-tokens/ for media delivery mcp-tokens/ holds live MCP OAuth access tokens (.json) and dynamically-registered OAuth client credentials (.client.json), layout per tools/mcp_oauth.py. This is the same credential class as auth.json/credentials/, which _media_delivery_denied_paths() already blocks. The write side already denies this dir (file_tools _check_sensitive_path), but the media-delivery (read/exfil) side did not, leaving an unpaired half-door. Without it, a prompt-injection MEDIA: tag emitting ~/.hermes/mcp-tokens/.json would, in default (non-strict) mode, pass the denylist and exfiltrate a live OAuth bearer token to the same untrusted channel. Sibling follow-up to commit 4ec0adebe (config.yaml media-delivery denylist). mcp-tokens is a directory and _path_under_denied_prefix already does containment matching, so the whole subtree (.json/.client.json/ .meta.json) is denied, mirroring credentials/. --- gateway/platforms/base.py | 14 +++++++++---- tests/gateway/test_platform_base.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index e323f618e..1efb7630e 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1159,12 +1159,18 @@ 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 live MCP OAuth access tokens (.json) and + # dynamically-registered client credentials (.client.json); see + # tools/mcp_oauth.py. Same credential class as auth.json/credentials/. + # The write side already denies it (file_tools _check_sensitive_path); + # this pairs the media-delivery (exfil) side so a prompt-injection MEDIA + # tag can't deliver a live bearer token as a native attachment. + # (session/kanban SQLite stores are handled by #41071 — kept out here.) _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 47d1286ad..133f50ee3 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -991,6 +991,38 @@ class TestMediaDeliveryDefaultMode: assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None + @pytest.mark.parametrize( + "rel", + [ + "mcp-tokens/github.json", + "mcp-tokens/github.client.json", + "mcp-tokens/github.meta.json", + ], + ) + def test_denylist_blocks_mcp_oauth_tokens(self, tmp_path, monkeypatch, rel): + """Live MCP OAuth tokens/client creds under ~/.hermes/mcp-tokens/ must + never deliver as native media — same exfil class as auth.json/.env. + Sibling to the pairing/ directory denylist entry. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + (hermes_dir / "mcp-tokens").mkdir(parents=True) + secret = hermes_dir / rel + secret.write_text('{"access_token": "live-bearer-abc123"}') + 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(secret)) is None + def test_denylist_blocks_hermes_config_in_active_profile(self, tmp_path, monkeypatch): """The active profile config stays blocked in default mode.""" self._patch_roots(monkeypatch)