Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
5 changes: 4 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions tests/agent/test_redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions tests/tools/test_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})'
)

# =========================================================================
Expand Down