fix(debug): redact log content at upload time in hermes debug share

Apply agent.redact.redact_sensitive_text with force=True to log content
captured by _capture_log_snapshot before it reaches upload_to_pastebin.
On-disk logs are untouched. Compatible with the off-by-default local
redaction policy from #16794: this is upload-time-only and applies
regardless of security.redact_secrets because the public paste service
is the leak surface. A visible banner is prepended to each uploaded log
paste so reviewers know redaction was applied. --no-redact preserves
deliberate unredacted sharing for maintainer-coordinated cases.

The bug-report, setup-help, and feature-request issue templates direct
users to run hermes debug share and paste the resulting public URLs.
With redaction off by default per #16794, those uploads have been
carrying credentials onto paste.rs and dpaste.com.

force=True is non-negotiable: without it, redact_sensitive_text
short-circuits at agent/redact.py:322 when the env var is unset, so the
fix would silently be a no-op for its target audience. A regression
test pins this down.

Fixes #19316
This commit is contained in:
GodsBoy
2026-05-03 19:58:44 +02:00
committed by Teknium
parent c9a3f36f56
commit b8ae8cc801
4 changed files with 308 additions and 7 deletions

View File

@@ -1,12 +1,19 @@
"""``hermes debug`` debug tools for Hermes Agent. """``hermes debug`` debug tools for Hermes Agent.
Currently supports: Currently supports:
hermes debug share Upload debug report (system info + logs) to a hermes debug share Upload debug report (system info + logs) to a
paste service and print a shareable URL. paste service and print a shareable URL.
By default, log content is run through
``agent.redact.redact_sensitive_text`` with
``force=True`` before upload so credentials in
``~/.hermes/logs/*.log`` are not leaked into
the public paste service. Pass ``--no-redact``
to disable.
""" """
import io import io
import json import json
import logging
import sys import sys
import time import time
import urllib.error import urllib.error
@@ -19,6 +26,16 @@ from typing import Optional
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from utils import atomic_replace from utils import atomic_replace
logger = logging.getLogger(__name__)
# Banner prepended to upload-bound log content when redaction is enabled.
# Visible in the public paste so reviewers know the content was sanitized.
# Kept short; the trailing newline guarantees the banner sits on its own line.
_REDACTION_BANNER = (
"[hermes debug share: log content redacted at upload time. "
"run with --no-redact to disable]\n"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Paste services — try paste.rs first, dpaste.com as fallback. # Paste services — try paste.rs first, dpaste.com as fallback.
@@ -368,17 +385,40 @@ def _resolve_log_path(log_name: str) -> Optional[Path]:
return None return None
def _redact_log_text(text: str) -> str:
"""Run ``redact_sensitive_text`` with ``force=True`` over upload-bound text.
Uses ``force=True`` so redaction fires regardless of the operator's
``security.redact_secrets`` setting. The local on-disk log file is
not modified; only the in-memory copy headed for the public paste
service is sanitized. Returns the redacted text (or the original
when empty / non-string).
"""
if not text:
return text
from agent.redact import redact_sensitive_text
return redact_sensitive_text(text, force=True)
def _capture_log_snapshot( def _capture_log_snapshot(
log_name: str, log_name: str,
*, *,
tail_lines: int, tail_lines: int,
max_bytes: int = _MAX_LOG_BYTES, max_bytes: int = _MAX_LOG_BYTES,
redact: bool = True,
) -> LogSnapshot: ) -> LogSnapshot:
"""Capture a log once and derive summary/full-log views from it. """Capture a log once and derive summary/full-log views from it.
The report tail and standalone log upload must come from the same file The report tail and standalone log upload must come from the same file
snapshot. Otherwise a rotation/truncate between reads can make the report snapshot. Otherwise a rotation/truncate between reads can make the report
look newer than the uploaded ``agent.log`` paste. look newer than the uploaded ``agent.log`` paste.
When ``redact`` is True (the default), both ``tail_text`` and
``full_text`` are run through ``_redact_log_text`` so the snapshot
returned is upload-safe. The on-disk log file is never modified.
Pass ``redact=False`` to capture original log content (used by
``hermes debug share --no-redact``).
""" """
log_path = _resolve_log_path(log_name) log_path = _resolve_log_path(log_name)
if log_path is None: if log_path is None:
@@ -438,18 +478,34 @@ def _capture_log_snapshot(
if truncated: if truncated:
full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}" full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}"
if redact:
tail_text = _redact_log_text(tail_text)
full_text = _redact_log_text(full_text)
return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text) return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)
except Exception as exc: except Exception as exc:
return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None) return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None)
def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]: def _capture_default_log_snapshots(
"""Capture all logs used by debug-share exactly once.""" log_lines: int, *, redact: bool = True
) -> dict[str, LogSnapshot]:
"""Capture all logs used by debug-share exactly once.
``redact`` is forwarded to each ``_capture_log_snapshot`` call so all
captured logs share the same redaction policy for a given run.
"""
errors_lines = min(log_lines, 100) errors_lines = min(log_lines, 100)
return { return {
"agent": _capture_log_snapshot("agent", tail_lines=log_lines), "agent": _capture_log_snapshot(
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines), "agent", tail_lines=log_lines, redact=redact
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines), ),
"errors": _capture_log_snapshot(
"errors", tail_lines=errors_lines, redact=redact
),
"gateway": _capture_log_snapshot(
"gateway", tail_lines=errors_lines, redact=redact
),
} }
@@ -532,6 +588,7 @@ def run_debug_share(args):
log_lines = getattr(args, "lines", 200) log_lines = getattr(args, "lines", 200)
expiry = getattr(args, "expire", 7) expiry = getattr(args, "expire", 7)
local_only = getattr(args, "local", False) local_only = getattr(args, "local", False)
redact = not getattr(args, "no_redact", False)
if not local_only: if not local_only:
print(_PRIVACY_NOTICE) print(_PRIVACY_NOTICE)
@@ -539,8 +596,16 @@ def run_debug_share(args):
print("Collecting debug report...") print("Collecting debug report...")
# Capture dump once — prepended to every paste for context. # Capture dump once — prepended to every paste for context.
# The dump is already redacted at extract time via dump.py:_redact;
# log_snapshots are redacted by _capture_default_log_snapshots when
# redact=True so credentials never reach the public paste service.
dump_text = _capture_dump() dump_text = _capture_dump()
log_snapshots = _capture_default_log_snapshots(log_lines) log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
if redact:
logger.info(
"hermes debug share: applied force-mode redaction to log snapshots before upload"
)
report = collect_debug_report( report = collect_debug_report(
log_lines=log_lines, log_lines=log_lines,
@@ -556,6 +621,15 @@ def run_debug_share(args):
if gateway_log: if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
# Visible banner so reviewers reading the public paste know redaction
# was applied at upload time. Banner is omitted under --no-redact.
if redact:
report = _REDACTION_BANNER + report
if agent_log:
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if local_only: if local_only:
print(report) print(report)
if agent_log: if agent_log:
@@ -666,6 +740,7 @@ def run_debug(args):
print(" --lines N Number of log lines to include (default: 200)") print(" --lines N Number of log lines to include (default: 200)")
print(" --expire N Paste expiry in days (default: 7)") print(" --expire N Paste expiry in days (default: 7)")
print(" --local Print report locally instead of uploading") print(" --local Print report locally instead of uploading")
print(" --no-redact Disable upload-time secret redaction (default: redact)")
print() print()
print("Options (delete):") print("Options (delete):")
print(" <url> ... One or more paste URLs to delete") print(" <url> ... One or more paste URLs to delete")

View File

@@ -8891,6 +8891,7 @@ Examples:
hermes debug share --lines 500 Include more log lines hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload) hermes debug share --local Print report locally (no upload)
hermes debug share --no-redact Disable upload-time secret redaction
hermes debug delete <url> Delete a previously uploaded paste hermes debug delete <url> Delete a previously uploaded paste
""", """,
) )
@@ -8916,6 +8917,16 @@ Examples:
action="store_true", action="store_true",
help="Print the report locally instead of uploading", help="Print the report locally instead of uploading",
) )
share_parser.add_argument(
"--no-redact",
action="store_true",
help=(
"Disable upload-time secret redaction (default: redact). Logs "
"are normally run through agent.redact.redact_sensitive_text "
"with force=True before upload so credentials are not leaked "
"into the public paste service."
),
)
delete_parser = debug_sub.add_parser( delete_parser = debug_sub.add_parser(
"delete", "delete",
help="Delete a paste uploaded by 'hermes debug share'", help="Delete a paste uploaded by 'hermes debug share'",

View File

@@ -679,6 +679,8 @@ AUTHOR_MAP = {
"ztzheng@163.com": "chengoak", # PR #17467 "ztzheng@163.com": "chengoak", # PR #17467
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only "24110240104@m.fudan.edu.cn": "YuShu", # co-author only
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951 "charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
# Debug share upload-time redaction (May 2026)
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
} }

View File

@@ -273,6 +273,101 @@ class TestCaptureLogSnapshot:
assert "rotated agent data" in snap.full_text assert "rotated agent data" in snap.full_text
# ---------------------------------------------------------------------------
# Capture log redaction (force=True applies regardless of HERMES_REDACT_SECRETS)
# ---------------------------------------------------------------------------
# A vendor-prefixed token used across redaction tests. Long enough to clear
# the redactor's `floor` parameter so it actually masks rather than fully blanks.
_REDACT_FIXTURE_TOKEN = "sk-proj-A1B2C3D4E5F6G7H8I9J0aA"
class TestCaptureLogSnapshotRedaction:
"""Pin upload-time redaction at the _capture_log_snapshot boundary."""
@pytest.fixture
def hermes_home_with_secret(self, tmp_path, monkeypatch):
"""Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Critical: ensure the user has NOT opted in to redaction. The whole
# point of this PR is that share-time redaction works for users who
# never set this env var.
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs"
logs_dir.mkdir()
(logs_dir / "agent.log").write_text(
f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n"
)
(logs_dir / "errors.log").write_text("")
(logs_dir / "gateway.log").write_text("")
return home
def test_default_redacts_tail_and_full_text(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("agent", tail_lines=10)
# Both views the upload uses must be sanitized.
assert _REDACT_FIXTURE_TOKEN not in snap.tail_text
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_redact_false_passes_through(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("agent", tail_lines=10, redact=False)
# Original token survives when the caller opts out.
assert _REDACT_FIXTURE_TOKEN in snap.tail_text
assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "")
def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret):
"""Regression test: redact_sensitive_text short-circuits without force=True.
If a future refactor drops `force=True` from `_redact_log_text`, this
test fails immediately. Without `force=True`, the redactor returns the
input unchanged when HERMES_REDACT_SECRETS is unset, and the feature
ships silently broken for its target audience.
"""
import os
from hermes_cli.debug import _capture_log_snapshot
# Belt-and-suspenders: confirm the env var is genuinely unset for this
# test so we know we're exercising the force=True path.
assert os.environ.get("HERMES_REDACT_SECRETS", "") == ""
snap = _capture_log_snapshot("agent", tail_lines=10)
assert _REDACT_FIXTURE_TOKEN not in snap.tail_text
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_capture_default_log_snapshots_threads_redact(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_default_log_snapshots
snaps = _capture_default_log_snapshots(50)
# Default threads redact=True to all three captured logs.
assert _REDACT_FIXTURE_TOKEN not in snaps["agent"].tail_text
assert _REDACT_FIXTURE_TOKEN not in (snaps["agent"].full_text or "")
def test_capture_default_log_snapshots_no_redact_passes_through(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_default_log_snapshots
snaps = _capture_default_log_snapshots(50, redact=False)
assert _REDACT_FIXTURE_TOKEN in snaps["agent"].tail_text
assert _REDACT_FIXTURE_TOKEN in (snaps["agent"].full_text or "")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Debug report collection # Debug report collection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -556,6 +651,124 @@ class TestRunDebugShare:
assert "all failed" in out.err assert "all failed" in out.err
# ---------------------------------------------------------------------------
# Share-time redaction wiring + visible banner
# ---------------------------------------------------------------------------
class TestRunDebugShareRedaction:
"""End-to-end: --no-redact flag, banner injection, default behavior."""
@pytest.fixture
def hermes_home_with_secret(self, tmp_path, monkeypatch):
"""Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs"
logs_dir.mkdir()
(logs_dir / "agent.log").write_text(
f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n"
)
(logs_dir / "errors.log").write_text("")
(logs_dir / "gateway.log").write_text(
f"2026-04-12 17:00:01 INFO gateway.run: token {_REDACT_FIXTURE_TOKEN}\n"
)
return home
def test_default_share_redacts_uploaded_content(
self, hermes_home_with_secret, capsys
):
"""The uploaded report and full-log pastes do not contain the raw token."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = False
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
# At least the report plus one full log paste reached the upload path.
assert len(captured) >= 2
for content in captured:
assert _REDACT_FIXTURE_TOKEN not in content, (
"raw token leaked into upload-bound content"
)
def test_default_share_includes_redaction_banner(
self, hermes_home_with_secret, capsys
):
"""Each upload-bound paste carries the visible redaction banner."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = False
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
for content in captured:
assert "redacted at upload time" in content, (
"redaction banner missing from upload-bound content"
)
def test_no_redact_flag_disables_redaction_and_banner(
self, hermes_home_with_secret, capsys
):
"""--no-redact preserves original log content and omits the banner."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = True
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
# The agent.log paste should now contain the raw token.
assert any(_REDACT_FIXTURE_TOKEN in c for c in captured), (
"expected raw token in --no-redact upload"
)
# No banner anywhere when redaction is disabled.
for content in captured:
assert "redacted at upload time" not in content, (
"banner present with --no-redact"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# run_debug router # run_debug router
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------