fix(security): deny root-level credential stores in media delivery

The media-delivery denylist in gateway/platforms/base.py enumerated only
.env/auth.json/credentials/config.yaml under HERMES_HOME, so other
credential stores that live at the root fell through and could be
auto-attached to chat replies. The reported case: the Google Workspace
skill's google_token.json refreshes every turn, bumping its mtime to
'now', which kept passing the strict-mode recency window and re-sent the
OAuth token on every reply.

Extend the explicit per-file denylist to mirror the canonical credential
set already enforced by the read/write guards in agent/file_safety.py:
google_token.json, google_oauth_pending.json, auth/google_oauth.json,
.anthropic_oauth.json, webhook_subscriptions.json, cache/bws_cache.json,
auth.lock, and the pairing/ token directory.

Targeted per-file additions (not a blanket ~/.hermes deny, which was
declined in #32090/#34425 because it would block skills/, logs/, and
ad-hoc agent-written deliverables). mcp-tokens/ (#37222) and
state.db/kanban.db (#41071) are left to their sibling targeted PRs.

Reported-by: xxxigm (#50912)
This commit is contained in:
kshitijk4poor 2026-06-23 02:51:00 +05:30 committed by kshitij
parent e9b86f352f
commit 100e7be20e
2 changed files with 146 additions and 8 deletions

View file

@ -1066,12 +1066,48 @@ def _media_delivery_denied_paths() -> List[Path]:
denied.append(home / sub)
# The active Hermes profile and shared Hermes root both contain control
# files and credentials. Only cache subdirectories under them are
# explicitly allowlisted above.
# explicitly allowlisted above (matched BEFORE this denylist in
# validate_media_delivery_path, so generated media still delivers).
#
# These are the per-file credential / secret stores that live at the
# HERMES_HOME root. The set mirrors the canonical read guard in
# agent/file_safety.py (get_read_block_error / build_write_denied_*) so the
# delivery (read/exfil) side can't trail the write side: a credential the
# agent is forbidden to write or read must also never be auto-attached to a
# chat reply. Enumerated explicitly per-file rather than denying the whole
# tree, so skills/, logs/, and ad-hoc agent-written files under ~/.hermes
# stay deliverable (see #32090, #34425).
_ROOT_CREDENTIAL_FILES = (
".env",
"auth.json",
"auth.lock",
"credentials",
"config.yaml",
# Anthropic PKCE / OAuth refresh credential store.
".anthropic_oauth.json",
# Google Workspace skill: auto-refreshing OAuth token (mtime bumps
# every turn, which defeated the strict-mode recency window) plus the
# pending-exchange session/verifier file.
"google_token.json",
"google_oauth_pending.json",
os.path.join("auth", "google_oauth.json"),
# Webhook subscription HMAC secrets.
"webhook_subscriptions.json",
# 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.)
_ROOT_CREDENTIAL_DIRS = (
"pairing",
)
for hermes_root in (_HERMES_HOME, _HERMES_ROOT):
denied.append(hermes_root / ".env")
denied.append(hermes_root / "auth.json")
denied.append(hermes_root / "credentials")
denied.append(hermes_root / "config.yaml")
for rel in _ROOT_CREDENTIAL_FILES:
denied.append(hermes_root / rel)
for rel in _ROOT_CREDENTIAL_DIRS:
denied.append(hermes_root / rel)
return denied
@ -1190,9 +1226,12 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
return str(resolved)
# Non-strict mode (default): accept anything not on the denylist.
# The denylist still blocks /etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
# ~/.hermes/auth.json, etc. — so the obvious prompt-injection sites
# (``MEDIA:/etc/passwd``, ``MEDIA:~/.ssh/id_rsa``) remain rejected.
# The denylist still blocks /etc, /proc, ~/.ssh, ~/.aws, and the
# credential/secret stores under the Hermes root (~/.hermes/.env,
# auth.json, .anthropic_oauth.json, google_token.json, pairing/, ...) —
# so the obvious prompt-injection / credential-exfil sites
# (``MEDIA:/etc/passwd``, ``MEDIA:~/.ssh/id_rsa``,
# ``MEDIA:~/.hermes/google_token.json``) remain rejected.
if not _media_delivery_strict_mode():
if _path_under_denied_prefix(resolved):
return None

View file

@ -967,6 +967,105 @@ class TestMediaDeliveryDefaultMode:
assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None
def test_denylist_blocks_google_token_default_mode(self, tmp_path, monkeypatch):
"""Integration credentials at the HERMES_HOME root (google_token.json)
must never be deliverable, even though they aren't the historically
enumerated .env/auth.json/config.yaml files. Regression for a
refreshed google_token.json being auto-attached to a Slack reply
(#50912).
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "home"
hermes_dir = fake_home / ".hermes"
hermes_dir.mkdir(parents=True)
token = hermes_dir / "google_token.json"
token.write_text('{"access_token": "***", "refresh_token": "***"}')
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_denylist_blocks_google_token_even_when_freshly_refreshed(self, tmp_path, monkeypatch):
"""The exploit was that the Google integration rewrites
google_token.json every turn, bumping its mtime to ~now, so the
strict-mode recency window (trust_recent_files) kept re-trusting it
and it re-sent on every reply. An explicit denylist entry must win
over recency trust.
"""
self._patch_roots(monkeypatch) # zero cache allowlist, strict mode on
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"
hermes_dir.mkdir(parents=True)
token = hermes_dir / "google_token.json"
token.write_text('{"access_token": "***"}') # 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_denylist_blocks_pairing_directory_contents(self, tmp_path, monkeypatch):
"""Files under ~/.hermes/pairing/ (platform pairing tokens) are
credential material and must not be deliverable.
"""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "home"
hermes_dir = fake_home / ".hermes"
pairing = hermes_dir / "pairing"
pairing.mkdir(parents=True)
token = pairing / "telegram-approved.json"
token.write_text('{"approved": ["123"]}')
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
matched before the denylist and still delivers.
"""
fake_home = tmp_path / "home"
hermes_dir = fake_home / ".hermes"
cache_dir = hermes_dir / "cache" / "documents"
cache_dir.mkdir(parents=True)
artifact = cache_dir / "report.pdf"
artifact.write_bytes(b"%PDF-1.4")
self._patch_roots(monkeypatch, cache_dir)
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(artifact)) == str(artifact.resolve())
def test_denylist_blocks_non_cache_file_under_hermes_home(self, tmp_path, monkeypatch):
"""A non-credential file the agent wrote directly under ~/.hermes
(not in a cache subdir) is still deliverable via recency trust we
did NOT blanket-deny the tree (per #32090/#34425). This guards against
accidentally re-introducing the rejected whole-tree deny.
"""
self._patch_roots(monkeypatch) # strict mode on
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"
hermes_dir.mkdir(parents=True)
artifact = hermes_dir / "adhoc_report.pdf"
artifact.write_bytes(b"%PDF-1.4") # fresh mtime
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(artifact)) == str(artifact.resolve())
def test_strict_mode_envvar_restores_legacy_behavior(self, tmp_path, monkeypatch):
"""Setting HERMES_MEDIA_DELIVERY_STRICT=1 reactivates the older
allowlist+recency logic. A stale file outside the allowlist is