fix: /debug privacy — auto-delete pastes after 1 hour, add privacy notices (#10510)

- Pastes uploaded by /debug now auto-delete after 1 hour via a detached
  background process that sends DELETE to paste.rs
- CLI: shows privacy notice listing what data will be uploaded
- Gateway: only uploads summary report (system info + log tails), NOT
  full log files containing conversation content
- Added 'hermes debug delete <url>' for immediate manual deletion
- 16 new tests covering auto-delete scheduling, paste deletion, privacy
  notices, and the delete subcommand

Addresses user privacy concern where /debug uploaded full conversation
logs to a public paste service with no warning or expiry.
This commit is contained in:
Teknium 2026-04-15 13:40:27 -07:00 committed by GitHub
parent 2edbf15560
commit 19142810ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 355 additions and 31 deletions

View file

@ -6803,11 +6803,17 @@ class GatewayRunner:
})
async def _handle_debug_command(self, event: MessageEvent) -> str:
"""Handle /debug — upload debug report + logs and return paste URLs."""
"""Handle /debug — upload debug report (summary only) and return paste URLs.
Gateway uploads ONLY the summary report (system info + log tails),
NOT full log files, to protect conversation privacy. Users who need
full log uploads should use ``hermes debug share`` from the CLI.
"""
import asyncio
from hermes_cli.debug import (
_capture_dump, collect_debug_report, _read_full_log,
upload_to_pastebin,
_capture_dump, collect_debug_report,
upload_to_pastebin, _schedule_auto_delete,
_GATEWAY_PRIVACY_NOTICE,
)
loop = asyncio.get_running_loop()
@ -6816,43 +6822,25 @@ class GatewayRunner:
def _collect_and_upload():
dump_text = _capture_dump()
report = collect_debug_report(log_lines=200, dump_text=dump_text)
agent_log = _read_full_log("agent")
gateway_log = _read_full_log("gateway")
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
urls = {}
failures = []
try:
urls["Report"] = upload_to_pastebin(report)
except Exception as exc:
return f"✗ Failed to upload debug report: {exc}"
if agent_log:
try:
urls["agent.log"] = upload_to_pastebin(agent_log)
except Exception:
failures.append("agent.log")
# Schedule auto-deletion after 1 hour
_schedule_auto_delete(list(urls.values()))
if gateway_log:
try:
urls["gateway.log"] = upload_to_pastebin(gateway_log)
except Exception:
failures.append("gateway.log")
lines = ["**Debug report uploaded:**", ""]
lines = [_GATEWAY_PRIVACY_NOTICE, "", "**Debug report uploaded:**", ""]
label_width = max(len(k) for k in urls)
for label, url in urls.items():
lines.append(f"`{label:<{label_width}}` {url}")
if failures:
lines.append(f"\n_(failed to upload: {', '.join(failures)})_")
lines.append("\nShare these links with the Hermes team for support.")
lines.append("")
lines.append("⏱ Pastes will auto-delete in 1 hour.")
lines.append("For full log uploads, use `hermes debug share` from the CLI.")
lines.append("Share these links with the Hermes team for support.")
return "\n".join(lines)
return await loop.run_in_executor(None, _collect_and_upload)

View file

