# NousResearch/hermes-agent · Privacy and consent concerns for \`hermes debug share\` uploads

## Open now

- 1 issue, 0 PRs open on GitHub.
- #46006: \[Bug\]: Security Issue when following the outlined Bug Report Procedure.

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.

## TL;DR

- 9 member(s): 1 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #56125. Verify it addresses your case.
- **Recommended:** read PR #56125 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-19318,pr-56125`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #19316: \[Bug\] hermes debug share uploads logs with credentials to public paste service
- #22016: \[Bug\]: SECURITY FLAW: \`hermes debug share\` exposes private data

## Prior & competing PRs

- **#19318: fix(debug): redact log content at upload time in hermes debug share**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/debug.py`, `hermes_cli/main.py`, `scripts/release.py`, `tests/hermes_cli/test_debug.py`
  - fix hunks:
  - `hermes_cli/debug.py`

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

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    +        ),
         }
     
     
    ```

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

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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:
    ```

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

    ```diff
    @@ -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
     """,
         )
    ```

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

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

    ```diff
    @@ -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
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/cli_commands_mixin.py`, `hermes_cli/debug.py`, `hermes_cli/subcommands/debug.py`, `tests/hermes_cli/test_debug.py`
  - fix hunks:
  - `hermes_cli/cli_commands_mixin.py`

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

    ```diff
    @@ -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 = (
    ```

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

    ```diff
    @@ -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...")
     
    ```

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

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

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

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

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

    ```diff
    @@ -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
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -1519,6 +1520,7 @@ class _A:
                 local = False
                 nous = True
                 no_redact = False
    +            yes = True
     
             a = _A()
             for k, v in over.items():
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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
    +
    ```
- **#19349: fix(debug): redact log content at upload time in hermes debug share**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#19318`
- **#22139: fix(cli): require user confirmation before uploading debug share data**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#47952`, `#22016`
- **#22210: fix(security): require explicit consent before uploading debug logs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#56125`
- **#69561: fix(debug): redact PII from public debug shares**. closed without merge
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `7c82d9710ccb`, `#22139`, `#22210`, `#47952`, `#56125`, `#69561`, `#22016`, `#46006`, `#1`, `#15803`, `#14532`, `#6`, `#9`, `#374151`, `#4338`

## Fix status

- Merged to main in PR #56125 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `hermes_cli`
