diff --git a/agent/redact.py b/agent/redact.py index afdee65288..1ac284cffd 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -56,12 +56,15 @@ _SENSITIVE_BODY_KEYS = frozenset({ }) # Snapshot at import time so runtime env mutations (e.g. LLM-generated -# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction -# mid-session. OFF by default — user must opt in via -# `security.redact_secrets: true` in config.yaml (bridged to this env var -# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true` -# in ~/.hermes/.env. -_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on") +# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction +# mid-session. ON by default — secure default per issue #17691. Users who +# need raw credential values in tool output (e.g. working on the redactor +# itself) can opt out via `security.redact_secrets: false` in config.yaml +# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and +# cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out +# warning is logged at gateway and CLI startup so operators see the +# downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py. +_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in ("1", "true", "yes", "on") # Known API key prefixes -- match the prefix + contiguous token chars _PREFIX_PATTERNS = [ diff --git a/cli.py b/cli.py index 1b2a81dfc4..c93a5dd073 100644 --- a/cli.py +++ b/cli.py @@ -10213,6 +10213,24 @@ class HermesCLI: _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_color = "#FFF8DC" self._console_print(f"[{_welcome_color}]{_welcome_text}[/]") + + # Redaction opt-out warning (#17691): ON by default, loud when off. + # The redactor snapshots its state at import time so any toggle now + # won't affect the running process — we just want the operator to + # see that they're running without the safety net. + try: + _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") + if _redact_raw.lower() not in ("1", "true", "yes", "on"): + self._console_print( + "[bold red]⚠ Secret redaction is DISABLED[/] " + f"(HERMES_REDACT_SECRETS={_redact_raw}). " + "API keys and tokens may appear verbatim in chat output, " + "session JSONs, and logs. Set " + "[cyan]security.redact_secrets: true[/] in config.yaml " + "to re-enable." + ) + except Exception: + pass # First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists # after an OpenClaw→Hermes migration (especially migrations done by # OpenClaw's own tool, which doesn't archive the source directory). diff --git a/gateway/run.py b/gateway/run.py index e6ba607c5a..ecddbf6a4f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2860,6 +2860,29 @@ class GatewayRunner: ) except Exception: pass + # Redaction status: ON by default (#17691). Surface a prominent + # warning if an operator has explicitly opted out so they don't + # forget the downgrade is active — the redactor snapshots its + # state at import time, so this log line is the source of truth + # for this process's lifetime. + try: + _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") + _redact_on = _redact_raw.lower() in ("1", "true", "yes", "on") + if _redact_on: + logger.info( + "Secret redaction: ENABLED (tool output, logs, and chat " + "responses are scrubbed before delivery)" + ) + else: + logger.warning( + "Secret redaction: DISABLED (HERMES_REDACT_SECRETS=%s). " + "API keys and tokens may appear verbatim in chat output, " + "session JSONs, and logs. Set security.redact_secrets: true " + "in config.yaml to re-enable.", + _redact_raw, + ) + except Exception: + pass try: from hermes_cli.profiles import get_active_profile_name _profile = get_active_profile_name() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index baf73c2ea5..6753ae3de0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1191,7 +1191,7 @@ DEFAULT_CONFIG = { # Pre-exec security scanning via tirith "security": { "allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs) - "redact_secrets": False, + "redact_secrets": True, "tirith_enabled": True, "tirith_path": "tirith", "tirith_timeout": 5, @@ -3978,10 +3978,10 @@ def load_config() -> Dict[str, Any]: _SECURITY_COMMENT = """ # ── Security ────────────────────────────────────────────────────────── -# Secret redaction is OFF by default — tool output (terminal stdout, -# read_file results, web content) passes through unmodified. Set -# redact_secrets to true to mask strings that look like API keys, tokens, -# and passwords before they enter the model context and logs. +# Secret redaction is ON by default — strings that look like API keys, +# tokens, and passwords are masked in tool output, logs, and chat +# responses before the model or user ever sees them. Set redact_secrets +# to false to disable (e.g. when developing the redactor itself). # tirith pre-exec scanning is enabled by default when the tirith binary # is available. Configure via security.tirith_* keys or env vars # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN). @@ -4021,8 +4021,8 @@ _FALLBACK_COMMENT = """ _COMMENTED_SECTIONS = """ # ── Security ────────────────────────────────────────────────────────── -# Secret redaction is OFF by default. Set to true to mask strings that -# look like API keys, tokens, and passwords in tool output and logs. +# Secret redaction is ON by default. Set to false to pass tool output, +# logs, and chat responses through unmodified (e.g. for redactor dev). # # security: # redact_secrets: true diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index b83023a76a..1996e7fce9 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -291,9 +291,11 @@ class TestCaptureLogSnapshotRedaction: home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - # Critical: ensure the user has NOT opted in to redaction. The whole - # point of this PR is that share-time redaction works for users who - # never set this env var. + # Baseline fixture: no explicit env-var opinion. With the post-#17691 + # default of ON, the default-path tests below exercise the + # secure-default behaviour. The `force=True` regression test + # setenvs to "false" inline to prove force=True works even when + # the runtime flag is disabled. monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) logs_dir = home / "logs" @@ -324,21 +326,26 @@ class TestCaptureLogSnapshotRedaction: assert _REDACT_FIXTURE_TOKEN in snap.tail_text assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "") - def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret): + def test_force_true_works_when_redaction_disabled( + self, hermes_home_with_secret, monkeypatch + ): """Regression test: redact_sensitive_text short-circuits without force=True. If a future refactor drops `force=True` from `_redact_log_text`, this test fails immediately. Without `force=True`, the redactor returns the - input unchanged when HERMES_REDACT_SECRETS is unset, and the feature - ships silently broken for its target audience. + input unchanged when HERMES_REDACT_SECRETS=false, and the share-time + redaction feature ships silently broken for users who opted out of + runtime redaction (e.g. developers working on the redactor itself). """ import os + # Force the runtime flag off so we're exercising the force=True path, + # not the default-on path. + monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") + from hermes_cli.debug import _capture_log_snapshot - # Belt-and-suspenders: confirm the env var is genuinely unset for this - # test so we know we're exercising the force=True path. - assert os.environ.get("HERMES_REDACT_SECRETS", "") == "" + assert os.environ.get("HERMES_REDACT_SECRETS", "") == "false" snap = _capture_log_snapshot("agent", tail_lines=10) diff --git a/tests/hermes_cli/test_redact_config_bridge.py b/tests/hermes_cli/test_redact_config_bridge.py index cf759e0538..00dac40b21 100644 --- a/tests/hermes_cli/test_redact_config_bridge.py +++ b/tests/hermes_cli/test_redact_config_bridge.py @@ -72,11 +72,13 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path): assert "ENV_VAR=false" in result.stdout -def test_redact_secrets_default_false_when_unset(tmp_path): - """Without the config key, redaction stays OFF by default. +def test_redact_secrets_default_true_when_unset(tmp_path): + """Without the config key or env var, redaction is ON by default (#17691). - Secret redaction is opt-in — users who want it must set - `security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=true). + Secret redaction is a secure default — users who need raw credential + values in tool output (e.g. working on the redactor itself) must set + `security.redact_secrets: false` explicitly (or + `HERMES_REDACT_SECRETS=false`). """ hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -107,7 +109,7 @@ def test_redact_secrets_default_false_when_unset(tmp_path): timeout=30, ) assert result.returncode == 0, f"probe failed: {result.stderr}" - assert "REDACT_ENABLED=False" in result.stdout + assert "REDACT_ENABLED=True" in result.stdout def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path): diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 7aa635bd44..bfb2e2ebbf 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -456,7 +456,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) | | `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time. | | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. | -| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in logs and shareable outputs (default: `true`). | +| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). | | `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that restricts `write_file`/`patch` writes; paths outside require approval. | | `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the "file changed since you read it" guard on `patch`/`write_file`. | | `HERMES_CORE_TOOLS` | Comma-separated override for the canonical core tool list (advanced; rarely needed). |