mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
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:
+82
-7
@@ -1,12 +1,19 @@
|
||||
"""``hermes debug`` — debug tools for Hermes Agent.
|
||||
"""``hermes debug`` debug tools for Hermes Agent.
|
||||
|
||||
Currently supports:
|
||||
hermes debug share Upload debug report (system info + logs) to a
|
||||
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 json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -19,6 +26,16 @@ from typing import Optional
|
||||
from hermes_constants import get_hermes_home
|
||||
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.
|
||||
@@ -368,17 +385,40 @@ def _resolve_log_path(log_name: str) -> Optional[Path]:
|
||||
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(
|
||||
log_name: str,
|
||||
*,
|
||||
tail_lines: int,
|
||||
max_bytes: int = _MAX_LOG_BYTES,
|
||||
redact: bool = True,
|
||||
) -> LogSnapshot:
|
||||
"""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
|
||||
snapshot. Otherwise a rotation/truncate between reads can make the report
|
||||
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)
|
||||
if log_path is None:
|
||||
@@ -438,18 +478,34 @@ def _capture_log_snapshot(
|
||||
if truncated:
|
||||
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)
|
||||
except Exception as exc:
|
||||
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]:
|
||||
"""Capture all logs used by debug-share exactly once."""
|
||||
def _capture_default_log_snapshots(
|
||||
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)
|
||||
return {
|
||||
"agent": _capture_log_snapshot("agent", tail_lines=log_lines),
|
||||
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines),
|
||||
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines),
|
||||
"agent": _capture_log_snapshot(
|
||||
"agent", tail_lines=log_lines, redact=redact
|
||||
),
|
||||
"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)
|
||||
expiry = getattr(args, "expire", 7)
|
||||
local_only = getattr(args, "local", False)
|
||||
redact = not getattr(args, "no_redact", False)
|
||||
|
||||
if not local_only:
|
||||
print(_PRIVACY_NOTICE)
|
||||
@@ -539,8 +596,16 @@ def run_debug_share(args):
|
||||
print("Collecting debug report...")
|
||||
|
||||
# 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()
|
||||
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(
|
||||
log_lines=log_lines,
|
||||
@@ -556,6 +621,15 @@ def run_debug_share(args):
|
||||
if 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:
|
||||
print(report)
|
||||
if agent_log:
|
||||
@@ -666,6 +740,7 @@ def run_debug(args):
|
||||
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(" --no-redact Disable upload-time secret redaction (default: redact)")
|
||||
print()
|
||||
print("Options (delete):")
|
||||
print(" <url> ... One or more paste URLs to delete")
|
||||
|
||||
@@ -8891,6 +8891,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 share --no-redact Disable upload-time secret redaction
|
||||
hermes debug delete <url> Delete a previously uploaded paste
|
||||
""",
|
||||
)
|
||||
@@ -8916,6 +8917,16 @@ Examples:
|
||||
action="store_true",
|
||||
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",
|
||||
help="Delete a paste uploaded by 'hermes debug share'",
|
||||
|
||||
@@ -679,6 +679,8 @@ AUTHOR_MAP = {
|
||||
"ztzheng@163.com": "chengoak", # PR #17467
|
||||
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only
|
||||
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
|
||||
# Debug share upload-time redaction (May 2026)
|
||||
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -273,6 +273,101 @@ class TestCaptureLogSnapshot:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -556,6 +651,124 @@ class TestRunDebugShare:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user