fix(security): require explicit consent before uploading debug logs

`hermes debug share` printed a privacy notice and then uploaded the
report to a public paste service in the same breath — the user never got
to say yes or no. Add a consent gate: an interactive [y/N] prompt, a
--yes/-y flag to skip it, and a hard refusal (exit 1) in non-interactive
contexts (no TTY on stdin) so debug data can't be exposed silently in
scripts/CI.

- New _confirm_upload() helper gates the actual upload after the notice.
- Applied to BOTH upload paths: the public paste.rs path and the --nous
  Nous-S3 path (the latter is a sibling site the original PR missed).
- The /debug slash command passes yes=True (typing /debug is itself the
  consent action, and input() would hang inside prompt_toolkit).
- Rewrote the privacy notice for accuracy: secrets (API keys/tokens/
  passwords) ARE force-redacted before upload; PII (display name,
  platform user ID, verbatim message content, filesystem paths) is NOT,
  and that URL is public.

Fixes #22016.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
This commit is contained in:
JezzaHehn
2026-07-01 00:17:16 -07:00
committed by Teknium
parent 3aebdb1d23
commit 54f32af4a7
4 changed files with 204 additions and 14 deletions
+6 -1
View File
@@ -2593,7 +2593,12 @@ class CLICommandsMixin:
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:
+50 -10
View File
@@ -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)
⚠️ This will upload system info + logs to a PUBLIC paste service.
Pastes auto-delete after 6 hours.
Cryptographic secrets (API keys, tokens, passwords) are redacted before
upload, but the following personal data is NOT redacted and will be public:
• Your display name and persistent platform user ID
• Verbatim content of your recent messages (prompts, responses, tool output)
• Local filesystem paths
• Any other PII present in the logs
The resulting URL is public to anyone who has the link. Pastes auto-delete
after 6 hours, but may be archived by third parties in the meantime.
Use --local to view the report without uploading.
"""
_GATEWAY_PRIVACY_NOTICE = (
@@ -774,6 +778,38 @@ def build_debug_share(
)
def _confirm_upload(args) -> bool:
"""Require explicit consent before any debug-share upload.
The privacy notice is printed by the caller. This gates the actual
upload: with ``--yes`` (or ``-y``) we proceed unprompted; otherwise we
ask an interactive ``[y/N]`` question. In a non-interactive context
(no TTY on stdin — scripts, CI, piped input) we refuse rather than
hang or upload silently, so debug data can't be exposed without a
deliberate ``--yes``.
Returns True to proceed with the upload, False to abort.
"""
if bool(getattr(args, "yes", False)):
return True
if not sys.stdin.isatty():
print(
"ERROR: Non-interactive mode requires --yes to confirm upload.\n"
" This prevents accidental exposure of personal data.\n"
" Use --local to view the report without uploading.",
file=sys.stderr,
)
sys.exit(1)
try:
answer = input("Upload debug report? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in ("y", "yes"):
print("Aborted.")
return False
return True
def run_debug_share(args):
"""Collect debug report + full logs, upload each, print URLs."""
log_lines = getattr(args, "lines", 200)
@@ -805,10 +841,12 @@ def run_debug_share(args):
return
if nous:
_run_debug_share_nous(log_lines=log_lines, redact=redact)
_run_debug_share_nous(args, log_lines=log_lines, redact=redact)
return
print(_PRIVACY_NOTICE)
if not _confirm_upload(args):
return
print("Collecting debug report...")
print("Uploading...")
@@ -856,7 +894,7 @@ _NOUS_PRIVACY_NOTICE = """\
"""
def _run_debug_share_nous(*, log_lines: int, redact: bool) -> None:
def _run_debug_share_nous(args, *, log_lines: int, redact: bool) -> None:
"""Handle ``hermes debug share --nous``: upload the bundle to Nous-S3.
Collects the same force-redacted bundle as the paste path, gzips it into
@@ -867,6 +905,8 @@ def _run_debug_share_nous(*, log_lines: int, redact: bool) -> None:
from hermes_cli.diagnostics_upload import share_to_nous
print(_NOUS_PRIVACY_NOTICE)
if not _confirm_upload(args):
return
if not redact:
print(
"⚠️ --no-redact is set: secrets in your logs will NOT be redacted "
+12 -1
View File
@@ -24,7 +24,8 @@ def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None:
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
hermes debug share Upload debug report and print URL
hermes debug share Upload debug report (asks for confirmation)
hermes debug share --yes Skip confirmation (for scripts/CI)
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
@@ -55,6 +56,16 @@ Examples:
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",
+136 -2
View File
@@ -1289,7 +1289,8 @@ class TestShareIncludesAutoDelete:
run_debug_share(args)
out = capsys.readouterr().out
assert "public paste service" in out
assert "PUBLIC paste service" in out
assert "NOT redacted" in out
def test_local_no_privacy_notice(self, hermes_home, capsys):
from hermes_cli.debug import run_debug_share
@@ -1304,7 +1305,7 @@ class TestShareIncludesAutoDelete:
run_debug_share(args)
out = capsys.readouterr().out
assert "public paste service" not in out
assert "PUBLIC paste service" not in out
# ---------------------------------------------------------------------------
@@ -1519,6 +1520,7 @@ class TestRunDebugShareNous:
local = False
nous = True
no_redact = False
yes = True
a = _A()
for k, v in over.items():
@@ -1602,6 +1604,9 @@ class TestDebugSlashCommand:
c = self._captured("/debug")
assert c["nous"] is False and c["local"] is False
assert c["lines"] == 200 and c["expire"] == 7
# The slash command IS the consent action → skip the [y/N] prompt
# (input() would hang inside prompt_toolkit's event loop).
assert c["yes"] is True
def test_nous_word_sets_nous(self):
c = self._captured("/debug nous")
@@ -1629,3 +1634,132 @@ class TestDebugSlashCommand:
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