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
- 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.
Next step: read #56125, then check whether your report is the one still open.
Read #56125Pick up a Lab task
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
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: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
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 +
-
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
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.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