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:
parent
ee710db135
commit
42d0174699
2 changed files with 42 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue