feat(security): enable secret redaction by default (#17691, #20785) (#21193)

Flip the default for HERMES_REDACT_SECRETS from off to on so the redactor
already wired into send_message_tool, logs, and tool output actually runs
on a fresh install.

- agent/redact.py: env-var default "" → "true"
- hermes_cli/config.py: DEFAULT_CONFIG security.redact_secrets True;
  two config-template comments rewritten
- gateway/run.py + cli.py: startup log / banner warning when the user
  has explicitly opted out, so the downgrade is visible in agent.log
  and at CLI banner time
- docs/reference/environment-variables.md: description reconciled
- tests: flipped the default-pin, restructured the force=True
  regression test to explicit-false instead of unset

Users who need raw credential values (redactor development) can still
opt out via security.redact_secrets: false in config.yaml or
HERMES_REDACT_SECRETS=false in .env.

Closes #17691.
Addresses #20785 (short-term output-pipeline recommendation).
This commit is contained in:
Teknium 2026-05-07 05:10:33 -07:00 committed by GitHub
parent d856f4535d
commit fb1ce793e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 81 additions and 28 deletions

View file

@ -56,12 +56,15 @@ _SENSITIVE_BODY_KEYS = frozenset({
}) })
# Snapshot at import time so runtime env mutations (e.g. LLM-generated # Snapshot at import time so runtime env mutations (e.g. LLM-generated
# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction # `export HERMES_REDACT_SECRETS=false`) cannot disable redaction
# mid-session. OFF by default — user must opt in via # mid-session. ON by default — secure default per issue #17691. Users who
# `security.redact_secrets: true` in config.yaml (bridged to this env var # need raw credential values in tool output (e.g. working on the redactor
# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true` # itself) can opt out via `security.redact_secrets: false` in config.yaml
# in ~/.hermes/.env. # (bridged to this env var in hermes_cli/main.py, gateway/run.py, and
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on") # 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 # Known API key prefixes -- match the prefix + contiguous token chars
_PREFIX_PATTERNS = [ _PREFIX_PATTERNS = [

18
cli.py
View file

@ -10213,6 +10213,24 @@ class HermesCLI:
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
_welcome_color = "#FFF8DC" _welcome_color = "#FFF8DC"
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]") 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 # First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists
# after an OpenClaw→Hermes migration (especially migrations done by # after an OpenClaw→Hermes migration (especially migrations done by
# OpenClaw's own tool, which doesn't archive the source directory). # OpenClaw's own tool, which doesn't archive the source directory).

View file

@ -2860,6 +2860,29 @@ class GatewayRunner:
) )
except Exception: except Exception:
pass 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: try:
from hermes_cli.profiles import get_active_profile_name from hermes_cli.profiles import get_active_profile_name
_profile = get_active_profile_name() _profile = get_active_profile_name()

View file

@ -1191,7 +1191,7 @@ DEFAULT_CONFIG = {
# Pre-exec security scanning via tirith # Pre-exec security scanning via tirith
"security": { "security": {
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs) "allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
"redact_secrets": False, "redact_secrets": True,
"tirith_enabled": True, "tirith_enabled": True,
"tirith_path": "tirith", "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_timeout": 5,
@ -3978,10 +3978,10 @@ def load_config() -> Dict[str, Any]:
_SECURITY_COMMENT = """ _SECURITY_COMMENT = """
# ── Security ────────────────────────────────────────────────────────── # ── Security ──────────────────────────────────────────────────────────
# Secret redaction is OFF by default — tool output (terminal stdout, # Secret redaction is ON by default — strings that look like API keys,
# read_file results, web content) passes through unmodified. Set # tokens, and passwords are masked in tool output, logs, and chat
# redact_secrets to true to mask strings that look like API keys, tokens, # responses before the model or user ever sees them. Set redact_secrets
# and passwords before they enter the model context and logs. # to false to disable (e.g. when developing the redactor itself).
# tirith pre-exec scanning is enabled by default when the tirith binary # tirith pre-exec scanning is enabled by default when the tirith binary
# is available. Configure via security.tirith_* keys or env vars # is available. Configure via security.tirith_* keys or env vars
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN). # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
@ -4021,8 +4021,8 @@ _FALLBACK_COMMENT = """
_COMMENTED_SECTIONS = """ _COMMENTED_SECTIONS = """
# ── Security ────────────────────────────────────────────────────────── # ── Security ──────────────────────────────────────────────────────────
# Secret redaction is OFF by default. Set to true to mask strings that # Secret redaction is ON by default. Set to false to pass tool output,
# look like API keys, tokens, and passwords in tool output and logs. # logs, and chat responses through unmodified (e.g. for redactor dev).
# #
# security: # security:
# redact_secrets: true # redact_secrets: true

View file

@ -291,9 +291,11 @@ class TestCaptureLogSnapshotRedaction:
home = tmp_path / ".hermes" home = tmp_path / ".hermes"
home.mkdir() home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("HERMES_HOME", str(home))
# Critical: ensure the user has NOT opted in to redaction. The whole # Baseline fixture: no explicit env-var opinion. With the post-#17691
# point of this PR is that share-time redaction works for users who # default of ON, the default-path tests below exercise the
# never set this env var. # 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) monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs" logs_dir = home / "logs"
@ -324,21 +326,26 @@ class TestCaptureLogSnapshotRedaction:
assert _REDACT_FIXTURE_TOKEN in snap.tail_text assert _REDACT_FIXTURE_TOKEN in snap.tail_text
assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "") 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. """Regression test: redact_sensitive_text short-circuits without force=True.
If a future refactor drops `force=True` from `_redact_log_text`, this If a future refactor drops `force=True` from `_redact_log_text`, this
test fails immediately. Without `force=True`, the redactor returns the test fails immediately. Without `force=True`, the redactor returns the
input unchanged when HERMES_REDACT_SECRETS is unset, and the feature input unchanged when HERMES_REDACT_SECRETS=false, and the share-time
ships silently broken for its target audience. redaction feature ships silently broken for users who opted out of
runtime redaction (e.g. developers working on the redactor itself).
""" """
import os 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 from hermes_cli.debug import _capture_log_snapshot
# Belt-and-suspenders: confirm the env var is genuinely unset for this assert os.environ.get("HERMES_REDACT_SECRETS", "") == "false"
# test so we know we're exercising the force=True path.
assert os.environ.get("HERMES_REDACT_SECRETS", "") == ""
snap = _capture_log_snapshot("agent", tail_lines=10) snap = _capture_log_snapshot("agent", tail_lines=10)

View file

@ -72,11 +72,13 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path):
assert "ENV_VAR=false" in result.stdout assert "ENV_VAR=false" in result.stdout
def test_redact_secrets_default_false_when_unset(tmp_path): def test_redact_secrets_default_true_when_unset(tmp_path):
"""Without the config key, redaction stays OFF by default. """Without the config key or env var, redaction is ON by default (#17691).
Secret redaction is opt-in users who want it must set Secret redaction is a secure default users who need raw credential
`security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=true). 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 = tmp_path / ".hermes"
hermes_home.mkdir() hermes_home.mkdir()
@ -107,7 +109,7 @@ def test_redact_secrets_default_false_when_unset(tmp_path):
timeout=30, timeout=30,
) )
assert result.returncode == 0, f"probe failed: {result.stderr}" 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): def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path):

View file

@ -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_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_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_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_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_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). | | `HERMES_CORE_TOOLS` | Comma-separated override for the canonical core tool list (advanced; rarely needed). |