{"repo":"NousResearch/hermes-agent","public_id":"20785","display_label":"Secret redaction in agent output and chat","summary":"The members share a theme of preventing secrets or credentials from appearing in agent output: one proposes enabling secret redaction by default, another redacts secrets from assistant output, and a third reports credential leakage in chat and reasoning blocks. The titles alone do not show how these relate in implementation or what their current status is.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21193,"merged_at":"2026-05-07T12:10:33+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-07T12:10:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":17691,"pr_number":21193,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T12:10:33+00:00"},{"issue_number":20785,"pr_number":21193,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-07T12:10:33+00:00"}],"members":[{"kind":"issue","number":20785,"title":"Credential leakage in agent output: systemic failure to redact secrets in chat and reasoning blocks","url":null,"state":"closed"},{"kind":"pr","number":21044,"title":"fix(agent): redact secrets from assistant output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19798","number":19798},{"kind":"pr","value":"#21193","number":21193},{"kind":"pr","value":"#20785","number":20785}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21193,"title":"feat(security): enable secret redaction by default (#17691, #20785)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","cli.py","gateway/run.py","hermes_cli/config.py","tests/hermes_cli/test_debug.py","tests/hermes_cli/test_redact_config_bridge.py","website/docs/reference/environment-variables.md"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -56,12 +56,15 @@\n })\n \n # Snapshot at import time so runtime env mutations (e.g. LLM-generated\n-# `export HERMES_REDACT_SECRETS=[redacted:secret-assignment] cannot enable/disable redaction\n-# mid-session.  OFF by default — user must opt in via\n-# `security.redact_secrets: true` in config.yaml (bridged to this env var\n-# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`\n-# in ~/.hermes/.env.\n-_REDACT_ENABLED = os.getenv(\"HERMES_REDACT_SECRETS\", \"\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n+# `export HERMES_REDACT_SECRETS=[redacted:secret-assignment] cannot disable redaction\n+# mid-session.  ON by default — secure default per issue #17691. Users who\n+# need raw credential values in tool output (e.g. working on the redactor\n+# itself) can opt out via `security.redact_secrets: false` in config.yaml\n+# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and\n+# cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out\n+# warning is logged at gateway and CLI startup so operators see the\n+# downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py.\n+_REDACT_ENABLED = os.getenv(\"HERMES_REDACT_SECRETS\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n \n # Known API key prefixes -- match the prefix + contiguous token chars\n _PREFIX_PATTERNS = ["]},{"file":"cli.py","hunks":["@@ -10213,6 +10213,24 @@ def run(self):\n             _welcome_text = \"Welcome to Hermes Agent! Type your message or /help for commands.\"\n             _welcome_color = \"#FFF8DC\"\n         self._console_print(f\"[{_welcome_color}]{_welcome_text}[/]\")\n+\n+        # Redaction opt-out warning (#17691): ON by default, loud when off.\n+        # The redactor snapshots its state at import time so any toggle now\n+        # won't affect the running process — we just want the operator to\n+        # see that they're running without the safety net.\n+        try:\n+            _redact_raw = os.getenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+            if _redact_raw.lower() not in (\"1\", \"true\", \"yes\", \"on\"):\n+                self._console_print(\n+                    \"[bold red]⚠  Secret redaction is DISABLED[/] \"\n+                    f\"(HERMES_REDACT_SECRETS={_redact_raw}). \"\n+                    \"API keys and tokens may appear verbatim in chat output, \"\n+                    \"session JSONs, and logs. Set \"\n+                    \"[cyan]security.redact_secrets: [redacted:secret-assignment] in config.yaml \"\n+                    \"to re-enable.\"\n+                )\n+        except Exception:\n+            pass\n         # First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists\n         # after an OpenClaw→Hermes migration (especially migrations done by\n         # OpenClaw's own tool, which doesn't archive the source directory)."]},{"file":"gateway/run.py","hunks":["@@ -2763,6 +2763,29 @@ async def start(self) -> bool:\n             )\n         except Exception:\n             pass\n+        # Redaction status: ON by default (#17691). Surface a prominent\n+        # warning if an operator has explicitly opted out so they don't\n+        # forget the downgrade is active — the redactor snapshots its\n+        # state at import time, so this log line is the source of truth\n+        # for this process's lifetime.\n+        try:\n+            _redact_raw = os.getenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+            _redact_on = _redact_raw.lower() in (\"1\", \"true\", \"yes\", \"on\")\n+            if _redact_on:\n+                logger.info(\n+                    \"Secret redaction: ENABLED (tool output, logs, and chat \"\n+                    \"responses are scrubbed before delivery)\"\n+                )\n+            else:\n+                logger.warning(\n+                    \"Secret redaction: DISABLED (HERMES_REDACT_SECRETS=%s). \"\n+                    \"API keys and tokens may appear verbatim in chat output, \"\n+                    \"session JSONs, and logs. Set security.redact_secrets: true \"\n+                    \"in config.yaml to re-enable.\",\n+                    _redact_raw,\n+                )\n+        except Exception:\n+            pass\n         try:\n             from hermes_cli.profiles import get_active_profile_name\n             _profile = get_active_profile_name()"]},{"file":"hermes_cli/config.py","hunks":["@@ -1191,7 +1191,7 @@ def _ensure_hermes_home_managed(home: Path):\n     # Pre-exec security scanning via tirith\n     \"security\": {\n         \"allow_private_urls\": False,  # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)\n-        \"redact_secrets\": [redacted:secret-assignment]\n+        \"redact_secrets\": True,\n         \"tirith_enabled\": True,\n         \"tirith_path\": \"tirith\",\n         \"tirith_timeout\": 5,","@@ -3974,10 +3974,10 @@ def load_config() -> Dict[str, Any]:\n \n _SECURITY_COMMENT = \"\"\"\n # ── Security ──────────────────────────────────────────────────────────\n-# Secret redaction is OFF by default — tool output (terminal stdout,\n-# read_file results, web content) passes through unmodified. Set\n-# redact_secrets to true to mask strings that look like API keys, tokens,\n-# and passwords before they enter the model context and logs.\n+# Secret redaction is ON by default — strings that look like API keys,\n+# tokens, and passwords are masked in tool output, logs, and chat\n+# responses before the model or user ever sees them. Set redact_secrets\n+# to false to disable (e.g. when developing the redactor itself).\n # tirith pre-exec scanning is enabled by default when the tirith binary\n # is available. Configure via security.tirith_* keys or env vars\n # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).","@@ -4017,8 +4017,8 @@ def load_config() -> Dict[str, Any]:\n \n _COMMENTED_SECTIONS = \"\"\"\n # ── Security ──────────────────────────────────────────────────────────\n-# Secret redaction is OFF by default. Set to true to mask strings that\n-# look like API keys, tokens, and passwords in tool output and logs.\n+# Secret redaction is ON by default. Set to false to pass tool output,\n+# logs, and chat responses through unmodified (e.g. for redactor dev).\n #\n # security:\n #   redact_secrets: true"]},{"file":"tests/hermes_cli/test_debug.py","hunks":["@@ -291,9 +291,11 @@ def hermes_home_with_secret(self, tmp_path, monkeypatch):\n         home = tmp_path / \".hermes\"\n         home.mkdir()\n         monkeypatch.setenv(\"HERMES_HOME\", str(home))\n-        # Critical: ensure the user has NOT opted in to redaction. The whole\n-        # point of this PR is that share-time redaction works for users who\n-        # never set this env var.\n+        # Baseline fixture: no explicit env-var opinion. With the post-#17691\n+        # default of ON, the default-path tests below exercise the\n+        # secure-default behaviour. The `force=True` regression test\n+        # setenvs to \"false\" inline to prove force=True works even when\n+        # the runtime flag is disabled.\n         monkeypatch.delenv(\"HERMES_REDACT_SECRETS\", raising=False)\n \n         logs_dir = home / \"logs\"","@@ -324,21 +326,26 @@ def test_redact_false_passes_through(self, hermes_home_with_secret):\n         assert _REDACT_FIXTURE_TOKEN in snap.tail_text\n         assert _REDACT_FIXTURE_TOKEN in (snap.full_text or \"\")\n \n-    def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret):\n+    def test_force_true_works_when_redaction_disabled(\n+        self, hermes_home_with_secret, monkeypatch\n+    ):\n         \"\"\"Regression test: redact_sensitive_text short-circuits without force=True.\n \n         If a future refactor drops `force=True` from `_redact_log_text`, this\n         test fails immediately. Without `force=True`, the redactor returns the\n-        input unchanged when HERMES_REDACT_SECRETS is unset, and the feature\n-        ships silently broken for its target audience.\n+        input unchanged when HERMES_REDACT_SECRETS=[redacted:secret-assignment] and the share-time\n+        redaction feature ships silently broken for users who opted out of\n+        runtime redaction (e.g. developers working on the redactor itself).\n         \"\"\"\n         import os\n \n+        # Force the runtime flag off so we're exercising the force=True path,\n+        # not the default-on path.\n+        monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"false\")\n+\n         from hermes_cli.debug import _capture_log_snapshot\n \n-        # Belt-and-suspenders: confirm the env var is genuinely unset for this\n-        # test so we know we're exercising the force=True path.\n-        assert os.environ.get(\"HERMES_REDACT_SECRETS\", \"\") == \"\"\n+        assert os.environ.get(\"HERMES_REDACT_SECRETS\", \"\") == \"false\"\n \n         snap = _capture_log_snapshot(\"agent\", tail_lines=10)\n "]},{"file":"tests/hermes_cli/test_redact_config_bridge.py","hunks":["@@ -72,11 +72,13 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path):\n     assert \"ENV_VAR=false\" in result.stdout\n \n \n-def test_redact_secrets_default_false_when_unset(tmp_path):\n-    \"\"\"Without the config key, redaction stays OFF by default.\n+def test_redact_secrets_default_true_when_unset(tmp_path):\n+    \"\"\"Without the config key or env var, redaction is ON by default (#17691).\n \n-    Secret redaction is opt-in — users who want it must set\n-    `security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=[redacted:secret-assignment]\n+    Secret redaction is a secure default — users who need raw credential\n+    values in tool output (e.g. working on the redactor itself) must set\n+    `security.redact_secrets: false` explicitly (or\n+    `HERMES_REDACT_SECRETS=[redacted:secret-assignment]\n     \"\"\"\n     hermes_home = tmp_path / \".hermes\"\n     hermes_home.mkdir()","@@ -107,7 +109,7 @@ def test_redact_secrets_default_false_when_unset(tmp_path):\n         timeout=30,\n     )\n     assert result.returncode == 0, f\"probe failed: {result.stderr}\"\n-    assert \"REDACT_ENABLED=False\" in result.stdout\n+    assert \"REDACT_ENABLED=True\" in result.stdout\n \n \n def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path):"]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -456,7 +456,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us\n | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) |\n | `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time. |\n | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. |\n-| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in logs and shareable outputs (default: `true`). |\n+| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). |\n | `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that restricts `write_file`/`patch` writes; paths outside require approval. |\n | `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the \"file changed since you read it\" guard on `patch`/`write_file`. |\n | `HERMES_CORE_TOOLS` | Comma-separated override for the canonical core tool list (advanced; rarely needed). |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}