hermes-agent/tests/telemetry/test_export_redaction.py
emozilla 3e28eaccde feat(telemetry): local-first telemetry & observability
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.
2026-07-24 18:54:45 +00:00

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"