mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Add a built-in telemetry system that records what the agent does — workflows,
model calls, tool calls, errors — to the local machine, powers `/insights`, and
can export to an operator-chosen destination. Default-on locally; nothing leaves
the machine unless the user exports it or opts into the aggregate plane.
Three planes with a hard wall between them:
- local: full-fidelity observability (real model/provider/tool names), on by
default, never leaves the machine.
- aggregate: opt-in metadata, default off. No uploader ships — consent is
recorded via telemetry.consent_state, and `preview` shows what would be
produced, computed locally.
- trajectories: full message content, opt-in, exported only to the operator's
own destination.
Mechanism:
- Bundled `telemetry` plugin registers observational lifecycle hooks
(on_session_start / post_api_request / post_tool_call / on_session_finalize).
No core call sites are edited; hooks already carry the data.
- Fire-and-forget emitter: emit() returns in microseconds, never blocks or
raises into a model/tool call. A daemon thread writes events to an
append-only JSONL log and the tel_* tables in state.db (its own sqlite
connection, separate from SessionDB).
- tel_runs / tel_model_calls / tel_tool_calls live in the declarative
SCHEMA_SQL and are reconciled automatically; SCHEMA_VERSION 16 -> 17.
- metrics derives rollups for /usage and /insights; rollup builds per-run
summaries for `hermes telemetry preview`.
Consent is config, not a parallel command surface. The config file is the root
of trust: set telemetry.consent_state with `hermes config set`, or pin any
telemetry.* key (including allow_aggregate) via managed scope, which overrides
the user's value per key. `hermes telemetry` exposes only what config cannot:
status (report), preview (query), and export.
Export:
- exporter_bulk writes telemetry (and, when the trajectories plane is enabled,
session content) to ndjson/json.
- otlp_exporter streams spans to a configured OpenTelemetry Collector over
OTLP/HTTP. The SDK is an optional extra (hermes-agent[otlp]), lazily
installed via tools.lazy_deps on first use.
- Secrets are always redacted on every export path
(redact_sensitive_text(force=True)); content export is gated by the
trajectories plane, and PII scrubbing follows telemetry.content_redaction.
OTLP auth headers reference environment variable names, never inline values.
No outbound emission to Nous. The aggregate uploader is intentionally not built.
113 lines
4.8 KiB
Python
113 lines
4.8 KiB
Python
"""Export redaction pipeline tests — the security-critical layer.
|
|
|
|
Invariants:
|
|
* Secrets ALWAYS stripped, every mode, no flag disables it.
|
|
* Content gated by the trajectories plane, not a redaction mode.
|
|
* PII stripped in 'pii' mode; structure preserved (codec-aware).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from agent.telemetry import redaction as R
|
|
|
|
|
|
# ── secrets are always redacted ─────────────────────────────────────────────
|
|
def test_secrets_stripped_in_none_mode():
|
|
text = "here is sk-ant-api03-SECRETKEY123 and a token"
|
|
out = R.redact_for_export(text, content_mode=R.CONTENT_NONE)
|
|
assert "SECRETKEY123" not in out
|
|
|
|
|
|
def test_secrets_stripped_in_pii_mode():
|
|
text = "Authorization: Bearer abcdef123456789secret"
|
|
out = R.redact_for_export(text, content_mode=R.CONTENT_PII)
|
|
assert "abcdef123456789secret" not in out
|
|
|
|
|
|
def test_secret_redactor_fails_closed(monkeypatch):
|
|
# If the underlying redactor raises, we must NOT return the raw string.
|
|
import agent.redact as ar
|
|
monkeypatch.setattr(ar, "redact_sensitive_text", lambda *a, **k: (_ for _ in ()).throw(RuntimeError()))
|
|
out = R.redact_for_export("sk-secret-value", content_mode=R.CONTENT_NONE)
|
|
assert "sk-secret-value" not in out
|
|
assert out == "[redaction-unavailable]"
|
|
|
|
|
|
# ── PII ─────────────────────────────────────────────────────────────────────
|
|
def test_pii_mode_strips_email_and_phone():
|
|
text = "contact alice@example.com or +1 415 555 1234"
|
|
out = R.redact_for_export(text, content_mode=R.CONTENT_PII)
|
|
assert "alice@example.com" not in out
|
|
assert "[email]" in out
|
|
assert "555" not in out or "[phone]" in out
|
|
|
|
|
|
def test_none_mode_keeps_nonsecret_text_but_drops_via_message_path():
|
|
# redact_for_export(none) scrubs secrets but doesn't strip ordinary words;
|
|
# content *dropping* happens at the message layer (trajectories gate).
|
|
out = R.redact_for_export("just ordinary words", content_mode=R.CONTENT_NONE)
|
|
assert "ordinary" in out
|
|
|
|
|
|
# ── trajectories gate (content_export_enabled) ──────────────────────────────
|
|
def test_content_export_disabled_by_default():
|
|
assert R.content_export_enabled({}) is False
|
|
assert R.content_export_enabled({"telemetry": {}}) is False
|
|
assert R.content_export_enabled({"telemetry": {"trajectories": {"enabled": False}}}) is False
|
|
|
|
|
|
def test_content_export_enabled_when_trajectories_on():
|
|
assert R.content_export_enabled({"telemetry": {"trajectories": {"enabled": True}}}) is True
|
|
|
|
|
|
# ── codec-aware message redaction ───────────────────────────────────────────
|
|
def test_message_structural_only_when_content_excluded():
|
|
msg = {"role": "user", "content": "my email is bob@x.com and key sk-12345"}
|
|
out = R.redact_message(msg, include_content=False)
|
|
assert out["role"] == "user"
|
|
assert "content" not in out # body dropped entirely
|
|
assert out["content_chars"] == len(msg["content"]) # only the size remains
|
|
assert "bob@x.com" not in json.dumps(out)
|
|
|
|
|
|
def test_message_content_included_is_redacted():
|
|
msg = {"role": "user", "content": "email bob@x.com secret sk-ant-SECRET999"}
|
|
out = R.redact_message(msg, content_mode=R.CONTENT_PII, include_content=True)
|
|
assert "content" in out
|
|
assert "SECRET999" not in out["content"] # secret gone
|
|
assert "bob@x.com" not in out["content"] # pii gone
|
|
assert "[email]" in out["content"]
|
|
|
|
|
|
def test_tool_calls_redacted_names_kept_args_scrubbed():
|
|
msg = {
|
|
"role": "assistant",
|
|
"tool_calls": json.dumps([
|
|
{"function": {"name": "web_search", "arguments": '{"q": "email me at z@z.com"}'}}
|
|
]),
|
|
}
|
|
out = R.redact_message(msg, content_mode=R.CONTENT_PII, include_content=True)
|
|
tc = out["tool_calls"]
|
|
assert tc[0]["name"] == "web_search" # structure/name preserved
|
|
assert "z@z.com" not in json.dumps(tc) # arg pii scrubbed
|
|
|
|
|
|
def test_tool_calls_counted_when_content_excluded():
|
|
msg = {
|
|
"role": "assistant",
|
|
"tool_calls": json.dumps([
|
|
{"function": {"name": "a", "arguments": "{}"}},
|
|
{"function": {"name": "b", "arguments": "{}"}},
|
|
]),
|
|
}
|
|
out = R.redact_message(msg, include_content=False)
|
|
assert out["tool_call_count"] == 2
|
|
assert "tool_calls" not in out
|
|
|
|
|
|
def test_content_mode_for_reads_config():
|
|
assert R.content_mode_for({"telemetry": {"content_redaction": "pii"}}) == "pii"
|
|
assert R.content_mode_for({"telemetry": {"content_redaction": "bogus"}}) == "none"
|
|
assert R.content_mode_for({}) == "none"
|