@ -27,6 +27,110 @@ _DPASTE_COM_URL = "https://dpaste.com/api/"
# paste.rs caps at ~1 MB; we stay under that with headroom.
_MAX_LOG_BYTES = 512_000
# Auto-delete pastes after this many seconds (1 hour).
_AUTO_DELETE_SECONDS = 3600
# ---------------------------------------------------------------------------
# Privacy / delete helpers
# ---------------------------------------------------------------------------
_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 may contain
conversation fragments and file paths)
Full agent.log and gateway.log (up to 512 KB each likely contains
conversation content, tool outputs, and file paths)
Pastes auto-delete after 1 hour.
"""
_GATEWAY_PRIVACY_NOTICE = (
"⚠️ **Privacy notice:** This uploads system info + recent log tails "
"(may contain conversation fragments) to a public paste service. "
"Full logs are NOT included from the gateway — use `hermes debug share` "
"from the CLI for full log uploads.\n"
"Pastes auto-delete after 1 hour."
)
def _extract_paste_id(url: str) -> Optional[str]:
"""Extract the paste ID from a paste.rs or dpaste.com URL.
Returns the ID string, or None if the URL doesn't match a known service.
"""
url = url.strip().rstrip("/")
for prefix in ("https://paste.rs/", "http://paste.rs/"):
if url.startswith(prefix):
return url[len(prefix):]
return None
def delete_paste(url: str) -> bool:
"""Delete a paste from paste.rs. Returns True on success.
Only paste.rs supports unauthenticated DELETE. dpaste.com pastes
expire automatically but cannot be deleted via API.
"""
paste_id = _extract_paste_id(url)
if not paste_id:
raise ValueError(
f"Cannot delete: only paste.rs URLs are supported. Got: {url}"
)
target = f"{_PASTE_RS_URL}{paste_id}"
req = urllib.request.Request(
target, method="DELETE",
headers={"User-Agent": "hermes-agent/debug-share"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return 200 <= resp.status < 300
def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS):
"""Spawn a detached process to delete paste.rs pastes after *delay_seconds*.
The child process is fully detached (``start_new_session=True``) so it
survives the parent exiting (important for CLI mode). Only paste.rs
URLs are attempted dpaste.com pastes auto-expire on their own.
"""
import subprocess
paste_rs_urls = [u for u in urls if _extract_paste_id(u)]
if not paste_rs_urls:
return
# Build a tiny inline Python script. No imports beyond stdlib.
url_list = ", ".join(f'"{u}"' for u in paste_rs_urls)
script = (
"import time, urllib.request; "
f"time.sleep({delay_seconds}); "
f"[urllib.request.urlopen(urllib.request.Request(u, method='DELETE', "
f"headers={{'User-Agent': 'hermes-agent/auto-delete'}}), timeout=15) "
f"for u in [{url_list}]]"
)
try:
subprocess.Popen(
[sys.executable, "-c", script],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass # Best-effort; manual delete still available.
def _delete_hint(url: str) -> str:
"""Return a one-liner delete command for the given paste URL."""
paste_id = _extract_paste_id(url)
if paste_id:
return f"hermes debug delete {url}"
# dpaste.com — no API delete, expires on its own.
return "(auto-expires per dpaste.com policy)"
def _upload_paste_rs(content: str) -> str:
"""Upload to paste.rs. Returns the paste URL.
@ -250,6 +354,9 @@ def run_debug_share(args):
expiry = getattr(args, "expire", 7)
local_only = getattr(args, "local", False)
if not local_only:
print(_PRIVACY_NOTICE)
print("Collecting debug report...")
# Capture dump once — prepended to every paste for context.
@ -315,22 +422,56 @@ def run_debug_share(args):
if failures:
print(f"\n (failed to upload: {', '.join(failures)})")
# Schedule auto-deletion after 1 hour
_schedule_auto_delete(list(urls.values()))
print(f"\n⏱ Pastes will auto-delete in 1 hour.")
# Manual delete fallback
print(f"To delete now: hermes debug delete <url>")
print(f"\nShare these links with the Hermes team for support.")
def run_debug_delete(args):
"""Delete one or more paste URLs uploaded by /debug."""
urls = getattr(args, "urls", [])
if not urls:
print("Usage: hermes debug delete <url> [<url> ...]")
print(" Deletes paste.rs pastes uploaded by 'hermes debug share'.")
return
for url in urls:
try:
ok = delete_paste(url)
if ok:
print(f" ✓ Deleted: {url}")
else:
print(f" ✗ Failed to delete: {url} (unexpected response)")
except ValueError as exc:
print(f"{exc}")
except Exception as exc:
print(f" ✗ Could not delete {url}: {exc}")
def run_debug(args):
"""Route debug subcommands."""
subcmd = getattr(args, "debug_command", None)
if subcmd == "share":
run_debug_share(args)
elif subcmd == "delete":
run_debug_delete(args)
else:
# Default: show help
print("Usage: hermes debug share [--lines N] [--expire N] [--local]")
print("Usage: hermes debug <command>")
print()
print("Commands:")
print(" share Upload debug report to a paste service and print URL")
print(" delete Delete a previously uploaded paste")
print()
print("Options:")
print("Options (share):")
print(" --lines N Number of log lines to include (default: 200)")
print(" --expire N Paste expiry in days (default: 7)")
print(" --local Print report locally instead of uploading")
print()
print("Options (delete):")
print(" <url> ... One or more paste URLs to delete")

View file

