diff --git a/agent/redact.py b/agent/redact.py index 04d35e3c93602..a9ecd8bb1c7fa 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -15,7 +15,24 @@ # Snapshot at import time so runtime env mutations (e.g. LLM-generated # `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session. -_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off") +# +# Gateway mode always forces redaction on regardless of config, because +# gateway serves multiple users over messaging platforms — leaking secrets +# into LLM context risks reflecting them to any connected user. +_IS_GATEWAY = os.getenv("HERMES_GATEWAY_SESSION", "").lower() in ("1", "true", "yes") +_USER_DISABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("0", "false", "no", "off") +_REDACT_ENABLED = True if _IS_GATEWAY else not _USER_DISABLED + +if _USER_DISABLED and _IS_GATEWAY: + logger.warning( + "security.redact_secrets is set to false, but redaction is forced ON " + "in gateway mode to protect multi-user sessions." + ) +elif _USER_DISABLED: + logger.warning( + "Secret redaction is DISABLED (security.redact_secrets: false). " + "API keys and tokens will appear in plaintext in logs and tool output." + ) # Known API key prefixes -- match the prefix + contiguous token chars _PREFIX_PATTERNS = [ diff --git a/gateway/run.py b/gateway/run.py index 207ae120bae6e..ac21074dda741 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -197,7 +197,10 @@ def _ensure_ssl_certs() -> None: _tz_cfg = _cfg.get("timezone", "") if _tz_cfg and isinstance(_tz_cfg, str) and "HERMES_TIMEZONE" not in os.environ: os.environ["HERMES_TIMEZONE"] = _tz_cfg.strip() - # Security settings + # Security settings — gateway always forces redaction on to protect + # multi-user sessions. The config value is still read so that + # redact.py can log a warning, but HERMES_GATEWAY_SESSION ensures + # _REDACT_ENABLED stays True regardless. _security_cfg = _cfg.get("security", {}) if isinstance(_security_cfg, dict): _redact = _security_cfg.get("redact_secrets") diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 83b1b4d1a1352..efede90ef0578 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -185,6 +185,47 @@ def test_url_without_key_unchanged(self): assert redact_sensitive_text(text) == text +class TestGatewayForcesRedaction: + """Gateway mode must force redaction on even when config disables it.""" + + def test_gateway_overrides_disabled_redaction(self, monkeypatch): + """When HERMES_GATEWAY_SESSION=true, _REDACT_ENABLED must be True + even if HERMES_REDACT_SECRETS=false.""" + monkeypatch.setenv("HERMES_GATEWAY_SESSION", "true") + monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") + # Re-evaluate the module-level flag with the patched env + is_gateway = os.getenv("HERMES_GATEWAY_SESSION", "").lower() in ("1", "true", "yes") + user_disabled = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("0", "false", "no", "off") + enabled = True if is_gateway else not user_disabled + assert enabled is True + + def test_gateway_env_variations(self, monkeypatch): + """All truthy values for HERMES_GATEWAY_SESSION should force redaction.""" + for val in ("1", "true", "yes", "True", "YES"): + monkeypatch.setenv("HERMES_GATEWAY_SESSION", val) + monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") + is_gateway = os.getenv("HERMES_GATEWAY_SESSION", "").lower() in ("1", "true", "yes") + assert is_gateway is True + + def test_cli_mode_respects_disabled(self, monkeypatch): + """In CLI mode (no gateway), redact_secrets: false should take effect.""" + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") + is_gateway = os.getenv("HERMES_GATEWAY_SESSION", "").lower() in ("1", "true", "yes") + user_disabled = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("0", "false", "no", "off") + enabled = True if is_gateway else not user_disabled + assert enabled is False + + def test_redaction_active_by_default(self, monkeypatch): + """With no env vars set, redaction should be enabled.""" + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) + is_gateway = os.getenv("HERMES_GATEWAY_SESSION", "").lower() in ("1", "true", "yes") + user_disabled = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("0", "false", "no", "off") + enabled = True if is_gateway else not user_disabled + assert enabled is True + + class TestRedactingFormatter: def test_formats_and_redacts(self): formatter = RedactingFormatter("%(message)s") diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index bbd11cd45ce58..ba4f5d1c11b9b 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -359,6 +359,16 @@ def test_tee_quoted_custom_hermes_home_env(self): assert dangerous is True assert key is not None + def test_tee_hermes_config_yaml(self): + dangerous, key, desc = detect_dangerous_command("echo x | tee ~/.hermes/config.yaml") + assert dangerous is True + assert key is not None + + def test_tee_custom_hermes_home_config_yaml(self): + dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/config.yaml") + assert dangerous is True + assert key is not None + def test_tee_tmp_safe(self): dangerous, key, desc = detect_dangerous_command("echo hello | tee /tmp/output.txt") assert dangerous is False @@ -370,6 +380,36 @@ def test_tee_local_file_safe(self): assert key is None +class TestSensitiveRedirectConfigYaml: + """Detect shell redirection writes to ~/.hermes/config.yaml. + + config.yaml contains security settings (redact_secrets, tirith, etc.) + that control the agent's own safety mechanisms. Writing to it should + require the same approval as writing to .env. + """ + + def test_redirect_to_hermes_config_yaml(self): + dangerous, key, desc = detect_dangerous_command("echo x > ~/.hermes/config.yaml") + assert dangerous is True + assert key is not None + + def test_redirect_to_custom_hermes_home_config(self): + dangerous, key, desc = detect_dangerous_command("echo x > $HERMES_HOME/config.yaml") + assert dangerous is True + assert key is not None + + def test_append_to_hermes_config_yaml(self): + dangerous, key, desc = detect_dangerous_command("echo x >> ~/.hermes/config.yaml") + assert dangerous is True + assert key is not None + + def test_redirect_to_unrelated_config_yaml_safe(self): + """config.yaml outside ~/.hermes/ should not be flagged.""" + dangerous, key, desc = detect_dangerous_command("echo x > ./config.yaml") + assert dangerous is False + assert key is None + + class TestFindExecFullPathRm: """Detect find -exec with full-path rm bypasses.""" @@ -412,6 +452,11 @@ def test_append_to_tilde_ssh_authorized_keys(self): assert dangerous is True assert key is not None + def test_redirect_to_hermes_config_yaml(self): + dangerous, key, desc = detect_dangerous_command("echo x > ~/.hermes/config.yaml") + assert dangerous is True + assert key is not None + def test_redirect_to_safe_tmp_file(self): dangerous, key, desc = detect_dangerous_command("echo hello > /tmp/output.txt") assert dangerous is False diff --git a/tools/approval.py b/tools/approval.py index 9a3a4ef260ee9..13f71e28e2268 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -62,10 +62,20 @@ def get_current_session_key(default: str = "default") -> str: r'(?:\$hermes_home|\$\{hermes_home\})/)' r'\.env\b' ) +# config.yaml contains security settings (redact_secrets, tirith, etc.) +# that control the agent's own safety mechanisms — protect it the same +# way we protect .env to prevent prompt injection from disabling guards. +_HERMES_CONFIG_PATH = ( + r'(?:~\/\.hermes/|' + r'(?:\$home|\$\{home\})/\.hermes/|' + r'(?:\$hermes_home|\$\{hermes_home\})/)' + r'config\.yaml\b' +) _SENSITIVE_WRITE_TARGET = ( r'(?:/etc/|/dev/sd|' rf'{_SSH_SENSITIVE_PATH}|' - rf'{_HERMES_ENV_PATH})' + rf'{_HERMES_ENV_PATH}|' + rf'{_HERMES_CONFIG_PATH})' ) # =========================================================================