{"repo":"NousResearch/hermes-agent","public_id":"19316","display_label":"Privacy and consent concerns for `hermes debug share` uploads","summary":"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.","subsystem":"hermes_cli","counts":{"members":9,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56125,"merged_at":"2026-07-01T07:38:17+00:00"},"resolution":{"state":"partially_resolved","by":"pr-19318,pr-56125"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T07:38:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18666666666666668},"fix_edges":[{"issue_number":19316,"pr_number":19318,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-03T18:42:20+00:00"},{"issue_number":22016,"pr_number":56125,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-01T07:38:17+00:00"}],"members":[{"kind":"issue","number":19316,"title":"[Bug] hermes debug share uploads logs with credentials to public paste service","url":null,"state":"closed"},{"kind":"issue","number":22016,"title":"[Bug]: SECURITY FLAW: `hermes debug share` exposes private data","url":null,"state":"closed"},{"kind":"issue","number":46006,"title":"[Bug]: Security Issue when following the outlined Bug Report Procedure.","url":null,"state":"open"},{"kind":"pr","number":19318,"title":"fix(debug): redact log content at upload time in hermes debug share","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/debug.py","hermes_cli/main.py","scripts/release.py","tests/hermes_cli/test_debug.py"],"fix_hunks":[{"file":"hermes_cli/debug.py","hunks":["@@ -1,12 +1,19 @@\n-\"\"\"``hermes debug`` — debug tools for Hermes Agent.\n+\"\"\"``hermes debug`` debug tools for Hermes Agent.\n \n Currently supports:\n     hermes debug share    Upload debug report (system info + logs) to a\n                           paste service and print a shareable URL.\n+                          By default, log content is run through\n+                          ``agent.redact.redact_sensitive_text`` with\n+                          ``force=True`` before upload so credentials in\n+                          ``~/.hermes/logs/*.log`` are not leaked into\n+                          the public paste service. Pass ``--no-redact``\n+                          to disable.\n \"\"\"\n \n import io\n import json\n+import logging\n import sys\n import time\n import urllib.error","@@ -19,6 +26,16 @@\n from hermes_constants import get_hermes_home\n from utils import atomic_replace\n \n+logger = logging.getLogger(__name__)\n+\n+# Banner prepended to upload-bound log content when redaction is enabled.\n+# Visible in the public paste so reviewers know the content was sanitized.\n+# Kept short; the trailing newline guarantees the banner sits on its own line.\n+_REDACTION_BANNER = (\n+    \"[hermes debug share: log content redacted at upload time. \"\n+    \"run with --no-redact to disable]\\n\"\n+)\n+\n \n # ---------------------------------------------------------------------------\n # Paste services — try paste.rs first, dpaste.com as fallback.","@@ -368,17 +385,40 @@ def _resolve_log_path(log_name: str) -> Optional[Path]:\n     return None\n \n \n+def _redact_log_text(text: str) -> str:\n+    \"\"\"Run ``redact_sensitive_text`` with ``force=True`` over upload-bound text.\n+\n+    Uses ``force=True`` so redaction fires regardless of the operator's\n+    ``security.redact_secrets`` setting. The local on-disk log file is\n+    not modified; only the in-memory copy headed for the public paste\n+    service is sanitized. Returns the redacted text (or the original\n+    when empty / non-string).\n+    \"\"\"\n+    if not text:\n+        return text\n+    from agent.redact import redact_sensitive_text\n+\n+    return redact_sensitive_text(text, force=True)\n+\n+\n def _capture_log_snapshot(\n     log_name: str,\n     *,\n     tail_lines: int,\n     max_bytes: int = _MAX_LOG_BYTES,\n+    redact: bool = True,\n ) -> LogSnapshot:\n     \"\"\"Capture a log once and derive summary/full-log views from it.\n \n     The report tail and standalone log upload must come from the same file\n     snapshot. Otherwise a rotation/truncate between reads can make the report\n     look newer than the uploaded ``agent.log`` paste.\n+\n+    When ``redact`` is True (the default), both ``tail_text`` and\n+    ``full_text`` are run through ``_redact_log_text`` so the snapshot\n+    returned is upload-safe. The on-disk log file is never modified.\n+    Pass ``redact=False`` to capture original log content (used by\n+    ``hermes debug share --no-redact``).\n     \"\"\"\n     log_path = _resolve_log_path(log_name)\n     if log_path is None:","@@ -438,18 +478,34 @@ def _capture_log_snapshot(\n         if truncated:\n             full_text = f\"[... truncated — showing last ~{max_bytes // 1024}KB ...]\\n{full_text}\"\n \n+        if redact:\n+            tail_text = _redact_log_text(tail_text)\n+            full_text = _redact_log_text(full_text)\n+\n         return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)\n     except Exception as exc:\n         return LogSnapshot(path=log_path, tail_text=f\"(error reading: {exc})\", full_text=None)\n \n \n-def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]:\n-    \"\"\"Capture all logs used by debug-share exactly once.\"\"\"\n+def _capture_default_log_snapshots(\n+    log_lines: int, *, redact: bool = True\n+) -> dict[str, LogSnapshot]:\n+    \"\"\"Capture all logs used by debug-share exactly once.\n+\n+    ``redact`` is forwarded to each ``_capture_log_snapshot`` call so all\n+    captured logs share the same redaction policy for a given run.\n+    \"\"\"\n     errors_lines = min(log_lines, 100)\n     return {\n-        \"agent\": _capture_log_snapshot(\"agent\", tail_lines=log_lines),\n-        \"errors\": _capture_log_snapshot(\"errors\", tail_lines=errors_lines),\n-        \"gateway\": _capture_log_snapshot(\"gateway\", tail_lines=errors_lines),\n+        \"agent\": _capture_log_snapshot(\n+            \"agent\", tail_lines=log_lines, redact=redact\n+        ),\n+        \"errors\": _capture_log_snapshot(\n+            \"errors\", tail_lines=errors_lines, redact=redact\n+        ),\n+        \"gateway\": _capture_log_snapshot(\n+            \"gateway\", tail_lines=errors_lines, redact=redact\n+        ),\n     }\n \n ","@@ -532,6 +588,7 @@ def run_debug_share(args):\n     log_lines = getattr(args, \"lines\", 200)\n     expiry = getattr(args, \"expire\", 7)\n     local_only = getattr(args, \"local\", False)\n+    redact = not getattr(args, \"no_redact\", False)\n \n     if not local_only:\n         print(_PRIVACY_NOTICE)","@@ -539,8 +596,16 @@ def run_debug_share(args):\n     print(\"Collecting debug report...\")\n \n     # Capture dump once — prepended to every paste for context.\n+    # The dump is already redacted at extract time via dump.py:_redact;\n+    # log_snapshots are redacted by _capture_default_log_snapshots when\n+    # redact=True so credentials never reach the public paste service.\n     dump_text = _capture_dump()\n-    log_snapshots = _capture_default_log_snapshots(log_lines)\n+    log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)\n+\n+    if redact:\n+        logger.info(\n+            \"hermes debug share: applied force-mode redaction to log snapshots before upload\"\n+        )\n \n     report = collect_debug_report(\n         log_lines=log_lines,","@@ -556,6 +621,15 @@ def run_debug_share(args):\n     if gateway_log:\n         gateway_log = dump_text + \"\\n\\n--- full gateway.log ---\\n\" + gateway_log\n \n+    # Visible banner so reviewers reading the public paste know redaction\n+    # was applied at upload time. Banner is omitted under --no-redact.\n+    if redact:\n+        report = _REDACTION_BANNER + report\n+        if agent_log:\n+            agent_log = _REDACTION_BANNER + agent_log\n+        if gateway_log:\n+            gateway_log = _REDACTION_BANNER + gateway_log\n+\n     if local_only:\n         print(report)\n         if agent_log:","@@ -666,6 +740,7 @@ def run_debug(args):\n         print(\"  --lines N    Number of log lines to include (default: 200)\")\n         print(\"  --expire N   Paste expiry in days (default: 7)\")\n         print(\"  --local      Print report locally instead of uploading\")\n+        print(\"  --no-redact  Disable upload-time secret redaction (default: redact)\")\n         print()\n         print(\"Options (delete):\")\n         print(\"  <url> ...    One or more paste URLs to delete\")"]},{"file":"hermes_cli/main.py","hunks":["@@ -8891,6 +8891,7 @@ def main():\n     hermes debug share --lines 500  Include more log lines\n     hermes debug share --expire 30  Keep paste for 30 days\n     hermes debug share --local      Print report locally (no upload)\n+    hermes debug share --no-redact  Disable upload-time secret redaction\n     hermes debug delete <url>       Delete a previously uploaded paste\n \"\"\",\n     )","@@ -8916,6 +8917,16 @@ def main():\n         action=\"store_true\",\n         help=\"Print the report locally instead of uploading\",\n     )\n+    share_parser.add_argument(\n+        \"--no-redact\",\n+        action=\"store_true\",\n+        help=(\n+            \"Disable upload-time secret redaction (default: redact). Logs \"\n+            \"are normally run through agent.redact.redact_sensitive_text \"\n+            \"with force=True before upload so credentials are not leaked \"\n+            \"into the public paste service.\"\n+        ),\n+    )\n     delete_parser = debug_sub.add_parser(\n         \"delete\",\n         help=\"Delete a paste uploaded by 'hermes debug share'\","]},{"file":"scripts/release.py","hunks":["@@ -678,6 +678,8 @@\n     \"[redacted:email]\": \"chengoak\",  # PR #17467\n     \"[redacted:email]\": \"YuShu\",  # co-author only\n     \"[redacted:email]\": \"CharlieKerfoot\",  # PR #18951\n+    # Debug share upload-time redaction (May 2026)\n+    \"[redacted:email]\": \"GodsBoy\",  # PR #19318\n }\n \n "]},{"file":"tests/hermes_cli/test_debug.py","hunks":["@@ -273,6 +273,101 @@ def test_falls_back_when_primary_empty(self, hermes_home):\n         assert \"rotated agent data\" in snap.full_text\n \n \n+# ---------------------------------------------------------------------------\n+# Capture log redaction (force=True applies regardless of HERMES_REDACT_SECRETS)\n+# ---------------------------------------------------------------------------\n+\n+# A vendor-prefixed token used across redaction tests. Long enough to clear\n+# the redactor's `floor` parameter so it actually masks rather than fully blanks.\n+_REDACT_FIXTURE_TOKEN = \"[redacted:openai-key]\"\n+\n+\n+class TestCaptureLogSnapshotRedaction:\n+    \"\"\"Pin upload-time redaction at the _capture_log_snapshot boundary.\"\"\"\n+\n+    @pytest.fixture\n+    def hermes_home_with_secret(self, tmp_path, monkeypatch):\n+        \"\"\"Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token.\"\"\"\n+        home = tmp_path / \".hermes\"\n+        home.mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+        # Critical: ensure the user has NOT opted in to redaction. The whole\n+        # point of this PR is that share-time redaction works for users who\n+        # never set this env var.\n+        monkeypatch.delenv(\"HERMES_REDACT_SECRETS\", raising=False)\n+\n+        logs_dir = home / \"logs\"\n+        logs_dir.mkdir()\n+        (logs_dir / \"agent.log\").write_text(\n+            f\"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\\n\"\n+        )\n+        (logs_dir / \"errors.log\").write_text(\"\")\n+        (logs_dir / \"gateway.log\").write_text(\"\")\n+        return home\n+\n+    def test_default_redacts_tail_and_full_text(self, hermes_home_with_secret):\n+        from hermes_cli.debug import _capture_log_snapshot\n+\n+        snap = _capture_log_snapshot(\"agent\", tail_lines=10)\n+\n+        # Both views the upload uses must be sanitized.\n+        assert _REDACT_FIXTURE_TOKEN not in snap.tail_text\n+        assert snap.full_text is not None\n+        assert _REDACT_FIXTURE_TOKEN not in snap.full_text\n+\n+    def test_redact_false_passes_through(self, hermes_home_with_secret):\n+        from hermes_cli.debug import _capture_log_snapshot\n+\n+        snap = _capture_log_snapshot(\"agent\", tail_lines=10, redact=False)\n+\n+        # Original token survives when the caller opts out.\n+        assert _REDACT_FIXTURE_TOKEN in snap.tail_text\n+        assert _REDACT_FIXTURE_TOKEN in (snap.full_text or \"\")\n+\n+    def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret):\n+        \"\"\"Regression test: redact_sensitive_text short-circuits without force=True.\n+\n+        If a future refactor drops `force=True` from `_redact_log_text`, this\n+        test fails immediately. Without `force=True`, the redactor returns the\n+        input unchanged when HERMES_REDACT_SECRETS is unset, and the feature\n+        ships silently broken for its target audience.\n+        \"\"\"\n+        import os\n+\n+        from hermes_cli.debug import _capture_log_snapshot\n+\n+        # Belt-and-suspenders: confirm the env var is genuinely unset for this\n+        # test so we know we're exercising the force=True path.\n+        assert os.environ.get(\"HERMES_REDACT_SECRETS\", \"\") == \"\"\n+\n+        snap = _capture_log_snapshot(\"agent\", tail_lines=10)\n+\n+        assert _REDACT_FIXTURE_TOKEN not in snap.tail_text\n+        assert snap.full_text is not None\n+        assert _REDACT_FIXTURE_TOKEN not in snap.full_text\n+\n+    def test_capture_default_log_snapshots_threads_redact(\n+        self, hermes_home_with_secret\n+    ):\n+        from hermes_cli.debug import _capture_default_log_snapshots\n+\n+        snaps = _capture_default_log_snapshots(50)\n+\n+        # Default threads redact=True to all three captured logs.\n+        assert _REDACT_FIXTURE_TOKEN not in snaps[\"agent\"].tail_text\n+        assert _REDACT_FIXTURE_TOKEN not in (snaps[\"agent\"].full_text or \"\")\n+\n+    def test_capture_default_log_snapshots_no_redact_passes_through(\n+        self, hermes_home_with_secret\n+    ):\n+        from hermes_cli.debug import _capture_default_log_snapshots\n+\n+        snaps = _capture_default_log_snapshots(50, redact=False)\n+\n+        assert _REDACT_FIXTURE_TOKEN in snaps[\"agent\"].tail_text\n+        assert _REDACT_FIXTURE_TOKEN in (snaps[\"agent\"].full_text or \"\")\n+\n+\n # ---------------------------------------------------------------------------\n # Debug report collection\n # ---------------------------------------------------------------------------","@@ -556,6 +651,124 @@ def test_share_exits_on_report_upload_failure(self, hermes_home, capsys):\n         assert \"all failed\" in out.err\n \n \n+# ---------------------------------------------------------------------------\n+# Share-time redaction wiring + visible banner\n+# ---------------------------------------------------------------------------\n+\n+class TestRunDebugShareRedaction:\n+    \"\"\"End-to-end: --no-redact flag, banner injection, default behavior.\"\"\"\n+\n+    @pytest.fixture\n+    def hermes_home_with_secret(self, tmp_path, monkeypatch):\n+        \"\"\"Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token.\"\"\"\n+        home = tmp_path / \".hermes\"\n+        home.mkdir()\n+        monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+        monkeypatch.delenv(\"HERMES_REDACT_SECRETS\", raising=False)\n+\n+        logs_dir = home / \"logs\"\n+        logs_dir.mkdir()\n+        (logs_dir / \"agent.log\").write_text(\n+            f\"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\\n\"\n+        )\n+        (logs_dir / \"errors.log\").write_text(\"\")\n+        (logs_dir / \"gateway.log\").write_text(\n+            f\"2026-04-12 17:00:01 INFO gateway.run: token {_REDACT_FIXTURE_TOKEN}\\n\"\n+        )\n+        return home\n+\n+    def test_default_share_redacts_uploaded_content(\n+        self, hermes_home_with_secret, capsys\n+    ):\n+        \"\"\"The uploaded report and full-log pastes do not contain the raw token.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        args = MagicMock()\n+        args.lines = 50\n+        args.expire = 7\n+        args.local = False\n+        args.no_redact = False\n+\n+        captured: list[str] = []\n+\n+        def fake_upload(content, expiry_days=7):\n+            captured.append(content)\n+            return f\"https://paste.rs/{len(captured)}\"\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug._sweep_expired_pastes\", return_value=(0, 0)), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\", side_effect=fake_upload):\n+            run_debug_share(args)\n+\n+        # At least the report plus one full log paste reached the upload path.\n+        assert len(captured) >= 2\n+        for content in captured:\n+            assert _REDACT_FIXTURE_TOKEN not in content, (\n+                \"raw token leaked into upload-bound content\"\n+            )\n+\n+    def test_default_share_includes_redaction_banner(\n+        self, hermes_home_with_secret, capsys\n+    ):\n+        \"\"\"Each upload-bound paste carries the visible redaction banner.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        args = MagicMock()\n+        args.lines = 50\n+        args.expire = 7\n+        args.local = False\n+        args.no_redact = False\n+\n+        captured: list[str] = []\n+\n+        def fake_upload(content, expiry_days=7):\n+            captured.append(content)\n+            return f\"https://paste.rs/{len(captured)}\"\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug._sweep_expired_pastes\", return_value=(0, 0)), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\", side_effect=fake_upload):\n+            run_debug_share(args)\n+\n+        for content in captured:\n+            assert \"redacted at upload time\" in content, (\n+                \"redaction banner missing from upload-bound content\"\n+            )\n+\n+    def test_no_redact_flag_disables_redaction_and_banner(\n+        self, hermes_home_with_secret, capsys\n+    ):\n+        \"\"\"--no-redact preserves original log content and omits the banner.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        args = MagicMock()\n+        args.lines = 50\n+        args.expire = 7\n+        args.local = False\n+        args.no_redact = True\n+\n+        captured: list[str] = []\n+\n+        def fake_upload(content, expiry_days=7):\n+            captured.append(content)\n+            return f\"https://paste.rs/{len(captured)}\"\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug._sweep_expired_pastes\", return_value=(0, 0)), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\", side_effect=fake_upload):\n+            run_debug_share(args)\n+\n+        # The agent.log paste should now contain the raw token.\n+        assert any(_REDACT_FIXTURE_TOKEN in c for c in captured), (\n+            \"expected raw token in --no-redact upload\"\n+        )\n+        # No banner anywhere when redaction is disabled.\n+        for content in captured:\n+            assert \"redacted at upload time\" not in content, (\n+                \"banner present with --no-redact\"\n+            )\n+\n+\n # ---------------------------------------------------------------------------\n # run_debug router\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":19349,"title":"fix(debug): redact log content at upload time in hermes debug share","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19318","number":19318}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22139,"title":"fix(cli): require user confirmation before uploading debug share data","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47952","number":47952},{"kind":"pr","value":"#22016","number":22016}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22210,"title":"fix(security): require explicit consent before uploading debug logs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56125","number":56125}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":56125,"title":"fix(security): require explicit consent before uploading debug logs","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/cli_commands_mixin.py","hermes_cli/debug.py","hermes_cli/subcommands/debug.py","tests/hermes_cli/test_debug.py"],"fix_hunks":[{"file":"hermes_cli/cli_commands_mixin.py","hunks":["@@ -2593,7 +2593,12 @@ def _handle_debug_command(self, cmd_original: str = \"\"):\n         words = {w.lower() for w in cmd_original.split()[1:]}\n         local = \"local\" in words\n         nous = \"nous\" in words and not local\n-        args = SimpleNamespace(lines=200, expire=7, local=local, nous=nous)\n+        # Typing the /debug slash command is itself the explicit consent to\n+        # upload, so we pass yes=True to skip run_debug_share's [y/N] prompt.\n+        # input() would hang inside prompt_toolkit's event loop anyway.\n+        args = SimpleNamespace(\n+            lines=200, expire=7, local=local, nous=nous, yes=True\n+        )\n         run_debug_share(args)\n \n     def _handle_update_command(self) -> bool:"]},{"file":"hermes_cli/debug.py","hunks":["@@ -196,15 +196,19 @@ def _best_effort_sweep_expired_pastes() -> None:\n # ---------------------------------------------------------------------------\n \n _PRIVACY_NOTICE = \"\"\"\\\n-⚠️  This will upload the following to a public paste service:\n-  • System info (OS, Python version, Hermes version, provider, which API keys\n-    are configured — NOT the actual keys)\n-  • Recent log lines (agent.log, errors.log, gateway.log, gui.log, desktop.log\n-    — may contain conversation fragments and file paths)\n-  • Full agent.log, gateway.log, gui.log, and desktop.log (up to 512 KB each —\n-    likely contains conversation content, tool outputs, and file paths)\n-\n-Pastes auto-delete after 6 hours.\n+⚠️  This will upload system info + logs to a PUBLIC paste service.\n+\n+Cryptographic secrets (API keys, tokens, passwords) are redacted before\n+upload, but the following personal data is NOT redacted and will be public:\n+  • Your display name and persistent platform user ID\n+  • Verbatim content of your recent messages (prompts, responses, tool output)\n+  • Local filesystem paths\n+  • Any other PII present in the logs\n+\n+The resulting URL is public to anyone who has the link. Pastes auto-delete\n+after 6 hours, but may be archived by third parties in the meantime.\n+\n+Use --local to view the report without uploading.\n \"\"\"\n \n _GATEWAY_PRIVACY_NOTICE = (","@@ -774,6 +778,38 @@ def build_debug_share(\n     )\n \n \n+def _confirm_upload(args) -> bool:\n+    \"\"\"Require explicit consent before any debug-share upload.\n+\n+    The privacy notice is printed by the caller. This gates the actual\n+    upload: with ``--yes`` (or ``-y``) we proceed unprompted; otherwise we\n+    ask an interactive ``[y/N]`` question. In a non-interactive context\n+    (no TTY on stdin — scripts, CI, piped input) we refuse rather than\n+    hang or upload silently, so debug data can't be exposed without a\n+    deliberate ``--yes``.\n+\n+    Returns True to proceed with the upload, False to abort.\n+    \"\"\"\n+    if bool(getattr(args, \"yes\", False)):\n+        return True\n+    if not sys.stdin.isatty():\n+        print(\n+            \"ERROR: Non-interactive mode requires --yes to confirm upload.\\n\"\n+            \"       This prevents accidental exposure of personal data.\\n\"\n+            \"       Use --local to view the report without uploading.\",\n+            file=sys.stderr,\n+        )\n+        sys.exit(1)\n+    try:\n+        answer = input(\"Upload debug report? [y/N] \").strip().lower()\n+    except (EOFError, KeyboardInterrupt):\n+        answer = \"\"\n+    if answer not in (\"y\", \"yes\"):\n+        print(\"Aborted.\")\n+        return False\n+    return True\n+\n+\n def run_debug_share(args):\n     \"\"\"Collect debug report + full logs, upload each, print URLs.\"\"\"\n     log_lines = getattr(args, \"lines\", 200)","@@ -805,10 +841,12 @@ def run_debug_share(args):\n         return\n \n     if nous:\n-        _run_debug_share_nous(log_lines=log_lines, redact=redact)\n+        _run_debug_share_nous(args, log_lines=log_lines, redact=redact)\n         return\n \n     print(_PRIVACY_NOTICE)\n+    if not _confirm_upload(args):\n+        return\n     print(\"Collecting debug report...\")\n     print(\"Uploading...\")\n ","@@ -856,7 +894,7 @@ def run_debug_share(args):\n \"\"\"\n \n \n-def _run_debug_share_nous(*, log_lines: int, redact: bool) -> None:\n+def _run_debug_share_nous(args, *, log_lines: int, redact: bool) -> None:\n     \"\"\"Handle ``hermes debug share --nous``: upload the bundle to Nous-S3.\n \n     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:\n     from hermes_cli.diagnostics_upload import share_to_nous\n \n     print(_NOUS_PRIVACY_NOTICE)\n+    if not _confirm_upload(args):\n+        return\n     if not redact:\n         print(\n             \"⚠️  --no-redact is set: secrets in your logs will NOT be redacted \""]},{"file":"hermes_cli/subcommands/debug.py","hunks":["@@ -24,7 +24,8 @@ def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None:\n         formatter_class=argparse.RawDescriptionHelpFormatter,\n         epilog=\"\"\"\\\n Examples:\n-    hermes debug share              Upload debug report and print URL\n+    hermes debug share              Upload debug report (asks for confirmation)\n+    hermes debug share --yes        Skip confirmation (for scripts/CI)\n     hermes debug share --lines 500  Include more log lines\n     hermes debug share --expire 30  Keep paste for 30 days\n     hermes debug share --local      Print report locally (no upload)","@@ -55,6 +56,16 @@ def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None:\n         action=\"store_true\",\n         help=\"Print the report locally instead of uploading\",\n     )\n+    share_parser.add_argument(\n+        \"-y\",\n+        \"--yes\",\n+        action=\"store_true\",\n+        help=(\n+            \"Skip the confirmation prompt and upload immediately. Required \"\n+            \"in non-interactive contexts (scripts/CI); without it, and with \"\n+            \"no TTY on stdin, the command refuses rather than upload silently.\"\n+        ),\n+    )\n     share_parser.add_argument(\n         \"--no-redact\",\n         action=\"store_true\","]},{"file":"tests/hermes_cli/test_debug.py","hunks":["@@ -1289,7 +1289,8 @@ def test_share_shows_privacy_notice(self, hermes_home, capsys):\n             run_debug_share(args)\n \n         out = capsys.readouterr().out\n-        assert \"public paste service\" in out\n+        assert \"PUBLIC paste service\" in out\n+        assert \"NOT redacted\" in out\n \n     def test_local_no_privacy_notice(self, hermes_home, capsys):\n         from hermes_cli.debug import run_debug_share","@@ -1304,7 +1305,7 @@ def test_local_no_privacy_notice(self, hermes_home, capsys):\n             run_debug_share(args)\n \n         out = capsys.readouterr().out\n-        assert \"public paste service\" not in out\n+        assert \"PUBLIC paste service\" not in out\n \n \n # ---------------------------------------------------------------------------","@@ -1519,6 +1520,7 @@ class _A:\n             local = False\n             nous = True\n             no_redact = False\n+            yes = True\n \n         a = _A()\n         for k, v in over.items():","@@ -1602,6 +1604,9 @@ def test_bare_debug_defaults_to_paste(self):\n         c = self._captured(\"/debug\")\n         assert c[\"nous\"] is False and c[\"local\"] is False\n         assert c[\"lines\"] == 200 and c[\"expire\"] == 7\n+        # The slash command IS the consent action → skip the [y/N] prompt\n+        # (input() would hang inside prompt_toolkit's event loop).\n+        assert c[\"yes\"] is True\n \n     def test_nous_word_sets_nous(self):\n         c = self._captured(\"/debug nous\")","@@ -1629,3 +1634,132 @@ def test_no_arg_default_keyword(self):\n         c = self._captured(\"\")\n         assert c[\"nous\"] is False and c[\"local\"] is False\n \n+\n+class TestShareConsentGate:\n+    \"\"\"`hermes debug share` requires explicit consent before uploading.\n+\n+    Uses SimpleNamespace rather than MagicMock so ``args.yes`` is a real\n+    ``False`` — a MagicMock auto-provides a truthy ``.yes`` and would silently\n+    bypass the very gate under test.\n+    \"\"\"\n+\n+    def _args(self, **over):\n+        from types import SimpleNamespace\n+\n+        base = dict(lines=50, expire=7, local=False, nous=False,\n+                    no_redact=False, yes=False)\n+        base.update(over)\n+        return SimpleNamespace(**base)\n+\n+    def test_aborts_on_user_decline(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"Interactive user typing anything but y/yes → no upload.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        monkeypatch.setattr(\"sys.stdin.isatty\", lambda: True)\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"n\")\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\") as mock_upload:\n+            run_debug_share(self._args())\n+\n+        mock_upload.assert_not_called()\n+        assert \"Aborted\" in capsys.readouterr().out\n+\n+    def test_proceeds_on_user_accept(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"Interactive user typing 'y' → upload proceeds.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        monkeypatch.setattr(\"sys.stdin.isatty\", lambda: True)\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"y\")\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug._sweep_expired_pastes\", return_value=(0, 0)), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\",\n+                   return_value=\"https://paste.rs/test\"), \\\n+             patch(\"hermes_cli.debug._schedule_auto_delete\"):\n+            run_debug_share(self._args())\n+\n+        out = capsys.readouterr().out\n+        assert \"Debug report uploaded\" in out\n+        assert \"Aborted\" not in out\n+\n+    def test_yes_flag_skips_prompt(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"--yes uploads without ever calling input().\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        def _boom(_):\n+            raise AssertionError(\"input() must not be called with --yes\")\n+\n+        monkeypatch.setattr(\"builtins.input\", _boom)\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug._sweep_expired_pastes\", return_value=(0, 0)), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\",\n+                   return_value=\"https://paste.rs/test\"), \\\n+             patch(\"hermes_cli.debug._schedule_auto_delete\"):\n+            run_debug_share(self._args(yes=True))\n+\n+        assert \"Debug report uploaded\" in capsys.readouterr().out\n+\n+    def test_non_interactive_requires_yes(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"No TTY + no --yes → exit(1), never upload silently.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        monkeypatch.setattr(\"sys.stdin.isatty\", lambda: False)\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\") as mock_upload:\n+            with pytest.raises(SystemExit) as exc:\n+                run_debug_share(self._args())\n+\n+        assert exc.value.code == 1\n+        mock_upload.assert_not_called()\n+        err = capsys.readouterr().err\n+        assert \"Non-interactive mode requires --yes\" in err\n+        assert \"personal data\" in err\n+\n+    def test_non_interactive_with_yes_succeeds(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"No TTY but --yes present → upload proceeds.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        monkeypatch.setattr(\"sys.stdin.isatty\", lambda: False)\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug._sweep_expired_pastes\", return_value=(0, 0)), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\",\n+                   return_value=\"https://paste.rs/test\"), \\\n+             patch(\"hermes_cli.debug._schedule_auto_delete\"):\n+            run_debug_share(self._args(yes=True))\n+\n+        assert \"https://paste.rs/test\" in capsys.readouterr().out\n+\n+    def test_nous_path_also_gated(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"The --nous S3 path enforces the same consent gate (sibling site).\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        monkeypatch.setattr(\"sys.stdin.isatty\", lambda: True)\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"n\")\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.diagnostics_upload.share_to_nous\") as mock_nous:\n+            run_debug_share(self._args(nous=True))\n+\n+        mock_nous.assert_not_called()\n+        assert \"Aborted\" in capsys.readouterr().out\n+\n+    def test_local_never_prompts(self, hermes_home, capsys, monkeypatch):\n+        \"\"\"--local renders to stdout and must not prompt or upload.\"\"\"\n+        from hermes_cli.debug import run_debug_share\n+\n+        def _boom(_):\n+            raise AssertionError(\"input() must not be called for --local\")\n+\n+        monkeypatch.setattr(\"builtins.input\", _boom)\n+\n+        with patch(\"hermes_cli.dump.run_dump\"), \\\n+             patch(\"hermes_cli.debug.upload_to_pastebin\") as mock_upload:\n+            run_debug_share(self._args(local=True))\n+\n+        mock_upload.assert_not_called()\n+        assert \"Aborted\" not in capsys.readouterr().out\n+\n"]}]},{"kind":"pr","number":69561,"title":"fix(debug): redact PII from public debug shares","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7c82d9710ccb","number":0},{"kind":"pr","value":"#22139","number":22139},{"kind":"pr","value":"#22210","number":22210},{"kind":"pr","value":"#47952","number":47952},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#69561","number":69561},{"kind":"pr","value":"#22016","number":22016},{"kind":"pr","value":"#46006","number":46006},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#15803","number":15803},{"kind":"pr","value":"#14532","number":14532},{"kind":"pr","value":"#6","number":6},{"kind":"pr","value":"#9","number":9},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#4338","number":4338}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}