diff --git a/agent/telemetry/__init__.py b/agent/telemetry/__init__.py index 4c79dd329c0..d824d5e6f4b 100644 --- a/agent/telemetry/__init__.py +++ b/agent/telemetry/__init__.py @@ -8,8 +8,8 @@ raises into a model/tool call (the hot-path invariant). Events record the observed model ids, provider names, and tool names. ``metrics`` derives rollups for /usage and /insights; ``rollup`` builds the per-run summaries shown by ``hermes telemetry preview``. ``redaction`` + ``exporter_bulk`` + ``otlp_exporter`` -handle export to an operator-chosen destination. ``policy`` holds the consent state -machine for the opt-in aggregate plane (no uploader ships). +handle export to an operator-chosen destination. ``policy`` holds the consent +constants and the aggregate upload gate (no uploader ships). """ from __future__ import annotations diff --git a/agent/telemetry/policy.py b/agent/telemetry/policy.py index f1c001b4c9a..6090857eab5 100644 --- a/agent/telemetry/policy.py +++ b/agent/telemetry/policy.py @@ -1,49 +1,32 @@ """Telemetry consent posture and the aggregate-plane gate. -Consent is a single field, ``telemetry.consent_state``: +Consent is a single config field, ``telemetry.consent_state``: * "unknown" — no choice recorded; never uploads (the default). * "local" — declined the aggregate plane; local plane only. * "aggregate" — opted in to the aggregate plane. The config file is the source of truth: set ``telemetry.consent_state`` with -``hermes config set`` (or a managed-scope pin). There is no separate boolean mirror — -a single field cannot drift out of sync with itself, so a stray value can't -accidentally imply consent. +``hermes config set`` (or a managed-scope pin). Callers that gate behavior read +``telemetry.*`` directly from config; this module only provides the consent +constants, the install-id helper, and the upload gate a future uploader must +consult. ``allow_aggregate`` is the hard gate. An administrator pins ``telemetry.allow_aggregate: false`` through the managed-scope layer -(``/etc/hermes/config.yaml``), which takes precedence over the user's config; when it -is false, the aggregate plane is off regardless of ``consent_state``. - -This module makes the decisions; it performs no I/O and contains no uploader. A future -uploader must call :func:`may_upload_aggregate` at its boundary. +(``/etc/hermes/config.yaml``), which takes precedence over the user's config; when +it is false, the aggregate plane is off regardless of ``consent_state``. """ from __future__ import annotations import uuid -from dataclasses import dataclass from typing import Any, Dict CONSENT_UNKNOWN = "unknown" CONSENT_LOCAL = "local" CONSENT_AGGREGATE = "aggregate" -_VALID_STATES = {CONSENT_UNKNOWN, CONSENT_LOCAL, CONSENT_AGGREGATE} - - -@dataclass(slots=True) -class TelemetryDecision: - """The resolved telemetry posture for the current process.""" - local_enabled: bool - aggregate_enabled: bool - consent_state: str - install_id: str - allow_aggregate: bool - - def may_upload_aggregate(self) -> bool: - """The single gate the uploader must consult before any network send.""" - return self.allow_aggregate and self.consent_state == CONSENT_AGGREGATE +VALID_CONSENT_STATES = {CONSENT_UNKNOWN, CONSENT_LOCAL, CONSENT_AGGREGATE} def _telemetry_cfg(config: Dict[str, Any]) -> Dict[str, Any]: @@ -65,43 +48,23 @@ def ensure_install_id(config: Dict[str, Any]) -> str: return str(uuid.uuid4()) -def resolve(config: Dict[str, Any]) -> TelemetryDecision: - """Resolve the effective telemetry posture from config. +def may_upload_aggregate(config: Dict[str, Any]) -> bool: + """Whether the aggregate plane may upload — the gate a future uploader consults. - ``consent_state`` is the single source of truth for the aggregate opt-in. - ``allow_aggregate`` (admin-pinnable via managed scope) hard-disables the aggregate - plane regardless of consent. + True only when the admin hard gate allows it AND the user has opted in via + ``telemetry.consent_state``. """ tel = _telemetry_cfg(config) - - local_enabled = bool(tel.get("local", True)) allow_aggregate = bool(tel.get("allow_aggregate", True)) state = tel.get("consent_state", CONSENT_UNKNOWN) - if state not in _VALID_STATES: - state = CONSENT_UNKNOWN - - aggregate_enabled = allow_aggregate and state == CONSENT_AGGREGATE - - return TelemetryDecision( - local_enabled=local_enabled, - aggregate_enabled=aggregate_enabled, - consent_state=state, - install_id=ensure_install_id(config), - allow_aggregate=allow_aggregate, - ) - - -def may_upload_aggregate(config: Dict[str, Any]) -> bool: - """Convenience gate for the uploader boundary.""" - return resolve(config).may_upload_aggregate() + return allow_aggregate and state == CONSENT_AGGREGATE __all__ = [ "CONSENT_UNKNOWN", "CONSENT_LOCAL", "CONSENT_AGGREGATE", - "TelemetryDecision", - "resolve", + "VALID_CONSENT_STATES", "may_upload_aggregate", "ensure_install_id", ] diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 46017aaa13f..41f6565caa0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -14322,25 +14322,32 @@ def cmd_telemetry(args): action = getattr(args, "telemetry_action", None) or "status" config = load_config() - decision = policy.resolve(config) + tel = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {} + local_enabled = bool(tel.get("local", True)) + allow_aggregate = bool(tel.get("allow_aggregate", True)) + consent_state = tel.get("consent_state", policy.CONSENT_UNKNOWN) + if consent_state not in policy.VALID_CONSENT_STATES: + consent_state = policy.CONSENT_UNKNOWN + aggregate_enabled = allow_aggregate and consent_state == policy.CONSENT_AGGREGATE + install_id = policy.ensure_install_id(config) def _persist_install_id(): # Make sure a minted id is written back so it stays stable. - config.setdefault("telemetry", {})["install_id"] = decision.install_id + config.setdefault("telemetry", {})["install_id"] = install_id save_config(config) if action == "status": print("Telemetry status") print("─" * 56) - print(f" Local plane: {'on' if decision.local_enabled else 'off'} " + print(f" Local plane: {'on' if local_enabled else 'off'} " f"(telemetry.local)") - print(f" Aggregate plane: {'on' if decision.aggregate_enabled else 'off'} " - f"(opt-in; consent_state={decision.consent_state})") - if decision.consent_state != policy.CONSENT_AGGREGATE and decision.allow_aggregate: + print(f" Aggregate plane: {'on' if aggregate_enabled else 'off'} " + f"(opt-in; consent_state={consent_state})") + if consent_state != policy.CONSENT_AGGREGATE and allow_aggregate: print(" opt in: hermes config set telemetry.consent_state aggregate") - if not decision.allow_aggregate: + if not allow_aggregate: print(" ⚠ allow_aggregate is false (egress hard-disabled)") - print(f" Install id: {decision.install_id}") + print(f" Install id: {install_id}") print(" Upload: DISABLED — no server yet. Aggregate is computed " "locally only.") print() @@ -14393,7 +14400,7 @@ def cmd_telemetry(args): _persist_install_id() since_ns = int((time.time() - args.days * 86400) * 1e9) events = rollup.build_aggregate_events( - install_id=decision.install_id, since_ns=since_ns + install_id=install_id, since_ns=since_ns ) summary = rollup.summarize(events) print("Telemetry preview — computed locally, NOT uploaded") diff --git a/tests/telemetry/test_governance.py b/tests/telemetry/test_governance.py index 37bf1810d8d..4c94cab5df9 100644 --- a/tests/telemetry/test_governance.py +++ b/tests/telemetry/test_governance.py @@ -83,7 +83,7 @@ def test_allow_aggregate_pin_blocks_opt_in(home): """A managed allow_aggregate:false pin overrides a consent_state opt-in. Consent is set in config (as a user or managed-scope pin would); the hard gate - still wins, so the aggregate plane resolves off and may_upload stays false. + still wins, so may_upload stays false. """ from hermes_cli.config import load_config, save_config from agent.telemetry import policy @@ -92,7 +92,4 @@ def test_allow_aggregate_pin_blocks_opt_in(home): tel["consent_state"] = "aggregate" tel["allow_aggregate"] = False save_config(c) - d = policy.resolve(load_config()) - assert d.allow_aggregate is False - assert d.aggregate_enabled is False - assert d.may_upload_aggregate() is False + assert policy.may_upload_aggregate(load_config()) is False diff --git a/tests/telemetry/test_policy_consent.py b/tests/telemetry/test_policy_consent.py index f3ace454770..7bc0d68e2fe 100644 --- a/tests/telemetry/test_policy_consent.py +++ b/tests/telemetry/test_policy_consent.py @@ -1,8 +1,9 @@ -"""Consent posture + org-policy enforcement tests. +"""Consent gate tests. -Consent is a single field (``telemetry.consent_state``); the aggregate opt-in is -expressed by setting it to ``"aggregate"`` (via ``hermes config set`` or a managed-scope -pin). ``allow_aggregate`` is the hard gate. +Consent is a single config field (``telemetry.consent_state``); the aggregate opt-in +is expressed by setting it to ``"aggregate"`` (via ``hermes config set`` or a +managed-scope pin). ``allow_aggregate`` is the hard gate. ``policy.may_upload_aggregate`` +is the gate a future uploader must consult. """ from __future__ import annotations @@ -14,43 +15,27 @@ def _cfg(**telemetry): return {"telemetry": telemetry} -def test_default_posture_is_local_only(): - d = policy.resolve(_cfg(local=True, consent_state="unknown")) - assert d.local_enabled is True - assert d.aggregate_enabled is False - assert d.may_upload_aggregate() is False +def test_default_posture_never_uploads(): + # No consent recorded → unknown → never uploads. + assert policy.may_upload_aggregate(_cfg(local=True, consent_state="unknown")) is False -def test_unknown_consent_never_uploads(): - # A headless box with no choice recorded: stays unknown, never uploads. - d = policy.resolve(_cfg(local=True, consent_state="unknown")) - assert d.may_upload_aggregate() is False +def test_missing_telemetry_block_never_uploads(): + assert policy.may_upload_aggregate({}) is False def test_opted_in_uploads(): - d = policy.resolve(_cfg(local=True, consent_state="aggregate")) - assert d.aggregate_enabled is True - assert d.may_upload_aggregate() is True + assert policy.may_upload_aggregate(_cfg(consent_state="aggregate")) is True def test_declined_does_not_upload(): - d = policy.resolve(_cfg(local=True, consent_state="local")) - assert d.may_upload_aggregate() is False + assert policy.may_upload_aggregate(_cfg(consent_state="local")) is False def test_allow_aggregate_false_overrides_opt_in(): # An admin pins telemetry.allow_aggregate: false via managed scope. - cfg = _cfg(local=True, consent_state="aggregate", allow_aggregate=False) - d = policy.resolve(cfg) - assert d.allow_aggregate is False - assert d.aggregate_enabled is False - assert d.may_upload_aggregate() is False # the hard gate wins - - -def test_invalid_consent_state_treated_as_unknown(): - d = policy.resolve(_cfg(local=True, consent_state="bogus")) - assert d.consent_state == "unknown" - assert d.may_upload_aggregate() is False + cfg = _cfg(consent_state="aggregate", allow_aggregate=False) + assert policy.may_upload_aggregate(cfg) is False # the hard gate wins def test_install_id_minted_when_empty_and_stable_when_set():