Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,12 @@ def _ensure_hermes_home_managed(home: Path):
"level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING
"max_size_mb": 5, # Max size per log file before rotation
"backup_count": 3, # Number of rotated backup files to keep
# Opt-in deep dashboard/TUI debugging: capture HTTP request bodies and
# PTY/WebSocket frames to a SEPARATE gui_bodies.log. OFF by default.
# Bodies may contain conversation content; this log is isolated from
# gui.log and is NEVER uploaded by `hermes debug share`. Enable only
# while reproducing an issue.
"capture_bodies": False,
},

# Remotely-hosted model catalog manifest. When enabled, the CLI fetches
Expand Down
57 changes: 57 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,13 +483,58 @@ def _new_request_id() -> str:
return secrets.token_hex(4)


# Opt-in body-capture logger (OFF unless logging.capture_bodies=true). It is
# isolated from gui.log and excluded from debug share — see hermes_logging.py.
# The handler attached to it gates output (NullHandler + level above CRITICAL
# when disabled), so calling _capture_body() while disabled is a cheap no-op.
try:
from hermes_logging import BODY_CAPTURE_LOGGER as _BODY_LOGGER_NAME
except Exception: # pragma: no cover - logging module always present in practice
_BODY_LOGGER_NAME = "hermes_body_capture"
_body_log = logging.getLogger(_BODY_LOGGER_NAME)

# Cap per-captured body so a large upload/stream can't bloat the log.
_BODY_CAPTURE_MAX = 4096


def _capture_body(kind: str, rid, data) -> None:
"""Record one request/response/frame body to the opt-in body log.

No-op unless ``logging.capture_bodies`` is enabled (the logger level/handler
gate this without a config read on the hot path). Truncates to
``_BODY_CAPTURE_MAX`` and relies on the logger's RedactingFormatter to scrub
secrets as defence-in-depth. Diagnostic-only — never raises into a request.
"""
if not _body_log.isEnabledFor(logging.INFO):
return
try:
if isinstance(data, (bytes, bytearray)):
text = bytes(data[:_BODY_CAPTURE_MAX]).decode("utf-8", "replace")
else:
text = str(data)[:_BODY_CAPTURE_MAX]
truncated = len(data) > _BODY_CAPTURE_MAX
_body_log.info(
"%s rid=%s len=%d%s body=%r",
kind, rid, len(data), " (truncated)" if truncated else "", text,
)
except Exception:
pass


@app.middleware("http")
async def access_log_middleware(request: Request, call_next):
import time as _time

rid = request.headers.get("x-request-id") or _new_request_id()
request.state.rid = rid
peer = request.client.host if request.client else "?"
# Opt-in request-body capture. request.body() is cached by Starlette, so
# reading it here does not consume the stream for downstream handlers.
if _body_log.isEnabledFor(logging.INFO):
try:
_capture_body("http.request", rid, await request.body())
except Exception:
pass
start = _time.monotonic()
status = 500
response = None
Expand Down Expand Up @@ -612,6 +657,16 @@ async def access_log_middleware(request: Request, call_next):
"description": "Log level for agent.log",
"options": ["DEBUG", "INFO", "WARNING", "ERROR"],
},
"logging.capture_bodies": {
"type": "boolean",
"description": (
"Capture HTTP request bodies and PTY/WebSocket frames to a "
"separate gui_bodies.log for deep dashboard/TUI debugging. OFF by "
"default. Bodies may contain conversation content — this log is "
"isolated from gui.log and is NEVER included in 'hermes debug "
"share'. Enable only while reproducing an issue."
),
},
"agent.service_tier": {
"type": "select",
"description": "API service tier (OpenAI/Anthropic)",
Expand Down Expand Up @@ -11201,6 +11256,7 @@ async def pump_pty_to_ws() -> None:
try:
await ws.send_bytes(chunk)
_stats["bytes_out"] += len(chunk)
_capture_body("pty.out", peer, chunk)
except Exception:
_stats["reason"] = "send_failed"
return
Expand Down Expand Up @@ -11231,6 +11287,7 @@ async def pump_pty_to_ws() -> None:

bridge.write(raw)
_stats["bytes_in"] += len(raw)
_capture_body("pty.in", peer, raw)
except WebSocketDisconnect:
pass
except Exception:
Expand Down
56 changes: 56 additions & 0 deletions hermes_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ def filter(self, record: logging.LogRecord) -> bool:
),
}

