{"repo":"NousResearch/hermes-agent","public_id":"17691","display_label":"Secret redaction gaps in logs, chat, and debug uploads","summary":"Multiple reports and fixes address credentials and API keys leaking through outbound chat messages, gateway logs, and the debug-share upload feature, alongside work to enable redaction by default and require user consent before uploading logs.","subsystem":"agent","counts":{"members":24,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":7,"merged_prs":4,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19318},"resolution":{"state":"partially_resolved","by":"pr-19318,pr-21193,pr-53907,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.0736231884057971},"fix_edges":[{"issue_number":17691,"pr_number":21193,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":19316,"pr_number":19318,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":20785,"pr_number":21193,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":22016,"pr_number":56125,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23810,"pr_number":53907,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":17691,"title":"Security: HERMES_REDACT_SECRETS off-by-default exposes API keys in user-visible Telegram/Discord chat output","url":null,"state":"closed"},{"kind":"issue","number":19316,"title":"[Bug] hermes debug share uploads logs with credentials to public paste service","url":null,"state":"closed"},{"kind":"issue","number":19897,"title":"Security: HERMES_REDACT_SECRETS off-by-default exposes API keys in user-visible Telegram/Discord chat output","url":null,"state":"closed"},{"kind":"issue","number":20785,"title":"Credential leakage in agent output: systemic failure to redact secrets in chat and reasoning blocks","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":23810,"title":"Security: outbound chat messages bypass redact_sensitive_text in v0.13.0 (gateway/platforms/*.py)","url":null,"state":"closed"},{"kind":"issue","number":31423,"title":"[Security] Default secret-pattern redaction on outbound agent messages / dispatch logs","url":null,"state":"closed"},{"kind":"issue","number":46006,"title":"[Bug]: Security Issue when following the outlined Bug Report Procedure.","url":null,"state":"open"},{"kind":"issue","number":64351,"title":"[Bug]: Gateway logs inbound message bodies verbatim; `hermes debug share` then uploads them to a public paste (leaked a live OAuth client_secret)","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","implication":"superseded"},"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:secret-assignment]\"\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=[redacted:secret-assignment] 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=[redacted:secret-assignment] 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":21044,"title":"fix(agent): redact secrets from assistant output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19798","number":19798},{"kind":"pr","value":"#21193","number":21193},{"kind":"pr","value":"#20785","number":20785}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21193,"title":"feat(security): enable secret redaction by default (#17691, #20785)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","cli.py","gateway/run.py","hermes_cli/config.py","tests/hermes_cli/test_debug.py","tests/hermes_cli/test_redact_config_bridge.py","website/docs/reference/environment-variables.md"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -56,12 +56,15 @@\n })\n \n # Snapshot at import time so runtime env mutations (e.g. LLM-generated\n-# `export HERMES_REDACT_SECRETS=[redacted:secret-assignment] cannot enable/disable redaction\n-# mid-session.  OFF by default — user must opt in via\n-# `security.redact_secrets: true` in config.yaml (bridged to this env var\n-# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`\n-# in ~/.hermes/.env.\n-_REDACT_ENABLED = os.getenv(\"HERMES_REDACT_SECRETS\", \"\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n+# `export HERMES_REDACT_SECRETS=[redacted:secret-assignment] cannot disable redaction\n+# mid-session.  ON by default — secure default per issue #17691. Users who\n+# need raw credential values in tool output (e.g. working on the redactor\n+# itself) can opt out via `security.redact_secrets: [redacted:secret-assignment] in config.yaml\n+# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and\n+# cli.py) or `HERMES_REDACT_SECRETS=[redacted:secret-assignment] in ~/.hermes/.env. An opt-out\n+# warning is logged at gateway and CLI startup so operators see the\n+# downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py.\n+_REDACT_ENABLED = os.getenv(\"HERMES_REDACT_SECRETS\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n \n # Known API key prefixes -- match the prefix + contiguous token chars\n _PREFIX_PATTERNS = ["]},{"file":"cli.py","hunks":["@@ -10213,6 +10213,24 @@ def run(self):\n             _welcome_text = \"Welcome to Hermes Agent! Type your message or /help for commands.\"\n             _welcome_color = \"#FFF8DC\"\n         self._console_print(f\"[{_welcome_color}]{_welcome_text}[/]\")\n+\n+        # Redaction opt-out warning (#17691): ON by default, loud when off.\n+        # The redactor snapshots its state at import time so any toggle now\n+        # won't affect the running process — we just want the operator to\n+        # see that they're running without the safety net.\n+        try:\n+            _redact_raw = os.getenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+            if _redact_raw.lower() not in (\"1\", \"true\", \"yes\", \"on\"):\n+                self._console_print(\n+                    \"[bold red]⚠  Secret redaction is DISABLED[/] \"\n+                    f\"(HERMES_REDACT_SECRETS=[redacted:secret-assignment] \"\n+                    \"API keys and tokens may appear verbatim in chat output, \"\n+                    \"session JSONs, and logs. Set \"\n+                    \"[cyan]security.redact_secrets: [redacted:secret-assignment] in config.yaml \"\n+                    \"to re-enable.\"\n+                )\n+        except Exception:\n+            pass\n         # First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists\n         # after an OpenClaw→Hermes migration (especially migrations done by\n         # OpenClaw's own tool, which doesn't archive the source directory)."]},{"file":"gateway/run.py","hunks":["@@ -2763,6 +2763,29 @@ async def start(self) -> bool:\n             )\n         except Exception:\n             pass\n+        # Redaction status: ON by default (#17691). Surface a prominent\n+        # warning if an operator has explicitly opted out so they don't\n+        # forget the downgrade is active — the redactor snapshots its\n+        # state at import time, so this log line is the source of truth\n+        # for this process's lifetime.\n+        try:\n+            _redact_raw = os.getenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+            _redact_on = _redact_raw.lower() in (\"1\", \"true\", \"yes\", \"on\")\n+            if _redact_on:\n+                logger.info(\n+                    \"Secret redaction: ENABLED (tool output, logs, and chat \"\n+                    \"responses are scrubbed before delivery)\"\n+                )\n+            else:\n+                logger.warning(\n+                    \"Secret redaction: DISABLED (HERMES_REDACT_SECRETS=%s). \"\n+                    \"API keys and tokens may appear verbatim in chat output, \"\n+                    \"session JSONs, and logs. Set security.redact_secrets: true \"\n+                    \"in config.yaml to re-enable.\",\n+                    _redact_raw,\n+                )\n+        except Exception:\n+            pass\n         try:\n             from hermes_cli.profiles import get_active_profile_name\n             _profile = get_active_profile_name()"]},{"file":"hermes_cli/config.py","hunks":["@@ -1191,7 +1191,7 @@ def _ensure_hermes_home_managed(home: Path):\n     # Pre-exec security scanning via tirith\n     \"security\": {\n         \"allow_private_urls\": False,  # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)\n-        \"redact_secrets\": False,\n+        \"redact_secrets\": True,\n         \"tirith_enabled\": True,\n         \"tirith_path\": \"tirith\",\n         \"tirith_timeout\": 5,","@@ -3974,10 +3974,10 @@ def load_config() -> Dict[str, Any]:\n \n _SECURITY_COMMENT = \"\"\"\n # ── Security ──────────────────────────────────────────────────────────\n-# Secret redaction is OFF by default — tool output (terminal stdout,\n-# read_file results, web content) passes through unmodified. Set\n-# redact_secrets to true to mask strings that look like API keys, tokens,\n-# and passwords before they enter the model context and logs.\n+# Secret redaction is ON by default — strings that look like API keys,\n+# tokens, and passwords are masked in tool output, logs, and chat\n+# responses before the model or user ever sees them. Set redact_secrets\n+# to false to disable (e.g. when developing the redactor itself).\n # tirith pre-exec scanning is enabled by default when the tirith binary\n # is available. Configure via security.tirith_* keys or env vars\n # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).","@@ -4017,8 +4017,8 @@ def load_config() -> Dict[str, Any]:\n \n _COMMENTED_SECTIONS = \"\"\"\n # ── Security ──────────────────────────────────────────────────────────\n-# Secret redaction is OFF by default. Set to true to mask strings that\n-# look like API keys, tokens, and passwords in tool output and logs.\n+# Secret redaction is ON by default. Set to false to pass tool output,\n+# logs, and chat responses through unmodified (e.g. for redactor dev).\n #\n # security:\n #   redact_secrets: true"]},{"file":"tests/hermes_cli/test_debug.py","hunks":["@@ -291,9 +291,11 @@ def hermes_home_with_secret(self, tmp_path, monkeypatch):\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+        # Baseline fixture: no explicit env-var opinion. With the post-#17691\n+        # default of ON, the default-path tests below exercise the\n+        # secure-default behaviour. The `force=True` regression test\n+        # setenvs to \"false\" inline to prove force=True works even when\n+        # the runtime flag is disabled.\n         monkeypatch.delenv(\"HERMES_REDACT_SECRETS\", raising=False)\n \n         logs_dir = home / \"logs\"","@@ -324,21 +326,26 @@ def test_redact_false_passes_through(self, hermes_home_with_secret):\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+    def test_force_true_works_when_redaction_disabled(\n+        self, hermes_home_with_secret, monkeypatch\n+    ):\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+        input unchanged when HERMES_REDACT_SECRETS=[redacted:secret-assignment] and the share-time\n+        redaction feature ships silently broken for users who opted out of\n+        runtime redaction (e.g. developers working on the redactor itself).\n         \"\"\"\n         import os\n \n+        # Force the runtime flag off so we're exercising the force=True path,\n+        # not the default-on path.\n+        monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"false\")\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+        assert os.environ.get(\"HERMES_REDACT_SECRETS\", \"\") == \"false\"\n \n         snap = _capture_log_snapshot(\"agent\", tail_lines=10)\n "]},{"file":"tests/hermes_cli/test_redact_config_bridge.py","hunks":["@@ -72,11 +72,13 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path):\n     assert \"ENV_VAR=false\" in result.stdout\n \n \n-def test_redact_secrets_default_false_when_unset(tmp_path):\n-    \"\"\"Without the config key, redaction stays OFF by default.\n+def test_redact_secrets_default_true_when_unset(tmp_path):\n+    \"\"\"Without the config key or env var, redaction is ON by default (#17691).\n \n-    Secret redaction is opt-in — users who want it must set\n-    `security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=[redacted:secret-assignment]\n+    Secret redaction is a secure default — users who need raw credential\n+    values in tool output (e.g. working on the redactor itself) must set\n+    `security.redact_secrets: [redacted:secret-assignment] explicitly (or\n+    `HERMES_REDACT_SECRETS=[redacted:secret-assignment]\n     \"\"\"\n     hermes_home = tmp_path / \".hermes\"\n     hermes_home.mkdir()","@@ -107,7 +109,7 @@ def test_redact_secrets_default_false_when_unset(tmp_path):\n         timeout=30,\n     )\n     assert result.returncode == 0, f\"probe failed: {result.stderr}\"\n-    assert \"REDACT_ENABLED=False\" in result.stdout\n+    assert \"REDACT_ENABLED=True\" in result.stdout\n \n \n def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path):"]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -456,7 +456,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us\n | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) |\n | `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time. |\n | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. |\n-| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in logs and shareable outputs (default: `true`). |\n+| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). |\n | `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that restricts `write_file`/`patch` writes; paths outside require approval. |\n | `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the \"file changed since you read it\" guard on `patch`/`write_file`. |\n | `HERMES_CORE_TOOLS` | Comma-separated override for the canonical core tool list (advanced; rarely needed). |\n"]}]},{"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":"#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":23821,"title":"Fix/gateway redact outbound chat","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1207d81ee","number":0},{"kind":"pr","value":"#53907","number":53907},{"kind":"pr","value":"#23810","number":23810},{"kind":"pr","value":"#53907","number":53907}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23822,"title":"fix(gateway): redact outbound replies before delivery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1207d81ee","number":0},{"kind":"pr","value":"#53907","number":53907},{"kind":"pr","value":"#23810","number":23810},{"kind":"pr","value":"#53907","number":53907}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":24303,"title":"fix(security): Apply secret redaction to all outbound platform messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1207d81ee","number":0},{"kind":"pr","value":"#53907","number":53907},{"kind":"pr","value":"#23810","number":23810},{"kind":"pr","value":"#53907","number":53907}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":29521,"title":"feat(gateway): Layer-2 credential redaction on outbound platform messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1207d81ee","number":0},{"kind":"pr","value":"#53907","number":53907},{"kind":"pr","value":"#23810","number":23810},{"kind":"pr","value":"#53907","number":53907}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53907,"title":"fix(gateway): redact full credential set on outbound chat path (#23810)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_telegram_noise_filter.py"]},{"kind":"pr","number":56125,"title":"fix(security): require explicit consent before uploading debug logs","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"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":64376,"title":"fix(gateway): redact credentials from inbound message logging","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":67969,"title":"fix(config): mask inline fallback provider keys in hermes dump","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"78ba04f21","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"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":"#69561","number":69561},{"kind":"pr","value":"#22139","number":22139},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#22210","number":22210},{"kind":"pr","value":"#22139","number":22139},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#47952","number":47952},{"kind":"pr","value":"#47952","number":47952},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#22016","number":22016},{"kind":"pr","value":"#46006","number":46006},{"kind":"pr","value":"#69561","number":69561},{"kind":"pr","value":"#22016","number":22016},{"kind":"pr","value":"#46006","number":46006},{"kind":"pr","value":"#22139","number":22139},{"kind":"pr","value":"#22210","number":22210},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#47952","number":47952},{"kind":"pr","value":"#69561","number":69561},{"kind":"pr","value":"#69561","number":69561},{"kind":"pr","value":"#22139","number":22139},{"kind":"pr","value":"#22210","number":22210},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#56125","number":56125},{"kind":"pr","value":"#47952","number":47952},{"kind":"pr","value":"#1","number":1},{"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":"#1","number":1},{"kind":"pr","value":"#9","number":9},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#4338","number":4338},{"kind":"pr","value":"#22016","number":22016},{"kind":"pr","value":"#46006","number":46006},{"kind":"pr","value":"#69561","number":69561}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}