diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 89806a739312f..747d07b7df067 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -956,13 +956,11 @@ def _media_delivery_denied_paths() -> List[Path]: for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS: 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. + # files, OAuth refresh tokens, MCP tokens, session databases, and + # credentials. Only cache/operator-allowlisted subdirectories under them + # may be delivered. 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") + denied.append(hermes_root) return denied diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 10a924764ab43..d43702140593d 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -737,6 +737,7 @@ def test_recency_trust_denies_system_paths_even_when_fresh(self, tmp_path, monke secret = ssh_dir / "id_rsa.txt" secret.write_bytes(b"-----BEGIN ...") # mtime = now monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None @@ -831,6 +832,7 @@ def test_denylist_still_blocks_credentials(self, tmp_path, monkeypatch): secret = ssh_dir / "id_rsa" secret.write_bytes(b"-----BEGIN ...") monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None @@ -866,6 +868,7 @@ def test_denylist_blocks_hermes_credentials(self, tmp_path, monkeypatch): env_file = hermes_dir / ".env" env_file.write_text("OPENAI_API_KEY=sk-...") monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) monkeypatch.setattr( "gateway.platforms.base._HERMES_HOME", hermes_dir, @@ -883,6 +886,7 @@ def test_denylist_blocks_hermes_config_in_active_profile(self, tmp_path, monkeyp config_file = hermes_dir / "config.yaml" config_file.write_text("model:\n provider: openai\n") monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) monkeypatch.setattr( "gateway.platforms.base._HERMES_HOME", hermes_dir, @@ -901,6 +905,7 @@ def test_denylist_blocks_shared_hermes_root_config_for_profiles(self, tmp_path, config_file = hermes_root / "config.yaml" config_file.write_text("profiles:\n active: work\n") monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) monkeypatch.setattr( "gateway.platforms.base._HERMES_HOME", profile_home, @@ -912,6 +917,54 @@ def test_denylist_blocks_shared_hermes_root_config_for_profiles(self, tmp_path, assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None + def test_denylist_blocks_non_cache_hermes_home_files(self, tmp_path, monkeypatch): + """Non-cache files under the active Hermes home are never attachments.""" + self._patch_roots(monkeypatch) + + hermes_dir = tmp_path / ".hermes" + for relative in ( + ".anthropic_oauth.json", + "auth/google_oauth.json", + "mcp-tokens/server.json", + "sessions.json", + ): + secret = hermes_dir / relative + secret.parent.mkdir(parents=True, exist_ok=True) + secret.write_text("secret") + 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_cache_allowlist_beats_hermes_home_denylist(self, tmp_path, monkeypatch): + """Generated cache artifacts still deliver from under HERMES_HOME.""" + hermes_dir = tmp_path / ".hermes" + cache_root = hermes_dir / "cache" / "documents" + self._patch_roots(monkeypatch, cache_root) + + report = cache_root / "report.pdf" + report.parent.mkdir(parents=True) + report.write_bytes(b"%PDF-1.4") + 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(report)) == str(report.resolve()) + + def test_operator_allowlist_beats_hermes_home_denylist(self, tmp_path, monkeypatch): + """Explicit operator media roots under HERMES_HOME remain deliverable.""" + self._patch_roots(monkeypatch) + + hermes_dir = tmp_path / ".hermes" + export_root = hermes_dir / "exports" + report = export_root / "report.pdf" + report.parent.mkdir(parents=True) + report.write_bytes(b"%PDF-1.4") + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(export_root)) + + assert BasePlatformAdapter.validate_media_delivery_path(str(report)) == str(report.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