# Dedicated logger for opt-in HTTP/WS body capture. Intentionally NOT a member
# of any COMPONENT_PREFIXES above and configured with propagate=False, so its
# records reach only the isolated gui_bodies.log handler — never gui.log,
# agent.log, or anything the diagnostic surfaces (debug share) read.
BODY_CAPTURE_LOGGER = "hermes_body_capture"


# ---------------------------------------------------------------------------
# Main setup
Expand Down Expand Up @@ -333,6 +339,35 @@ def setup_logging(
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gui"]),
)

# --- gui_bodies.log (opt-in HTTP/WS body capture) ------------------
# OFF by default; enabled only when config.yaml ``logging.capture_bodies``
# is true. This is the heavy diagnostic tier — request/response bodies
# and WS frames — so it is DELIBERATELY ISOLATED:
# * a dedicated logger (BODY_CAPTURE_LOGGER, propagate=False) that is
# NOT under any COMPONENT_PREFIXES, so bodies never leak into
# gui.log / agent.log;
# * a dedicated file that is NOT registered in hermes_cli/logs.py
# LOG_FILES and NOT in the debug-share snapshot set, so it can
# never be auto-uploaded by ``hermes debug share`` (see #22016).
# Still redacted via RedactingFormatter as defence-in-depth.
body_logger = logging.getLogger(BODY_CAPTURE_LOGGER)
body_logger.handlers.clear()
body_logger.propagate = False
if _read_capture_bodies_config():
body_logger.setLevel(logging.INFO)
_add_rotating_handler(
body_logger,
log_dir / "gui_bodies.log",
level=logging.INFO,
max_bytes=10 * 1024 * 1024,
backup_count=3,
formatter=RedactingFormatter(_LOG_FORMAT),
)
else:
# Disabled: swallow any emit so a stray call is a cheap no-op.
body_logger.setLevel(logging.CRITICAL + 1)
body_logger.addHandler(logging.NullHandler())

if _logging_initialized and not force:
return log_dir

Expand Down Expand Up @@ -563,3 +598,24 @@ def _read_logging_config():
except Exception:
pass
return (None, None, None)


def _read_capture_bodies_config() -> bool:
"""Best-effort read of ``logging.capture_bodies`` from config.yaml.

Defaults to ``False`` — body capture is opt-in. This is the gate that keeps
the heavy diagnostic tier off unless an operator explicitly enables it
(``hermes config set logging.capture_bodies true`` or the dashboard toggle).
"""
try:
import yaml
config_path = get_config_path()
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
log_cfg = cfg.get("logging", {})
if isinstance(log_cfg, dict):
return bool(log_cfg.get("capture_bodies", False))
except Exception:
pass
return False
92 changes: 92 additions & 0 deletions tests/test_hermes_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,3 +1075,95 @@ def flush(self):
logger.info("Session hygiene: 400 messages — auto-compressing")
finally:
logger.removeHandler(handler)


class TestBodyCaptureOptIn:
"""Opt-in HTTP/WS body capture (logging.capture_bodies).

The heavy diagnostic tier: OFF by default, isolated from gui.log, and
structurally excluded from `hermes debug share` (#22016).
"""

def _home(self, tmp_path, monkeypatch, capture):
import yaml
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
yaml.safe_dump({"logging": {"capture_bodies": capture}})
)
monkeypatch.setenv("HERMES_HOME", str(home))
return home

def test_disabled_by_default_no_body_file_and_no_capture(self, tmp_path, monkeypatch):
import logging
import hermes_logging
from hermes_logging import BODY_CAPTURE_LOGGER
import hermes_cli.web_server as ws

