NousResearch/hermes-agent

Privacy and consent concerns for `hermes debug share` uploads

Cluster 19316 · hermes cli. Data as of

1 open item. Fix merged in #56125 on 1 Jul 2026. Check it covers your case.

The short answer

1open issue
0open PRs
9items grouped
Known?
Yes. 9 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #56125. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #19318, #56125. That is separate from the fix event.
Still open
1 issue and 0 PRs.

Members share one theme: hermes debug share uploading debug logs that may contain credentials or private data to a public paste service. They include bug reports and proposed changes involving explicit consent or confirmation, and redaction of sensitive content at upload time.

Open issues1

  • Issue#46006[Bug]: Security Issue when following the outlined Bug Report Procedure.

Open PRs0

No open pull requests.

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:openai-key]"
      +
      +
      +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={_REDACT_FIXTURE_TOKEN} 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={_REDACT_FIXTURE_TOKEN} 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
       # ---------------------------------------------------------------------------
  • #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
      +

4 PRs closed without merging

From the close comments: done elsewhere 1 · 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
  • #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
  • #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 · 2
  • Closed#19316[Bug] hermes debug share uploads logs with credentials to public paste service
  • Closed#22016[Bug]: SECURITY FLAW: `hermes debug share` exposes private data
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.19: 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
hermes cli
Merged PRs
#19318, #56125
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.