From 23fab5fe4c6a32f91f02e8fef8cefdf1c814e066 Mon Sep 17 00:00:00 2001 From: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com> Date: Mon, 25 May 2026 23:50:28 +0300 Subject: [PATCH] fix(security): apply 0600 permissions on memory plugin config files containing API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four memory plugin save_config() methods wrote their JSON config files via write_text(), which obeys the process umask (typically 0o022), leaving them at 0644 — world-readable on shared hosts: ~/.hermes/honcho.json — Honcho API key ~/.hermes/mem0.json — Mem0 Platform API key (required) ~/.hermes/hindsight/config.json — Hindsight Cloud API key ~/.hermes/supermemory.json — Supermemory API key (required) All four schemas declare api_key with "secret": True. A local user on a multi-user host could read these files and recover live API keys. Apply os.chmod(config_path, 0o600) immediately after write_text() at each of the four save_config() call sites, matching the pattern used by the recent security cluster (79fc92e9c for .env, 3bace071b for webhook_subscriptions.json, 782681f90 for Google Chat OAuth). honcho/__init__.py also adds a local `import os` inside save_config() because that module does not import os at the top level. Adds one regression test per plugin asserting that the resulting file mode is 0o600 under a permissive umask (skipped on Windows). --- plugins/memory/hindsight/__init__.py | 4 ++++ plugins/memory/honcho/__init__.py | 5 +++++ plugins/memory/mem0/__init__.py | 4 ++++ plugins/memory/supermemory/__init__.py | 4 ++++ tests/plugins/memory/test_hindsight_provider.py | 13 +++++++++++++ tests/plugins/memory/test_mem0_v2.py | 14 ++++++++++++++ tests/plugins/memory/test_supermemory_provider.py | 12 ++++++++++++ tests/test_honcho_client_config.py | 13 +++++++++++++ 8 files changed, 69 insertions(+) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 1ca362e0089e..7d22279894e0 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -626,6 +626,10 @@ def save_config(self, values, hermes_home): pass existing.update(values) config_path.write_text(json.dumps(existing, indent=2)) + try: + os.chmod(config_path, 0o600) + except OSError: + pass def post_setup(self, hermes_home: str, config: dict) -> None: """Custom setup wizard — installs only the deps needed for the selected mode.""" diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index efbba937a4de..031634686a55 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -249,6 +249,7 @@ def is_available(self) -> bool: def save_config(self, values, hermes_home): """Write config to $HERMES_HOME/honcho.json (Honcho SDK native format).""" import json + import os from pathlib import Path config_path = Path(hermes_home) / "honcho.json" existing = {} @@ -259,6 +260,10 @@ def save_config(self, values, hermes_home): pass existing.update(values) config_path.write_text(json.dumps(existing, indent=2)) + try: + os.chmod(config_path, 0o600) + except OSError: + pass def get_config_schema(self): return [ diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index 32d1f6ff7002..00e6d58de018 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -156,6 +156,10 @@ def save_config(self, values, hermes_home): pass existing.update(values) config_path.write_text(json.dumps(existing, indent=2)) + try: + os.chmod(config_path, 0o600) + except OSError: + pass def get_config_schema(self): return [ diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index 35b5b6fd649e..e3b3d7a83fe6 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -153,6 +153,10 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None: existing = {} existing.update(values) config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + os.chmod(config_path, 0o600) + except OSError: + pass def _detect_category(text: str) -> str: diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index fcda46e56b09..54a43a30cf48 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -6,7 +6,9 @@ """ import json +import os import re +import stat import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -1549,3 +1551,14 @@ def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self, assert embedded._client is None assert provider._client is None + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """hindsight/config.json must be written with 0o600 so API key is not world-readable.""" + provider = HindsightMemoryProvider() + provider.save_config({"api_key": "hd-test-key"}, str(tmp_path)) + config_file = tmp_path / "hindsight" / "config.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" + diff --git a/tests/plugins/memory/test_mem0_v2.py b/tests/plugins/memory/test_mem0_v2.py index 6f60771f5c48..a9a866764523 100644 --- a/tests/plugins/memory/test_mem0_v2.py +++ b/tests/plugins/memory/test_mem0_v2.py @@ -4,6 +4,9 @@ """ import json +import os +import stat + import pytest from plugins.memory.mem0 import Mem0MemoryProvider @@ -203,6 +206,17 @@ def test_prefetch_dict_response(self, monkeypatch): # --------------------------------------------------------------------------- +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """mem0.json must be written with 0o600 so API key is not world-readable.""" + provider = Mem0MemoryProvider() + provider.save_config({"api_key": "m0-test-key"}, str(tmp_path)) + config_file = tmp_path / "mem0.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" + + class TestMem0Defaults: """Ensure we don't break existing users' defaults.""" diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index 0aee459757f4..d5f1c5bb1740 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -1,4 +1,6 @@ import json +import os +import stat import threading import pytest @@ -409,3 +411,13 @@ def test_get_config_schema_minimal(): assert len(schema) == 1 assert schema[0]["key"] == "api_key" assert schema[0]["secret"] is True + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """supermemory.json must be written with 0o600 so API key is not world-readable.""" + _save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path)) + config_file = tmp_path / "supermemory.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" diff --git a/tests/test_honcho_client_config.py b/tests/test_honcho_client_config.py index feb0eb41d7c2..6a2c4c3bcc42 100644 --- a/tests/test_honcho_client_config.py +++ b/tests/test_honcho_client_config.py @@ -2,12 +2,14 @@ import json import os +import stat import tempfile from pathlib import Path import pytest from plugins.memory.honcho.client import HonchoClientConfig +from plugins.memory.honcho import HonchoMemoryProvider class TestHonchoClientConfigAutoEnable: @@ -103,3 +105,14 @@ def test_falls_back_to_env_when_no_config_file(self, tmp_path, monkeypatch): assert cfg.api_key == "fallback-key" assert cfg.enabled is True # from_env() sets enabled=True + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """honcho.json must be written with 0o600 so API key is not world-readable.""" + provider = HonchoMemoryProvider() + provider.save_config({"api_key": "hc-test-key"}, str(tmp_path)) + config_file = tmp_path / "honcho.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"