@ -5073,6 +5073,7 @@ Examples:
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
hermes debug delete <url> Delete a previously uploaded paste
""",
)
debug_sub = debug_parser.add_subparsers(dest="debug_command")
@ -5092,6 +5093,14 @@ Examples:
"--local", action="store_true",
help="Print the report locally instead of uploading",
)
delete_parser = debug_sub.add_parser(
"delete",
help="Delete a paste uploaded by 'hermes debug share'",
)
delete_parser.add_argument(
"urls", nargs="*", default=[],
help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)",
)
debug_parser.set_defaults(func=cmd_debug)
# =========================================================================

View file

@ -428,7 +428,9 @@ class TestRunDebug:
run_debug(args)
out = capsys.readouterr().out
assert "hermes debug share" in out
assert "hermes debug" in out
assert "share" in out
assert "delete" in out
def test_share_subcommand_routes(self, hermes_home):
from hermes_cli.debug import run_debug
@ -459,3 +461,187 @@ class TestArgparseIntegration:
args = MagicMock()
args.debug_command = None
cmd_debug(args)
# ---------------------------------------------------------------------------
# Delete / auto-delete
# ---------------------------------------------------------------------------
class TestExtractPasteId:
def test_paste_rs_url(self):
from hermes_cli.debug import _extract_paste_id
assert _extract_paste_id("https://paste.rs/abc123") == "abc123"
def test_paste_rs_trailing_slash(self):
from hermes_cli.debug import _extract_paste_id
assert _extract_paste_id("https://paste.rs/abc123/") == "abc123"
def test_http_variant(self):
from hermes_cli.debug import _extract_paste_id
assert _extract_paste_id("http://paste.rs/xyz") == "xyz"
def test_non_paste_rs_returns_none(self):
from hermes_cli.debug import _extract_paste_id
assert _extract_paste_id("https://dpaste.com/ABCDEF") is None
def test_empty_returns_none(self):
from hermes_cli.debug import _extract_paste_id
assert _extract_paste_id("") is None
class TestDeletePaste:
def test_delete_sends_delete_request(self):
from hermes_cli.debug import delete_paste
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("hermes_cli.debug.urllib.request.urlopen",
return_value=mock_resp) as mock_open:
result = delete_paste("https://paste.rs/abc123")
assert result is True
req = mock_open.call_args[0][0]
assert req.method == "DELETE"
assert "paste.rs/abc123" in req.full_url
def test_delete_rejects_non_paste_rs(self):
from hermes_cli.debug import delete_paste
with pytest.raises(ValueError, match="only paste.rs"):
delete_paste("https://dpaste.com/something")
class TestScheduleAutoDelete:
def test_spawns_detached_process(self):
from hermes_cli.debug import _schedule_auto_delete
with patch("subprocess.Popen") as mock_popen:
_schedule_auto_delete(
["https://paste.rs/abc", "https://paste.rs/def"],
delay_seconds=10,
)
mock_popen.assert_called_once()
call_args = mock_popen.call_args
# Verify detached
assert call_args[1]["start_new_session"] is True
# Verify the script references both URLs
script = call_args[0][0][2] # [python, -c, script]
assert "paste.rs/abc" in script
assert "paste.rs/def" in script
assert "time.sleep(10)" in script
def test_skips_non_paste_rs_urls(self):
from hermes_cli.debug import _schedule_auto_delete
with patch("subprocess.Popen") as mock_popen:
_schedule_auto_delete(["https://dpaste.com/something"])
mock_popen.assert_not_called()
def test_handles_popen_failure_gracefully(self):
from hermes_cli.debug import _schedule_auto_delete
with patch("subprocess.Popen",
side_effect=OSError("no such file")):
# Should not raise
_schedule_auto_delete(["https://paste.rs/abc"])
class TestRunDebugDelete:
def test_deletes_valid_url(self, capsys):
from hermes_cli.debug import run_debug_delete
args = MagicMock()
args.urls = ["https://paste.rs/abc"]
with patch("hermes_cli.debug.delete_paste", return_value=True):
run_debug_delete(args)
out = capsys.readouterr().out
assert "Deleted" in out
assert "paste.rs/abc" in out
def test_handles_delete_failure(self, capsys):
from hermes_cli.debug import run_debug_delete
args = MagicMock()
args.urls = ["https://paste.rs/abc"]
with patch("hermes_cli.debug.delete_paste",
side_effect=Exception("network error")):
run_debug_delete(args)
out = capsys.readouterr().out
assert "Could not delete" in out
def test_no_urls_shows_usage(self, capsys):
from hermes_cli.debug import run_debug_delete
args = MagicMock()
args.urls = []
run_debug_delete(args)
out = capsys.readouterr().out
assert "Usage" in out
class TestShareIncludesAutoDelete:
"""Verify that run_debug_share schedules auto-deletion and prints TTL."""
def test_share_schedules_auto_delete(self, hermes_home, capsys):
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug.upload_to_pastebin",
return_value="https://paste.rs/test1"), \
patch("hermes_cli.debug._schedule_auto_delete") as mock_sched:
run_debug_share(args)
# auto-delete was scheduled with the uploaded URLs
mock_sched.assert_called_once()
urls_arg = mock_sched.call_args[0][0]
assert "https://paste.rs/test1" in urls_arg
out = capsys.readouterr().out
assert "auto-delete" in out
def test_share_shows_privacy_notice(self, hermes_home, capsys):
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug.upload_to_pastebin",
return_value="https://paste.rs/test"), \
patch("hermes_cli.debug._schedule_auto_delete"):
run_debug_share(args)
out = capsys.readouterr().out
assert "public paste service" in out
def test_local_no_privacy_notice(self, hermes_home, capsys):
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = True
with patch("hermes_cli.dump.run_dump"):
run_debug_share(args)
out = capsys.readouterr().out
assert "public paste service" not in out