mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 11:02:03 +00:00
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:
parent
e9b86f352f
commit
100e7be20e
2 changed files with 146 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue