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

2open issues
2open PRs
24items grouped
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.

Multiple 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

  • Issue#46006[Bug]: Security Issue when following the outlined Bug Report Procedure.
  • Issue#64351[Bug]: Gateway logs inbound message bodies verbatim; `hermes debug share` then uploads them to a public paste (leaked a live OAuth client_secret)

Open PRs2

  • PR#64376fix(gateway): redact credentials from inbound message logging
  • PR#67969fix(config): mask inline fallback provider keys in hermes dump

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #19318 fix(debug): redact log content at upload time in hermes debug share

    merged to main

    files: hermes_cli/debug.py, hermes_cli/main.py, scripts/release.py, tests/hermes_cli/test_debug.py

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

    merged to main

    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.md

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

    merged to main

    files: gateway/run.py, tests/gateway/test_telegram_noise_filter.py

  • #56125 fix(security): require explicit consent before uploading debug logs

    merged to main

    files: hermes_cli/cli_commands_mixin.py, hermes_cli/debug.py, hermes_cli/subcommands/debug.py, tests/hermes_cli/test_debug.py

    Show 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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#19318, #21193, #53907, #56125
Closed issues
7
Attempts
9 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.