Secret redaction gaps in logs, chat, and debug uploads
NousResearch/hermes-agent · cluster 17691 · agent
Data as of
4 open items. Fix merged in #19318. Check it covers your case.
The short answer
- Known?
- Yes. 24 related items grouped here, 7 issues already closed.
- Fix event
- A fix was merged to main in PR #19318. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #19318, #21193, #53907, #56125. That is separate from the fix event. - Still open
- 2 issues and 2 PRs.
Next step: read #19318, then check whether your report is one of the 2 still open.
Read #19318Pick up a Lab taskMultiple reports and fixes address credentials and API keys leaking through outbound chat messages, gateway logs, and the debug-share upload feature, alongside work to enable redaction by default and require user consent before uploading logs.
Open issues2
Open PRs2
Fixes on record
-
#19318 fix(debug): redact log content at upload time in hermes debug share
files:
hermes_cli/debug.py,hermes_cli/main.py,scripts/release.py,tests/hermes_cli/test_debug.pyShow the diff
-
hermes_cli/debug.py@@ -1,12 +1,19 @@ -"""``hermes debug`` — debug tools for Hermes Agent. +"""``hermes debug`` debug tools for Hermes Agent. Currently supports: hermes debug share Upload debug report (system info + logs) to a paste service and print a shareable URL. + By default, log content is run through + ``agent.redact.redact_sensitive_text`` with + ``force=True`` before upload so credentials in + ``~/.hermes/logs/*.log`` are not leaked into + the public paste service. Pass ``--no-redact`` + to disable. """ import io import json +import logging import sys import time import urllib.error @@ -19,6 +26,16 @@ from hermes_constants import get_hermes_home from utils import atomic_replace +logger = logging.getLogger(__name__) + +# Banner prepended to upload-bound log content when redaction is enabled. +# Visible in the public paste so reviewers know the content was sanitized. +# Kept short; the trailing newline guarantees the banner sits on its own line. +_REDACTION_BANNER = ( + "[hermes debug share: log content redacted at upload time. " + "run with --no-redact to disable]\n" +) + # --------------------------------------------------------------------------- # Paste services — try paste.rs first, dpaste.com as fallback. @@ -368,17 +385,40 @@ def _resolve_log_path(log_name: str) -> Optional[Path]: return None +def _redact_log_text(text: str) -> str: + """Run ``redact_sensitive_text`` with ``force=True`` over upload-bound text. + + Uses ``force=True`` so redaction fires regardless of the operator's + ``security.redact_secrets`` setting. The local on-disk log file is + not modified; only the in-memory copy headed for the public paste + service is sanitized. Returns the redacted text (or the original + when empty / non-string). + """ + if not text: + return text + from agent.redact import redact_sensitive_text + + return redact_sensitive_text(text, force=True) + + def _capture_log_snapshot( log_name: str, *, tail_lines: int, max_bytes: int = _MAX_LOG_BYTES, + redact: bool = True, ) -> LogSnapshot: """Capture a log once and derive summary/full-log views from it. The report tail and standalone log upload must come from the same file snapshot. Otherwise a rotation/truncate between reads can make the report look newer than the uploaded ``agent.log`` paste. + + When ``redact`` is True (the default), both ``tail_text`` and + ``full_text`` are run through ``_redact_log_text`` so the snapshot + returned is upload-safe. The on-disk log file is never modified. + Pass ``redact=False`` to capture original log content (used by + ``hermes debug share --no-redact``). """ log_path = _resolve_log_path(log_name) if log_path is None: @@ -438,18 +478,34 @@ def _capture_log_snapshot( if truncated: full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}" + if redact: + tail_text = _redact_log_text(tail_text) + full_text = _redact_log_text(full_text) + return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text) except Exception as exc: return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None) -def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]: - """Capture all logs used by debug-share exactly once.""" +def _capture_default_log_snapshots( + log_lines: int, *, redact: bool = True +) -> dict[str, LogSnapshot]: + """Capture all logs used by debug-share exactly once. + + ``redact`` is forwarded to each ``_capture_log_snapshot`` call so all + captured logs share the same redaction policy for a given run. + """ errors_lines = min(log_lines, 100) return { - "agent": _capture_log_snapshot("agent", tail_lines=log_lines), - "errors": _capture_log_snapshot("errors", tail_lines=errors_lines), - "gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines), + "agent": _capture_log_snapshot( + "agent", tail_lines=log_lines, redact=redact + ), + "errors": _capture_log_snapshot( + "errors", tail_lines=errors_lines, redact=redact + ), + "gateway": _capture_log_snapshot( + "gateway", tail_lines=errors_lines, redact=redact + ), } @@ -532,6 +588,7 @@ def run_debug_share(args): log_lines = getattr(args, "lines", 200) expiry = getattr(args, "expire", 7) local_only = getattr(args, "local", False) + redact = not getattr(args, "no_redact", False) if not local_only: print(_PRIVACY_NOTICE) @@ -539,8 +596,16 @@ def run_debug_share(args): print("Collecting debug report...") # Capture dump once — prepended to every paste for context. + # The dump is already redacted at extract time via dump.py:_redact; + # log_snapshots are redacted by _capture_default_log_snapshots when + # redact=True so credentials never reach the public paste service. dump_text = _capture_dump() - log_snapshots = _capture_default_log_snapshots(log_lines) + log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact) + + if redact: + logger.info( + "hermes debug share: applied force-mode redaction to log snapshots before upload" + ) report = collect_debug_report( log_lines=log_lines, @@ -556,6 +621,15 @@ def run_debug_share(args): if gateway_log: gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log + # Visible banner so reviewers reading the public paste know redaction + # was applied at upload time. Banner is omitted under --no-redact. + if redact: + report = _REDACTION_BANNER + report + if agent_log: + agent_log = _REDACTION_BANNER + agent_log + if gateway_log: + gateway_log = _REDACTION_BANNER + gateway_log + if local_only: print(report) if agent_log: @@ -666,6 +740,7 @@ def run_debug(args): print(" --lines N Number of log lines to include (default: 200)") print(" --expire N Paste expiry in days (default: 7)") print(" --local Print report locally instead of uploading") + print(" --no-redact Disable upload-time secret redaction (default: redact)") print() print("Options (delete):") print(" <url> ... One or more paste URLs to delete") -
hermes_cli/main.py@@ -8891,6 +8891,7 @@ def main(): hermes debug share --lines 500 Include more log lines hermes debug share --expire 30 Keep paste for 30 days hermes debug share --local Print report locally (no upload) + hermes debug share --no-redact Disable upload-time secret redaction hermes debug delete <url> Delete a previously uploaded paste """, ) @@ -8916,6 +8917,16 @@ def main(): action="store_true", help="Print the report locally instead of uploading", ) + share_parser.add_argument( + "--no-redact", + action="store_true", + help=( + "Disable upload-time secret redaction (default: redact). Logs " + "are normally run through agent.redact.redact_sensitive_text " + "with force=True before upload so credentials are not leaked " + "into the public paste service." + ), + ) delete_parser = debug_sub.add_parser( "delete", help="Delete a paste uploaded by 'hermes debug share'", -
scripts/release.py@@ -678,6 +678,8 @@ "[redacted:email]": "chengoak", # PR #17467 "[redacted:email]": "YuShu", # co-author only "[redacted:email]": "CharlieKerfoot", # PR #18951 + # Debug share upload-time redaction (May 2026) + "[redacted:email]": "GodsBoy", # PR #19318 } -
tests/hermes_cli/test_debug.py@@ -273,6 +273,101 @@ def test_falls_back_when_primary_empty(self, hermes_home): assert "rotated agent data" in snap.full_text +# --------------------------------------------------------------------------- +# Capture log redaction (force=True applies regardless of HERMES_REDACT_SECRETS) +# --------------------------------------------------------------------------- + +# A vendor-prefixed token used across redaction tests. Long enough to clear +# the redactor's `floor` parameter so it actually masks rather than fully blanks. +_REDACT_FIXTURE_TOKEN = "[redacted:secret-assignment]" + + +class TestCaptureLogSnapshotRedaction: + """Pin upload-time redaction at the _capture_log_snapshot boundary.""" + + @pytest.fixture + def hermes_home_with_secret(self, tmp_path, monkeypatch): + """Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token.""" + 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. + monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) + + logs_dir = home / "logs" + logs_dir.mkdir() + (logs_dir / "agent.log").write_text( + f"2026-04-12 17:00:00 INFO config: api_key=[redacted:secret-assignment] loaded\n" + ) + (logs_dir / "errors.log").write_text("") + (logs_dir / "gateway.log").write_text("") + return home + + def test_default_redacts_tail_and_full_text(self, hermes_home_with_secret): + from hermes_cli.debug import _capture_log_snapshot + + snap = _capture_log_snapshot("agent", tail_lines=10) + + # Both views the upload uses must be sanitized. + assert _REDACT_FIXTURE_TOKEN not in snap.tail_text + assert snap.full_text is not None + assert _REDACT_FIXTURE_TOKEN not in snap.full_text + + def test_redact_false_passes_through(self, hermes_home_with_secret): + from hermes_cli.debug import _capture_log_snapshot + + snap = _capture_log_snapshot("agent", tail_lines=10, redact=False) + + # Original token survives when the caller opts out. + 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): + """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. + """ + import os + + 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", "") == "" + + snap = _capture_log_snapshot("agent", tail_lines=10) + + assert _REDACT_FIXTURE_TOKEN not in snap.tail_text + assert snap.full_text is not None + assert _REDACT_FIXTURE_TOKEN not in snap.full_text + + def test_capture_default_log_snapshots_threads_redact( + self, hermes_home_with_secret + ): + from hermes_cli.debug import _capture_default_log_snapshots + + snaps = _capture_default_log_snapshots(50) + + # Default threads redact=True to all three captured logs. + assert _REDACT_FIXTURE_TOKEN not in snaps["agent"].tail_text + assert _REDACT_FIXTURE_TOKEN not in (snaps["agent"].full_text or "") + + def test_capture_default_log_snapshots_no_redact_passes_through( + self, hermes_home_with_secret + ): + from hermes_cli.debug import _capture_default_log_snapshots + + snaps = _capture_default_log_snapshots(50, redact=False) + + assert _REDACT_FIXTURE_TOKEN in snaps["agent"].tail_text + assert _REDACT_FIXTURE_TOKEN in (snaps["agent"].full_text or "") + + # --------------------------------------------------------------------------- # Debug report collection # --------------------------------------------------------------------------- @@ -556,6 +651,124 @@ def test_share_exits_on_report_upload_failure(self, hermes_home, capsys): assert "all failed" in out.err +# --------------------------------------------------------------------------- +# Share-time redaction wiring + visible banner +# --------------------------------------------------------------------------- + +class TestRunDebugShareRedaction: + """End-to-end: --no-redact flag, banner injection, default behavior.""" + + @pytest.fixture + def hermes_home_with_secret(self, tmp_path, monkeypatch): + """Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) + + logs_dir = home / "logs" + logs_dir.mkdir() + (logs_dir / "agent.log").write_text( + f"2026-04-12 17:00:00 INFO config: api_key=[redacted:secret-assignment] loaded\n" + ) + (logs_dir / "errors.log").write_text("") + (logs_dir / "gateway.log").write_text( + f"2026-04-12 17:00:01 INFO gateway.run: token {_REDACT_FIXTURE_TOKEN}\n" + ) + return home + + def test_default_share_redacts_uploaded_content( + self, hermes_home_with_secret, capsys + ): + """The uploaded report and full-log pastes do not contain the raw token.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + args.no_redact = False + + captured: list[str] = [] + + def fake_upload(content, expiry_days=7): + captured.append(content) + return f"https://paste.rs/{len(captured)}" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ + patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload): + run_debug_share(args) + + # At least the report plus one full log paste reached the upload path. + assert len(captured) >= 2 + for content in captured: + assert _REDACT_FIXTURE_TOKEN not in content, ( + "raw token leaked into upload-bound content" + ) + + def test_default_share_includes_redaction_banner( + self, hermes_home_with_secret, capsys + ): + """Each upload-bound paste carries the visible redaction banner.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + args.no_redact = False + + captured: list[str] = [] + + def fake_upload(content, expiry_days=7): + captured.append(content) + return f"https://paste.rs/{len(captured)}" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ + patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload): + run_debug_share(args) + + for content in captured: + assert "redacted at upload time" in content, ( + "redaction banner missing from upload-bound content" + ) + + def test_no_redact_flag_disables_redaction_and_banner( + self, hermes_home_with_secret, capsys + ): + """--no-redact preserves original log content and omits the banner.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + args.no_redact = True + + captured: list[str] = [] + + def fake_upload(content, expiry_days=7): + captured.append(content) + return f"https://paste.rs/{len(captured)}" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ + patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload): + run_debug_share(args) + + # The agent.log paste should now contain the raw token. + assert any(_REDACT_FIXTURE_TOKEN in c for c in captured), ( + "expected raw token in --no-redact upload" + ) + # No banner anywhere when redaction is disabled. + for content in captured: + assert "redacted at upload time" not in content, ( + "banner present with --no-redact" + ) + + # --------------------------------------------------------------------------- # run_debug router # ---------------------------------------------------------------------------
-
-
#21193 feat(security): enable secret redaction by default (#17691, #20785)
files:
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.mdShow the diff
-
agent/redact.py@@ -56,12 +56,15 @@ }) # Snapshot at import time so runtime env mutations (e.g. LLM-generated -# `export HERMES_REDACT_SECRETS=[redacted:secret-assignment] 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=[redacted:secret-assignment] 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: [redacted:secret-assignment] in config.yaml +# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and +# cli.py) or `HERMES_REDACT_SECRETS=[redacted:secret-assignment] 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 = [ -
cli.py@@ -10213,6 +10213,24 @@ def run(self): _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=[redacted:secret-assignment] " + "API keys and tokens may appear verbatim in chat output, " + "session JSONs, and logs. Set " + "[cyan]security.redact_secrets: [redacted:secret-assignment] 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). -
gateway/run.py@@ -2763,6 +2763,29 @@ async def start(self) -> bool: ) 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() -
hermes_cli/config.py@@ -1191,7 +1191,7 @@ def _ensure_hermes_home_managed(home: Path): # 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, @@ -3974,10 +3974,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). @@ -4017,8 +4017,8 @@ def load_config() -> Dict[str, Any]: _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 -
tests/hermes_cli/test_debug.py@@ -291,9 +291,11 @@ def hermes_home_with_secret(self, tmp_path, monkeypatch): 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 @@ def test_redact_false_passes_through(self, hermes_home_with_secret): 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=[redacted:secret-assignment] 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) -
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=[redacted:secret-assignment] + 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: [redacted:secret-assignment] explicitly (or + `HERMES_REDACT_SECRETS=[redacted:secret-assignment] """ 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): -
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). |
-
-
#53907 fix(gateway): redact full credential set on outbound chat path (#23810)
files:
gateway/run.py,tests/gateway/test_telegram_noise_filter.py -
#56125 fix(security): require explicit consent before uploading debug logs
files:
hermes_cli/cli_commands_mixin.py,hermes_cli/debug.py,hermes_cli/subcommands/debug.py,tests/hermes_cli/test_debug.pyShow the diff
-
hermes_cli/cli_commands_mixin.py@@ -2593,7 +2593,12 @@ def _handle_debug_command(self, cmd_original: str = ""): words = {w.lower() for w in cmd_original.split()[1:]} local = "local" in words nous = "nous" in words and not local - args = SimpleNamespace(lines=200, expire=7, local=local, nous=nous) + # Typing the /debug slash command is itself the explicit consent to + # upload, so we pass yes=True to skip run_debug_share's [y/N] prompt. + # input() would hang inside prompt_toolkit's event loop anyway. + args = SimpleNamespace( + lines=200, expire=7, local=local, nous=nous, yes=True + ) run_debug_share(args) def _handle_update_command(self) -> bool: -
hermes_cli/debug.py@@ -196,15 +196,19 @@ def _best_effort_sweep_expired_pastes() -> None: # --------------------------------------------------------------------------- _PRIVACY_NOTICE = """\ -⚠️ This will upload the following to a public paste service: - • System info (OS, Python version, Hermes version, provider, which API keys - are configured — NOT the actual keys) - • Recent log lines (agent.log, errors.log, gateway.log, gui.log, desktop.log - — may contain conversation fragments and file paths) - • Full agent.log, gateway.log, gui.log, and desktop.log (up to 512 KB each — - likely contains conversation content, tool outputs, and file paths) - -Pastes auto-delete after 6 hours. +⚠️ This will upload system info + logs to a PUBLIC paste service. + +Cryptographic secrets (API keys, tokens, passwords) are redacted before +upload, but the following personal data is NOT redacted and will be public: + • Your display name and persistent platform user ID + • Verbatim content of your recent messages (prompts, responses, tool output) + • Local filesystem paths + • Any other PII present in the logs + +The resulting URL is public to anyone who has the link. Pastes auto-delete +after 6 hours, but may be archived by third parties in the meantime. + +Use --local to view the report without uploading. """ _GATEWAY_PRIVACY_NOTICE = ( @@ -774,6 +778,38 @@ def build_debug_share( ) +def _confirm_upload(args) -> bool: + """Require explicit consent before any debug-share upload. + + The privacy notice is printed by the caller. This gates the actual + upload: with ``--yes`` (or ``-y``) we proceed unprompted; otherwise we + ask an interactive ``[y/N]`` question. In a non-interactive context + (no TTY on stdin — scripts, CI, piped input) we refuse rather than + hang or upload silently, so debug data can't be exposed without a + deliberate ``--yes``. + + Returns True to proceed with the upload, False to abort. + """ + if bool(getattr(args, "yes", False)): + return True + if not sys.stdin.isatty(): + print( + "ERROR: Non-interactive mode requires --yes to confirm upload.\n" + " This prevents accidental exposure of personal data.\n" + " Use --local to view the report without uploading.", + file=sys.stderr, + ) + sys.exit(1) + try: + answer = input("Upload debug report? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + if answer not in ("y", "yes"): + print("Aborted.") + return False + return True + + def run_debug_share(args): """Collect debug report + full logs, upload each, print URLs.""" log_lines = getattr(args, "lines", 200) @@ -805,10 +841,12 @@ def run_debug_share(args): return if nous: - _run_debug_share_nous(log_lines=log_lines, redact=redact) + _run_debug_share_nous(args, log_lines=log_lines, redact=redact) return print(_PRIVACY_NOTICE) + if not _confirm_upload(args): + return print("Collecting debug report...") print("Uploading...") @@ -856,7 +894,7 @@ def run_debug_share(args): """ -def _run_debug_share_nous(*, log_lines: int, redact: bool) -> None: +def _run_debug_share_nous(args, *, log_lines: int, redact: bool) -> None: """Handle ``hermes debug share --nous``: upload the bundle to Nous-S3. Collects the same force-redacted bundle as the paste path, gzips it into @@ -867,6 +905,8 @@ def _run_debug_share_nous(*, log_lines: int, redact: bool) -> None: from hermes_cli.diagnostics_upload import share_to_nous print(_NOUS_PRIVACY_NOTICE) + if not _confirm_upload(args): + return if not redact: print( "⚠️ --no-redact is set: secrets in your logs will NOT be redacted " -
hermes_cli/subcommands/debug.py@@ -24,7 +24,8 @@ def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None: formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: - hermes debug share Upload debug report and print URL + hermes debug share Upload debug report (asks for confirmation) + hermes debug share --yes Skip confirmation (for scripts/CI) hermes debug share --lines 500 Include more log lines hermes debug share --expire 30 Keep paste for 30 days hermes debug share --local Print report locally (no upload) @@ -55,6 +56,16 @@ def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None: action="store_true", help="Print the report locally instead of uploading", ) + share_parser.add_argument( + "-y", + "--yes", + action="store_true", + help=( + "Skip the confirmation prompt and upload immediately. Required " + "in non-interactive contexts (scripts/CI); without it, and with " + "no TTY on stdin, the command refuses rather than upload silently." + ), + ) share_parser.add_argument( "--no-redact", action="store_true", -
tests/hermes_cli/test_debug.py@@ -1289,7 +1289,8 @@ def test_share_shows_privacy_notice(self, hermes_home, capsys): run_debug_share(args) out = capsys.readouterr().out - assert "public paste service" in out + assert "PUBLIC paste service" in out + assert "NOT redacted" in out def test_local_no_privacy_notice(self, hermes_home, capsys): from hermes_cli.debug import run_debug_share @@ -1304,7 +1305,7 @@ def test_local_no_privacy_notice(self, hermes_home, capsys): run_debug_share(args) out = capsys.readouterr().out - assert "public paste service" not in out + assert "PUBLIC paste service" not in out # --------------------------------------------------------------------------- @@ -1519,6 +1520,7 @@ class _A: local = False nous = True no_redact = False + yes = True a = _A() for k, v in over.items(): @@ -1602,6 +1604,9 @@ def test_bare_debug_defaults_to_paste(self): c = self._captured("/debug") assert c["nous"] is False and c["local"] is False assert c["lines"] == 200 and c["expire"] == 7 + # The slash command IS the consent action → skip the [y/N] prompt + # (input() would hang inside prompt_toolkit's event loop). + assert c["yes"] is True def test_nous_word_sets_nous(self): c = self._captured("/debug nous") @@ -1629,3 +1634,132 @@ def test_no_arg_default_keyword(self): c = self._captured("") assert c["nous"] is False and c["local"] is False + +class TestShareConsentGate: + """`hermes debug share` requires explicit consent before uploading. + + Uses SimpleNamespace rather than MagicMock so ``args.yes`` is a real + ``False`` — a MagicMock auto-provides a truthy ``.yes`` and would silently + bypass the very gate under test. + """ + + def _args(self, **over): + from types import SimpleNamespace + + base = dict(lines=50, expire=7, local=False, nous=False, + no_redact=False, yes=False) + base.update(over) + return SimpleNamespace(**base) + + def test_aborts_on_user_decline(self, hermes_home, capsys, monkeypatch): + """Interactive user typing anything but y/yes → no upload.""" + from hermes_cli.debug import run_debug_share + + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "n") + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin") as mock_upload: + run_debug_share(self._args()) + + mock_upload.assert_not_called() + assert "Aborted" in capsys.readouterr().out + + def test_proceeds_on_user_accept(self, hermes_home, capsys, monkeypatch): + """Interactive user typing 'y' → upload proceeds.""" + from hermes_cli.debug import run_debug_share + + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "y") + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test"), \ + patch("hermes_cli.debug._schedule_auto_delete"): + run_debug_share(self._args()) + + out = capsys.readouterr().out + assert "Debug report uploaded" in out + assert "Aborted" not in out + + def test_yes_flag_skips_prompt(self, hermes_home, capsys, monkeypatch): + """--yes uploads without ever calling input().""" + from hermes_cli.debug import run_debug_share + + def _boom(_): + raise AssertionError("input() must not be called with --yes") + + monkeypatch.setattr("builtins.input", _boom) + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test"), \ + patch("hermes_cli.debug._schedule_auto_delete"): + run_debug_share(self._args(yes=True)) + + assert "Debug report uploaded" in capsys.readouterr().out + + def test_non_interactive_requires_yes(self, hermes_home, capsys, monkeypatch): + """No TTY + no --yes → exit(1), never upload silently.""" + from hermes_cli.debug import run_debug_share + + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin") as mock_upload: + with pytest.raises(SystemExit) as exc: + run_debug_share(self._args()) + + assert exc.value.code == 1 + mock_upload.assert_not_called() + err = capsys.readouterr().err + assert "Non-interactive mode requires --yes" in err + assert "personal data" in err + + def test_non_interactive_with_yes_succeeds(self, hermes_home, capsys, monkeypatch): + """No TTY but --yes present → upload proceeds.""" + from hermes_cli.debug import run_debug_share + + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test"), \ + patch("hermes_cli.debug._schedule_auto_delete"): + run_debug_share(self._args(yes=True)) + + assert "https://paste.rs/test" in capsys.readouterr().out + + def test_nous_path_also_gated(self, hermes_home, capsys, monkeypatch): + """The --nous S3 path enforces the same consent gate (sibling site).""" + from hermes_cli.debug import run_debug_share + + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "n") + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.diagnostics_upload.share_to_nous") as mock_nous: + run_debug_share(self._args(nous=True)) + + mock_nous.assert_not_called() + assert "Aborted" in capsys.readouterr().out + + def test_local_never_prompts(self, hermes_home, capsys, monkeypatch): + """--local renders to stdout and must not prompt or upload.""" + from hermes_cli.debug import run_debug_share + + def _boom(_): + raise AssertionError("input() must not be called for --local") + + monkeypatch.setattr("builtins.input", _boom) + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin") as mock_upload: + run_debug_share(self._args(local=True)) + + mock_upload.assert_not_called() + assert "Aborted" not in capsys.readouterr().out +
-
9 PRs closed without merging
From the close comments: done elsewhere 6 · merged via another change 1 · partial fix 1 · superseded by another PR 1
-
#19349 fix(debug): redact log content at upload time in hermes debug share · done elsewhere →
#19318 -
#21044 fix(agent): redact secrets from assistant output · done elsewhere →
#19798,#21193,#20785 -
#22139 fix(cli): require user confirmation before uploading debug share data · superseded by another PR →
#47952,#22016 -
#22210 fix(security): require explicit consent before uploading debug logs · merged via another change →
#56125 -
#23821 Fix/gateway redact outbound chat · done elsewhere →
1207d81,#53907,#23810
Show 4 more
-
#23822 fix(gateway): redact outbound replies before delivery · done elsewhere →
1207d81,#53907,#23810 -
#24303 fix(security): Apply secret redaction to all outbound platform messages · done elsewhere →
1207d81,#53907,#23810 -
#29521 feat(gateway): Layer-2 credential redaction on outbound platform messages · done elsewhere →
1207d81,#53907,#23810 -
#69561 fix(debug): redact PII from public debug shares · partial fix →
7c82d97,#22139,#22210,#47952,#56125,#69561,#22016,#46006,#1,#15803,#14532,#6,#9,#374151,#4338
Closed issues · 7
- Closed#17691Security: HERMES_REDACT_SECRETS off-by-default exposes API keys in user-visible Telegram/Discord chat output
- Closed#19316[Bug] hermes debug share uploads logs with credentials to public paste service
- Closed#19897Security: HERMES_REDACT_SECRETS off-by-default exposes API keys in user-visible Telegram/Discord chat output
- Closed#20785Credential leakage in agent output: systemic failure to redact secrets in chat and reasoning blocks
- Closed#22016[Bug]: SECURITY FLAW: `hermes debug share` exposes private data
- Closed#23810Security: outbound chat messages bypass redact_sensitive_text in v0.13.0 (gateway/platforms/*.py)
- Closed#31423[Security] Default secret-pattern redaction on outbound agent messages / dispatch logs
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab