fix(security): denylist ~/.hermes/mcp-tokens/ for media delivery

mcp-tokens/ holds live MCP OAuth access tokens (<server>.json) and
dynamically-registered OAuth client credentials (<server>.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/<server>.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/.
This commit is contained in:
briandevans 2026-06-01 22:18:03 -07:00 committed by Teknium
parent ee710db135
commit 42d0174699
2 changed files with 42 additions and 4 deletions

View file

@ -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 (<server>.json) and
# dynamically-registered client credentials (<server>.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:

View file

@ -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)