From 50ec4a034d24582f73604b28039124c2152385c5 Mon Sep 17 00:00:00 2001 From: Simon Rosenberg <157206163+simonrosenberg@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:25:40 +0200 Subject: [PATCH 1/5] feat(agent-server): add require_secret_key to refuse plaintext secret persistence Adds an opt-in Config.require_secret_key flag (env OH_REQUIRE_SECRET_KEY). When set, FileSettingsStore/FileSecretsStore raise MissingCipherError instead of silently downgrading to plaintext storage when secrets are present and no OH_SECRET_KEY cipher is configured. Off by default, so the zero-config self-hosted/OSS path is unchanged. Fixes #4609. --- .../openhands/agent_server/config.py | 10 ++ .../agent_server/persistence/store.py | 26 +++++ .../test_persistence_require_cipher.py | 102 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 tests/agent_server/test_persistence_require_cipher.py diff --git a/openhands-agent-server/openhands/agent_server/config.py b/openhands-agent-server/openhands/agent_server/config.py index 65d8632fb9..3aa8d5a9b8 100644 --- a/openhands-agent-server/openhands/agent_server/config.py +++ b/openhands-agent-server/openhands/agent_server/config.py @@ -343,6 +343,16 @@ class Config(BaseModel): "be restored between restarts." ), ) + require_secret_key: bool = Field( + default=False, + description=( + "When True, refuse to persist settings/secrets in plaintext when " + "OH_SECRET_KEY is not configured — raises instead of silently " + "falling back to unencrypted storage. Off by default to preserve " + "the zero-config self-hosted/OSS path; multi-tenant deployments " + "should set this." + ), + ) web_url: str | None = Field( default_factory=_default_web_url, description=( diff --git a/openhands-agent-server/openhands/agent_server/persistence/store.py b/openhands-agent-server/openhands/agent_server/persistence/store.py index c798569a8e..951b745305 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/store.py +++ b/openhands-agent-server/openhands/agent_server/persistence/store.py @@ -33,6 +33,7 @@ from openhands.sdk.logger import get_logger from openhands.sdk.profiles.agent_profile_store import AgentProfileStore from openhands.sdk.utils.cipher import Cipher +from openhands.sdk.utils.pydantic_secrets import MissingCipherError # fcntl is Unix-only; on Windows, use msvcrt for file locking @@ -307,12 +308,14 @@ def __init__( persistence_dir: Path | str, cipher: Cipher | None = None, filename: str = "settings.json", + require_cipher: bool = False, ): # Validate filename to prevent path traversal and injection attacks _validate_filename(filename) self.persistence_dir = Path(persistence_dir) self.cipher = cipher self.filename = filename + self.require_cipher = require_cipher self._path = self.persistence_dir / filename self._lock_path = self.persistence_dir / ".settings.lock" @@ -374,6 +377,12 @@ def save(self, settings: PersistedSettings) -> None: context = {"expose_secrets": "plaintext"} # Warn about plaintext secret storage (only if secrets exist) if settings.llm_api_key_is_set: + if self.require_cipher: + raise MissingCipherError( + "Refusing to save settings with secrets in plaintext: " + "no cipher configured. Set OH_SECRET_KEY, or disable " + "require_secret_key to allow plaintext storage." + ) logger.warning( "Saving settings with secrets in PLAINTEXT (no cipher configured). " "Configure OH_SECRET_KEY for production deployments." @@ -445,12 +454,14 @@ def __init__( persistence_dir: Path | str, cipher: Cipher | None = None, filename: str = "secrets.json", + require_cipher: bool = False, ): # Use same validation as FileSettingsStore _validate_filename(filename) self.persistence_dir = Path(persistence_dir) self.cipher = cipher self.filename = filename + self.require_cipher = require_cipher self._path = self.persistence_dir / filename self._lock_path = self.persistence_dir / ".secrets.lock" @@ -543,6 +554,12 @@ def _save_with_versions( context = {"expose_secrets": "plaintext"} # Warn about plaintext secret storage (only if secrets exist) if secrets.custom_secrets: + if self.require_cipher: + raise MissingCipherError( + "Refusing to save secrets in plaintext: no cipher " + "configured. Set OH_SECRET_KEY, or disable " + "require_secret_key to allow plaintext storage." + ) logger.warning( "Saving secrets in PLAINTEXT (no cipher configured). " "Configure OH_SECRET_KEY for production deployments." @@ -833,6 +850,13 @@ def _get_cipher(config: Config | None = None) -> Cipher | None: return None +def _get_require_cipher(config: Config | None = None) -> bool: + """Get require_secret_key from config.""" + if config is not None: + return config.require_secret_key + return False + + def get_settings_store(config: Config | None = None) -> FileSettingsStore: """Get the global settings store instance (thread-safe). @@ -861,6 +885,7 @@ def get_settings_store(config: Config | None = None) -> FileSettingsStore: _settings_store = FileSettingsStore( persistence_dir=_get_profile_persistence_dir(), cipher=_get_cipher(config), + require_cipher=_get_require_cipher(config), ) return _settings_store @@ -893,6 +918,7 @@ def get_secrets_store(config: Config | None = None) -> FileSecretsStore: _secrets_store = FileSecretsStore( persistence_dir=_get_profile_persistence_dir(), cipher=_get_cipher(config), + require_cipher=_get_require_cipher(config), ) return _secrets_store diff --git a/tests/agent_server/test_persistence_require_cipher.py b/tests/agent_server/test_persistence_require_cipher.py new file mode 100644 index 0000000000..3f1d25e05f --- /dev/null +++ b/tests/agent_server/test_persistence_require_cipher.py @@ -0,0 +1,102 @@ +"""Tests for require_cipher: refuse plaintext secret persistence when set.""" + +import tempfile +from base64 import urlsafe_b64encode +from pathlib import Path + +import pytest +from pydantic import SecretStr + +from openhands.agent_server.persistence import ( + CustomSecret, + FileSecretsStore, + FileSettingsStore, + PersistedSettings, + Secrets, +) +from openhands.sdk.utils.cipher import Cipher +from openhands.sdk.utils.pydantic_secrets import MissingCipherError + + +@pytest.fixture +def persistence_dir(): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def cipher(): + return Cipher(urlsafe_b64encode(b"a" * 32).decode("ascii")) + + +def _settings_with_api_key() -> PersistedSettings: + return PersistedSettings.model_validate( + {"agent_settings": {"llm": {"model": "gpt-4o", "api_key": "sk-test-secret"}}} + ) + + +def _secrets_with_custom_secret() -> Secrets: + return Secrets( + custom_secrets={ + "MY_SECRET": CustomSecret(name="MY_SECRET", secret=SecretStr("sk-test")) + } + ) + + +def test_settings_save_raises_without_cipher_when_require_cipher(persistence_dir): + store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) + + with pytest.raises(MissingCipherError): + store.save(_settings_with_api_key()) + + +def test_secrets_save_raises_without_cipher_when_require_cipher(persistence_dir): + store = FileSecretsStore(persistence_dir=persistence_dir, require_cipher=True) + + with pytest.raises(MissingCipherError): + store.save(_secrets_with_custom_secret()) + + +def test_settings_save_without_secrets_does_not_raise_when_require_cipher( + persistence_dir, +): + """No secrets present -> nothing to protect, no cipher needed.""" + store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) + + store.save(PersistedSettings()) # no api key set + + +def test_settings_save_with_cipher_succeeds_when_require_cipher( + persistence_dir, cipher +): + store = FileSettingsStore( + persistence_dir=persistence_dir, cipher=cipher, require_cipher=True + ) + + store.save(_settings_with_api_key()) + + reloaded = store.load() + assert reloaded is not None + assert reloaded.llm_api_key_is_set + + +def test_secrets_save_with_cipher_succeeds_when_require_cipher(persistence_dir, cipher): + store = FileSecretsStore( + persistence_dir=persistence_dir, cipher=cipher, require_cipher=True + ) + + store.save(_secrets_with_custom_secret()) + + reloaded = store.load() + assert reloaded is not None + assert "MY_SECRET" in reloaded.custom_secrets + + +def test_settings_save_without_cipher_stores_plaintext_by_default(persistence_dir): + """require_cipher defaults to False -> unchanged backward-compatible behavior.""" + store = FileSettingsStore(persistence_dir=persistence_dir) + + store.save(_settings_with_api_key()) # does not raise + + raw = (persistence_dir / "settings.json").read_text() + assert "sk-test-secret" in raw From fad27ba0553a2934353c4bb4c0385cc5fcde6631 Mon Sep 17 00:00:00 2001 From: Simon Rosenberg <157206163+simonrosenberg@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:03:28 +0200 Subject: [PATCH 2/5] chore: address PR review feedback (#4618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add symmetric FileSecretsStore coverage for the two backward-compat cases (no-secrets no-op, default-off plaintext), mirroring the existing FileSettingsStore tests. - Drop the redundant _get_require_cipher docstring — it restated the signature verbatim. --- .../agent_server/persistence/store.py | 1 - .../test_persistence_require_cipher.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/openhands-agent-server/openhands/agent_server/persistence/store.py b/openhands-agent-server/openhands/agent_server/persistence/store.py index 951b745305..920cd38a1d 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/store.py +++ b/openhands-agent-server/openhands/agent_server/persistence/store.py @@ -851,7 +851,6 @@ def _get_cipher(config: Config | None = None) -> Cipher | None: def _get_require_cipher(config: Config | None = None) -> bool: - """Get require_secret_key from config.""" if config is not None: return config.require_secret_key return False diff --git a/tests/agent_server/test_persistence_require_cipher.py b/tests/agent_server/test_persistence_require_cipher.py index 3f1d25e05f..ba4e1f8c63 100644 --- a/tests/agent_server/test_persistence_require_cipher.py +++ b/tests/agent_server/test_persistence_require_cipher.py @@ -66,6 +66,15 @@ def test_settings_save_without_secrets_does_not_raise_when_require_cipher( store.save(PersistedSettings()) # no api key set +def test_secrets_save_without_secrets_does_not_raise_when_require_cipher( + persistence_dir, +): + """No secrets present -> nothing to protect, no cipher needed.""" + store = FileSecretsStore(persistence_dir=persistence_dir, require_cipher=True) + + store.save(Secrets()) # empty custom_secrets + + def test_settings_save_with_cipher_succeeds_when_require_cipher( persistence_dir, cipher ): @@ -100,3 +109,13 @@ def test_settings_save_without_cipher_stores_plaintext_by_default(persistence_di raw = (persistence_dir / "settings.json").read_text() assert "sk-test-secret" in raw + + +def test_secrets_save_without_cipher_stores_plaintext_by_default(persistence_dir): + """require_cipher defaults to False -> unchanged backward-compatible behavior.""" + store = FileSecretsStore(persistence_dir=persistence_dir) + + store.save(_secrets_with_custom_secret()) # does not raise + + raw = (persistence_dir / "secrets.json").read_text() + assert "sk-test" in raw From 6ee2092b65c76e024448c875a5c85209e761ade5 Mon Sep 17 00:00:00 2001 From: Simon Rosenberg <157206163+simonrosenberg@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:18:23 +0200 Subject: [PATCH 3/5] fix: guard require_cipher against critic_api_key, not just llm.api_key PersistedSettings.has_any_secret covers both llm.api_key and verification.critic_api_key (a separate secret field on OpenHandsAgentSettings). Without this, a user who only set critic_api_key could still get plaintext storage even with require_cipher=True. --- .../agent_server/persistence/models.py | 19 +++++++++++++++++++ .../agent_server/persistence/store.py | 2 +- .../test_persistence_require_cipher.py | 13 +++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/openhands-agent-server/openhands/agent_server/persistence/models.py b/openhands-agent-server/openhands/agent_server/persistence/models.py index 2c4547a1f7..2da6d83cb1 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/models.py +++ b/openhands-agent-server/openhands/agent_server/persistence/models.py @@ -180,6 +180,25 @@ def llm_api_key_is_set(self) -> bool: ) return bool(secret_value and secret_value.strip()) + @property + def has_any_secret(self) -> bool: + """Check if any persisted secret is configured (LLM key or critic key). + + Broader than ``llm_api_key_is_set``: ``verification.critic_api_key`` + (OpenHands-agent settings only) is a separate secret field that can be + set even when the LLM key isn't. + """ + if self.llm_api_key_is_set: + return True + verification = getattr(self.agent_settings, "verification", None) + raw = verification.critic_api_key if verification is not None else None + if raw is None: + return False + secret_value = ( + raw.get_secret_value() if isinstance(raw, SecretStr) else str(raw) + ) + return bool(secret_value and secret_value.strip()) + def update( self, payload: SettingsUpdatePayload, diff --git a/openhands-agent-server/openhands/agent_server/persistence/store.py b/openhands-agent-server/openhands/agent_server/persistence/store.py index 920cd38a1d..eeeff68e83 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/store.py +++ b/openhands-agent-server/openhands/agent_server/persistence/store.py @@ -376,7 +376,7 @@ def save(self, settings: PersistedSettings) -> None: else: context = {"expose_secrets": "plaintext"} # Warn about plaintext secret storage (only if secrets exist) - if settings.llm_api_key_is_set: + if settings.has_any_secret: if self.require_cipher: raise MissingCipherError( "Refusing to save settings with secrets in plaintext: " diff --git a/tests/agent_server/test_persistence_require_cipher.py b/tests/agent_server/test_persistence_require_cipher.py index ba4e1f8c63..0fabe5acf5 100644 --- a/tests/agent_server/test_persistence_require_cipher.py +++ b/tests/agent_server/test_persistence_require_cipher.py @@ -50,6 +50,19 @@ def test_settings_save_raises_without_cipher_when_require_cipher(persistence_dir store.save(_settings_with_api_key()) +def test_settings_save_raises_for_critic_key_only_when_require_cipher( + persistence_dir, +): + """critic_api_key is a separate secret from llm.api_key -- must still guard it.""" + store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) + settings = PersistedSettings.model_validate( + {"agent_settings": {"verification": {"critic_api_key": "sk-critic-test"}}} + ) + + with pytest.raises(MissingCipherError): + store.save(settings) + + def test_secrets_save_raises_without_cipher_when_require_cipher(persistence_dir): store = FileSecretsStore(persistence_dir=persistence_dir, require_cipher=True) From 091ceb5b285f4d496f48ada5f5e02b8d795efcb6 Mon Sep 17 00:00:00 2001 From: Simon Rosenberg <157206163+simonrosenberg@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:34:04 +0200 Subject: [PATCH 4/5] fix: detect secrets via the real serialization pipeline, not a field checklist has_any_secret previously checked only llm.api_key + critic_api_key by name -- MCP server env/headers, agent_context.secrets, and any future secret-bearing field would silently fall through require_cipher=True to plaintext storage. Replaced with a probe cipher passed as context={"cipher": probe} to force every secret field's own serializer down its real 'encrypted' branch (see resolve_expose_mode), so detection reuses the actual serialization logic instead of hand-walking the model for SecretStr instances -- which also can't see fields like AgentContext.secrets, whose bare-string values are plain str at rest and only become secret-shaped inside their own field serializer at dump time. Added regression tests for MCP env secrets and agent_context.secrets. --- .../agent_server/persistence/models.py | 57 ++++++++++++++----- .../agent_server/persistence/store.py | 2 +- .../test_persistence_require_cipher.py | 38 +++++++++++++ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/persistence/models.py b/openhands-agent-server/openhands/agent_server/persistence/models.py index 2da6d83cb1..c09d585b94 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/models.py +++ b/openhands-agent-server/openhands/agent_server/persistence/models.py @@ -33,6 +33,38 @@ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret +class _SecretProbeCipher: + """Fake cipher that flags secret serialization without needing a real key. + + Passed as ``context={"cipher": probe}`` this forces every secret field's + serializer down the "encrypted" branch (see ``resolve_expose_mode``), + reusing the real serialization pipeline to detect secret-bearing fields + instead of hand-walking the model for ``SecretStr`` instances. That + matters for fields like ``AgentContext.secrets``, whose bare-string + entries are plain ``str`` at rest and only become secret-shaped inside + their own field serializer at dump time -- a value-type walk can't see + that, but reusing the pipeline does. + """ + + def __init__(self) -> None: + self.found = False + + def try_decrypt_str(self, raw: str) -> str | None: # noqa: ARG002 + return None + + def encrypt(self, value: SecretStr) -> str: + if value.get_secret_value(): + self.found = True + return "" + + +def _contains_secret_value(model: BaseModel) -> bool: + """Check if serializing `model` would touch any secret-bearing field.""" + probe = _SecretProbeCipher() + model.model_dump(mode="json", context={"cipher": probe}) + return probe.found + + class SettingsUpdatePayload(TypedDict, total=False): """Typed payload for PersistedSettings.update() method. @@ -182,22 +214,14 @@ def llm_api_key_is_set(self) -> bool: @property def has_any_secret(self) -> bool: - """Check if any persisted secret is configured (LLM key or critic key). + """Check if these settings contain any secret value anywhere. - Broader than ``llm_api_key_is_set``: ``verification.critic_api_key`` - (OpenHands-agent settings only) is a separate secret field that can be - set even when the LLM key isn't. + Broader than ``llm_api_key_is_set``: walks the whole ``agent_settings`` + tree (MCP server env/headers, ``critic_api_key``, provider creds, + ``agent_context.secrets``, ...) rather than checking a fixed field + list, so it stays correct as new secret-bearing fields are added. """ - if self.llm_api_key_is_set: - return True - verification = getattr(self.agent_settings, "verification", None) - raw = verification.critic_api_key if verification is not None else None - if raw is None: - return False - secret_value = ( - raw.get_secret_value() if isinstance(raw, SecretStr) else str(raw) - ) - return bool(secret_value and secret_value.strip()) + return _contains_secret_value(self.agent_settings) def update( self, @@ -431,6 +455,11 @@ class Secrets(BaseModel): model_config = ConfigDict(frozen=True) + @property + def has_any_secret(self) -> bool: + """Check if these secrets contain any non-empty value.""" + return _contains_secret_value(self) + def get_env_vars(self) -> dict[str, str]: """Get secrets as environment variables dict. diff --git a/openhands-agent-server/openhands/agent_server/persistence/store.py b/openhands-agent-server/openhands/agent_server/persistence/store.py index eeeff68e83..a33cdff772 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/store.py +++ b/openhands-agent-server/openhands/agent_server/persistence/store.py @@ -553,7 +553,7 @@ def _save_with_versions( else: context = {"expose_secrets": "plaintext"} # Warn about plaintext secret storage (only if secrets exist) - if secrets.custom_secrets: + if secrets.has_any_secret: if self.require_cipher: raise MissingCipherError( "Refusing to save secrets in plaintext: no cipher " diff --git a/tests/agent_server/test_persistence_require_cipher.py b/tests/agent_server/test_persistence_require_cipher.py index 0fabe5acf5..e84b273174 100644 --- a/tests/agent_server/test_persistence_require_cipher.py +++ b/tests/agent_server/test_persistence_require_cipher.py @@ -63,6 +63,44 @@ def test_settings_save_raises_for_critic_key_only_when_require_cipher( store.save(settings) +def test_settings_save_raises_for_mcp_secret_when_require_cipher(persistence_dir): + """MCP server env/header secrets must be guarded too, not just llm.api_key.""" + store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) + settings = PersistedSettings.model_validate( + { + "agent_settings": { + "mcp_config": { + "mcpServers": { + "github": { + "command": "uvx", + "args": ["mcp-server-github"], + "env": {"GITHUB_TOKEN": "ghp-test-secret"}, + } + } + } + } + } + ) + + with pytest.raises(MissingCipherError): + store.save(settings) + + +def test_settings_save_raises_for_agent_context_secret_when_require_cipher( + persistence_dir, +): + """agent_context.secrets values are plain str at rest (only secret-shaped at + serialize time), so a value-type walk alone would miss this -- must still + be caught.""" + store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) + settings = PersistedSettings.model_validate( + {"agent_settings": {"agent_context": {"secrets": {"MY_TOKEN": "sk-ctx"}}}} + ) + + with pytest.raises(MissingCipherError): + store.save(settings) + + def test_secrets_save_raises_without_cipher_when_require_cipher(persistence_dir): store = FileSecretsStore(persistence_dir=persistence_dir, require_cipher=True) From b481a038f6e9924eef4a66d04fdbedda95ecd915 Mon Sep 17 00:00:00 2001 From: Simon Rosenberg <157206163+simonrosenberg@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:07:06 +0200 Subject: [PATCH 5/5] simplify: drop require_secret_key, keep only the has_any_secret fix require_secret_key was an opt-in flag that only helps operators who remember to enable it -- the same failure mode as forgetting OH_SECRET_KEY itself, so it added a second forgettable switch rather than closing a gap. Removed: Config.require_secret_key, the require_cipher constructor params and raise branches on FileSettingsStore/FileSecretsStore, and _get_require_cipher. Kept has_any_secret (PersistedSettings/Secrets), which fixes a real pre-existing bug independent of require_secret_key: the plaintext-save warning was gated on llm_api_key_is_set alone, so critic_api_key, MCP server secrets, and agent_context.secrets triggered no warning at all. Tests renamed/rewritten to cover has_any_secret detection directly and the now-correct warning behavior, dropping the require_cipher-raise cases. --- .../openhands/agent_server/config.py | 10 - .../agent_server/persistence/store.py | 25 --- .../test_persistence_require_cipher.py | 172 ------------------ .../test_persistence_secret_detection.py | 145 +++++++++++++++ 4 files changed, 145 insertions(+), 207 deletions(-) delete mode 100644 tests/agent_server/test_persistence_require_cipher.py create mode 100644 tests/agent_server/test_persistence_secret_detection.py diff --git a/openhands-agent-server/openhands/agent_server/config.py b/openhands-agent-server/openhands/agent_server/config.py index 3aa8d5a9b8..65d8632fb9 100644 --- a/openhands-agent-server/openhands/agent_server/config.py +++ b/openhands-agent-server/openhands/agent_server/config.py @@ -343,16 +343,6 @@ class Config(BaseModel): "be restored between restarts." ), ) - require_secret_key: bool = Field( - default=False, - description=( - "When True, refuse to persist settings/secrets in plaintext when " - "OH_SECRET_KEY is not configured — raises instead of silently " - "falling back to unencrypted storage. Off by default to preserve " - "the zero-config self-hosted/OSS path; multi-tenant deployments " - "should set this." - ), - ) web_url: str | None = Field( default_factory=_default_web_url, description=( diff --git a/openhands-agent-server/openhands/agent_server/persistence/store.py b/openhands-agent-server/openhands/agent_server/persistence/store.py index a33cdff772..3c4999cf4b 100644 --- a/openhands-agent-server/openhands/agent_server/persistence/store.py +++ b/openhands-agent-server/openhands/agent_server/persistence/store.py @@ -33,7 +33,6 @@ from openhands.sdk.logger import get_logger from openhands.sdk.profiles.agent_profile_store import AgentProfileStore from openhands.sdk.utils.cipher import Cipher -from openhands.sdk.utils.pydantic_secrets import MissingCipherError # fcntl is Unix-only; on Windows, use msvcrt for file locking @@ -308,14 +307,12 @@ def __init__( persistence_dir: Path | str, cipher: Cipher | None = None, filename: str = "settings.json", - require_cipher: bool = False, ): # Validate filename to prevent path traversal and injection attacks _validate_filename(filename) self.persistence_dir = Path(persistence_dir) self.cipher = cipher self.filename = filename - self.require_cipher = require_cipher self._path = self.persistence_dir / filename self._lock_path = self.persistence_dir / ".settings.lock" @@ -377,12 +374,6 @@ def save(self, settings: PersistedSettings) -> None: context = {"expose_secrets": "plaintext"} # Warn about plaintext secret storage (only if secrets exist) if settings.has_any_secret: - if self.require_cipher: - raise MissingCipherError( - "Refusing to save settings with secrets in plaintext: " - "no cipher configured. Set OH_SECRET_KEY, or disable " - "require_secret_key to allow plaintext storage." - ) logger.warning( "Saving settings with secrets in PLAINTEXT (no cipher configured). " "Configure OH_SECRET_KEY for production deployments." @@ -454,14 +445,12 @@ def __init__( persistence_dir: Path | str, cipher: Cipher | None = None, filename: str = "secrets.json", - require_cipher: bool = False, ): # Use same validation as FileSettingsStore _validate_filename(filename) self.persistence_dir = Path(persistence_dir) self.cipher = cipher self.filename = filename - self.require_cipher = require_cipher self._path = self.persistence_dir / filename self._lock_path = self.persistence_dir / ".secrets.lock" @@ -554,12 +543,6 @@ def _save_with_versions( context = {"expose_secrets": "plaintext"} # Warn about plaintext secret storage (only if secrets exist) if secrets.has_any_secret: - if self.require_cipher: - raise MissingCipherError( - "Refusing to save secrets in plaintext: no cipher " - "configured. Set OH_SECRET_KEY, or disable " - "require_secret_key to allow plaintext storage." - ) logger.warning( "Saving secrets in PLAINTEXT (no cipher configured). " "Configure OH_SECRET_KEY for production deployments." @@ -850,12 +833,6 @@ def _get_cipher(config: Config | None = None) -> Cipher | None: return None -def _get_require_cipher(config: Config | None = None) -> bool: - if config is not None: - return config.require_secret_key - return False - - def get_settings_store(config: Config | None = None) -> FileSettingsStore: """Get the global settings store instance (thread-safe). @@ -884,7 +861,6 @@ def get_settings_store(config: Config | None = None) -> FileSettingsStore: _settings_store = FileSettingsStore( persistence_dir=_get_profile_persistence_dir(), cipher=_get_cipher(config), - require_cipher=_get_require_cipher(config), ) return _settings_store @@ -917,7 +893,6 @@ def get_secrets_store(config: Config | None = None) -> FileSecretsStore: _secrets_store = FileSecretsStore( persistence_dir=_get_profile_persistence_dir(), cipher=_get_cipher(config), - require_cipher=_get_require_cipher(config), ) return _secrets_store diff --git a/tests/agent_server/test_persistence_require_cipher.py b/tests/agent_server/test_persistence_require_cipher.py deleted file mode 100644 index e84b273174..0000000000 --- a/tests/agent_server/test_persistence_require_cipher.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Tests for require_cipher: refuse plaintext secret persistence when set.""" - -import tempfile -from base64 import urlsafe_b64encode -from pathlib import Path - -import pytest -from pydantic import SecretStr - -from openhands.agent_server.persistence import ( - CustomSecret, - FileSecretsStore, - FileSettingsStore, - PersistedSettings, - Secrets, -) -from openhands.sdk.utils.cipher import Cipher -from openhands.sdk.utils.pydantic_secrets import MissingCipherError - - -@pytest.fixture -def persistence_dir(): - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - - -@pytest.fixture -def cipher(): - return Cipher(urlsafe_b64encode(b"a" * 32).decode("ascii")) - - -def _settings_with_api_key() -> PersistedSettings: - return PersistedSettings.model_validate( - {"agent_settings": {"llm": {"model": "gpt-4o", "api_key": "sk-test-secret"}}} - ) - - -def _secrets_with_custom_secret() -> Secrets: - return Secrets( - custom_secrets={ - "MY_SECRET": CustomSecret(name="MY_SECRET", secret=SecretStr("sk-test")) - } - ) - - -def test_settings_save_raises_without_cipher_when_require_cipher(persistence_dir): - store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) - - with pytest.raises(MissingCipherError): - store.save(_settings_with_api_key()) - - -def test_settings_save_raises_for_critic_key_only_when_require_cipher( - persistence_dir, -): - """critic_api_key is a separate secret from llm.api_key -- must still guard it.""" - store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) - settings = PersistedSettings.model_validate( - {"agent_settings": {"verification": {"critic_api_key": "sk-critic-test"}}} - ) - - with pytest.raises(MissingCipherError): - store.save(settings) - - -def test_settings_save_raises_for_mcp_secret_when_require_cipher(persistence_dir): - """MCP server env/header secrets must be guarded too, not just llm.api_key.""" - store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) - settings = PersistedSettings.model_validate( - { - "agent_settings": { - "mcp_config": { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": {"GITHUB_TOKEN": "ghp-test-secret"}, - } - } - } - } - } - ) - - with pytest.raises(MissingCipherError): - store.save(settings) - - -def test_settings_save_raises_for_agent_context_secret_when_require_cipher( - persistence_dir, -): - """agent_context.secrets values are plain str at rest (only secret-shaped at - serialize time), so a value-type walk alone would miss this -- must still - be caught.""" - store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) - settings = PersistedSettings.model_validate( - {"agent_settings": {"agent_context": {"secrets": {"MY_TOKEN": "sk-ctx"}}}} - ) - - with pytest.raises(MissingCipherError): - store.save(settings) - - -def test_secrets_save_raises_without_cipher_when_require_cipher(persistence_dir): - store = FileSecretsStore(persistence_dir=persistence_dir, require_cipher=True) - - with pytest.raises(MissingCipherError): - store.save(_secrets_with_custom_secret()) - - -def test_settings_save_without_secrets_does_not_raise_when_require_cipher( - persistence_dir, -): - """No secrets present -> nothing to protect, no cipher needed.""" - store = FileSettingsStore(persistence_dir=persistence_dir, require_cipher=True) - - store.save(PersistedSettings()) # no api key set - - -def test_secrets_save_without_secrets_does_not_raise_when_require_cipher( - persistence_dir, -): - """No secrets present -> nothing to protect, no cipher needed.""" - store = FileSecretsStore(persistence_dir=persistence_dir, require_cipher=True) - - store.save(Secrets()) # empty custom_secrets - - -def test_settings_save_with_cipher_succeeds_when_require_cipher( - persistence_dir, cipher -): - store = FileSettingsStore( - persistence_dir=persistence_dir, cipher=cipher, require_cipher=True - ) - - store.save(_settings_with_api_key()) - - reloaded = store.load() - assert reloaded is not None - assert reloaded.llm_api_key_is_set - - -def test_secrets_save_with_cipher_succeeds_when_require_cipher(persistence_dir, cipher): - store = FileSecretsStore( - persistence_dir=persistence_dir, cipher=cipher, require_cipher=True - ) - - store.save(_secrets_with_custom_secret()) - - reloaded = store.load() - assert reloaded is not None - assert "MY_SECRET" in reloaded.custom_secrets - - -def test_settings_save_without_cipher_stores_plaintext_by_default(persistence_dir): - """require_cipher defaults to False -> unchanged backward-compatible behavior.""" - store = FileSettingsStore(persistence_dir=persistence_dir) - - store.save(_settings_with_api_key()) # does not raise - - raw = (persistence_dir / "settings.json").read_text() - assert "sk-test-secret" in raw - - -def test_secrets_save_without_cipher_stores_plaintext_by_default(persistence_dir): - """require_cipher defaults to False -> unchanged backward-compatible behavior.""" - store = FileSecretsStore(persistence_dir=persistence_dir) - - store.save(_secrets_with_custom_secret()) # does not raise - - raw = (persistence_dir / "secrets.json").read_text() - assert "sk-test" in raw diff --git a/tests/agent_server/test_persistence_secret_detection.py b/tests/agent_server/test_persistence_secret_detection.py new file mode 100644 index 0000000000..86235a5993 --- /dev/null +++ b/tests/agent_server/test_persistence_secret_detection.py @@ -0,0 +1,145 @@ +"""Tests for has_any_secret: detect secret-bearing fields via the real +serialization pipeline, not a hardcoded field checklist. +""" + +import logging +import tempfile +from base64 import urlsafe_b64encode +from pathlib import Path + +import pytest +from pydantic import SecretStr + +from openhands.agent_server.persistence import ( + CustomSecret, + FileSecretsStore, + FileSettingsStore, + PersistedSettings, + Secrets, +) +from openhands.sdk.utils.cipher import Cipher + + +@pytest.fixture +def persistence_dir(): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def cipher(): + return Cipher(urlsafe_b64encode(b"a" * 32).decode("ascii")) + + +def test_has_any_secret_detects_llm_api_key(): + settings = PersistedSettings.model_validate( + {"agent_settings": {"llm": {"model": "gpt-4o", "api_key": "sk-test"}}} + ) + assert settings.has_any_secret + + +def test_has_any_secret_detects_critic_api_key(): + """critic_api_key is a separate secret from llm.api_key.""" + settings = PersistedSettings.model_validate( + {"agent_settings": {"verification": {"critic_api_key": "sk-critic"}}} + ) + assert settings.has_any_secret + + +def test_has_any_secret_detects_mcp_server_secret(): + settings = PersistedSettings.model_validate( + { + "agent_settings": { + "mcp_config": { + "mcpServers": { + "github": { + "command": "uvx", + "args": ["mcp-server-github"], + "env": {"GITHUB_TOKEN": "ghp-test"}, + } + } + } + } + } + ) + assert settings.has_any_secret + + +def test_has_any_secret_detects_agent_context_secret(): + """agent_context.secrets values are plain str at rest (only secret-shaped + at serialize time via their own field serializer), so a naive + isinstance(v, SecretStr) walk would miss this -- must still be caught.""" + settings = PersistedSettings.model_validate( + {"agent_settings": {"agent_context": {"secrets": {"MY_TOKEN": "sk-ctx"}}}} + ) + assert settings.has_any_secret + + +def test_has_any_secret_false_for_empty_settings(): + assert not PersistedSettings().has_any_secret + + +def test_secrets_has_any_secret_detects_custom_secret(): + secrets = Secrets( + custom_secrets={ + "MY_SECRET": CustomSecret(name="MY_SECRET", secret=SecretStr("sk-test")) + } + ) + assert secrets.has_any_secret + + +def test_secrets_has_any_secret_false_for_empty_secrets(): + assert not Secrets().has_any_secret + + +def test_settings_save_warns_for_critic_key_only_without_cipher( + persistence_dir, caplog +): + """Regression test: before has_any_secret, the plaintext warning was + gated on llm_api_key_is_set alone, so a critic_api_key-only settings + object triggered no warning at all.""" + caplog.set_level(logging.WARNING) + store = FileSettingsStore(persistence_dir=persistence_dir) + settings = PersistedSettings.model_validate( + {"agent_settings": {"verification": {"critic_api_key": "sk-critic"}}} + ) + + store.save(settings) + + assert "PLAINTEXT" in caplog.text + + +def test_settings_save_no_warning_when_no_secrets_present(persistence_dir, caplog): + caplog.set_level(logging.WARNING) + store = FileSettingsStore(persistence_dir=persistence_dir) + + store.save(PersistedSettings()) + + assert "PLAINTEXT" not in caplog.text + + +def test_settings_save_with_cipher_round_trips_critic_key(persistence_dir, cipher): + store = FileSettingsStore(persistence_dir=persistence_dir, cipher=cipher) + settings = PersistedSettings.model_validate( + {"agent_settings": {"verification": {"critic_api_key": "sk-critic"}}} + ) + + store.save(settings) + + reloaded = store.load() + assert reloaded is not None + assert reloaded.has_any_secret + + +def test_secrets_save_warns_without_cipher(persistence_dir, caplog): + caplog.set_level(logging.WARNING) + store = FileSecretsStore(persistence_dir=persistence_dir) + secrets = Secrets( + custom_secrets={ + "MY_SECRET": CustomSecret(name="MY_SECRET", secret=SecretStr("sk-test")) + } + ) + + store.save(secrets) + + assert "PLAINTEXT" in caplog.text