home = self._home(tmp_path, monkeypatch, False)
hermes_logging.setup_logging(hermes_home=home, mode="gui", force=True)

ws._capture_body("http.request", "rid", b"secret-conversation-content")
for h in logging.getLogger(BODY_CAPTURE_LOGGER).handlers:
h.flush()

assert not (home / "logs" / "gui_bodies.log").exists()

def test_enabled_captures_to_isolated_file_not_gui_log(self, tmp_path, monkeypatch):
import logging
import hermes_logging
from hermes_logging import BODY_CAPTURE_LOGGER
import hermes_cli.web_server as ws

home = self._home(tmp_path, monkeypatch, True)
hermes_logging.setup_logging(hermes_home=home, mode="gui", force=True)

ws._capture_body("http.request", "rid", b"captured-body-payload")
for h in logging.getLogger(BODY_CAPTURE_LOGGER).handlers:
h.flush()

body = (home / "logs" / "gui_bodies.log")
assert body.exists()
assert "captured-body-payload" in body.read_text()

# Must never leak into the shared gui.log.
gui = home / "logs" / "gui.log"
gui_text = gui.read_text() if gui.exists() else ""
assert "captured-body-payload" not in gui_text

def test_truncates_large_body(self, tmp_path, monkeypatch):
import logging
import hermes_logging
from hermes_logging import BODY_CAPTURE_LOGGER
import hermes_cli.web_server as ws

home = self._home(tmp_path, monkeypatch, True)
hermes_logging.setup_logging(hermes_home=home, mode="gui", force=True)

ws._capture_body("http.request", "rid", b"A" * (ws._BODY_CAPTURE_MAX * 3))
for h in logging.getLogger(BODY_CAPTURE_LOGGER).handlers:
h.flush()

text = (home / "logs" / "gui_bodies.log").read_text()
assert "(truncated)" in text
# Far fewer than the 3x-cap input bytes are written.
assert text.count("A") <= ws._BODY_CAPTURE_MAX + 10

def test_body_logger_is_isolated_from_all_components(self):
from hermes_logging import BODY_CAPTURE_LOGGER, COMPONENT_PREFIXES

for comp, prefixes in COMPONENT_PREFIXES.items():
assert not BODY_CAPTURE_LOGGER.startswith(tuple(prefixes)), \
f"body logger must not be a member of component {comp!r}"

def test_body_file_excluded_from_log_registry_and_debug_share(self):
# Structural #22016 guarantee: the body file is neither tailable via
# `hermes logs` nor uploadable via `hermes debug share`.
from hermes_cli.logs import LOG_FILES
from hermes_cli.debug import _capture_default_log_snapshots

assert "gui_bodies" not in LOG_FILES
assert not any("bodies" in fname for fname in LOG_FILES.values())

snaps = _capture_default_log_snapshots(50)
assert "gui_bodies" not in snaps
1 change: 1 addition & 0 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@ View, tail, and filter Hermes log files. All logs are stored in `~/.hermes/logs/
| `errors` | `errors.log` | Warnings and errors only — a filtered subset of agent.log |
| `gateway` | `gateway.log` | Messaging gateway activity — platform connections, message dispatch, webhook events |
| `gui` | `gui.log` | Dashboard / TUI-gateway / PTY-bridge / websocket events — HTTP access log (method, path, status, latency, request id) and WebSocket lifecycle (accept, reject reason, close reason, duration, byte/message counters) |
| _(opt-in)_ | `gui_bodies.log` | HTTP request bodies + PTY/WebSocket frames, captured only when `logging.capture_bodies: true` (off by default). Isolated from `gui.log` and **never** included in `hermes debug share`, since bodies can contain conversation content. Not tailable via `hermes logs`; read the file directly while reproducing an issue. |
| `desktop` | `desktop.log` | Electron desktop app — boot, backend spawn output, and recent Python tracebacks |

### Options
Expand Down