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
# `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 = [

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_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).

View file

@ -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()

View file

@ -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

View file

@ -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)

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
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):

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_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). |