Skip to main content

cellos_host_telemetry/
lib.rs

1//! Host-side telemetry receiver for the in-VM observability path (Phase F).
2//!
3//! **Status: F3b — vsock listener + host-stamping + agent-silenced detection
4//! shipped.** The remaining F-phase work is F4b (supervisor signing of the
5//! outbound CloudEvents) and F1b (additive event constructor for
6//! `cell.observability.guest.agent_silenced`). ADR-0006 is the doctrine
7//! reference.
8//!
9//! Role: bind a per-cell UDS at `<vsock_uds_base>_9001` BEFORE the workload
10//! runs, receive CBOR-framed `cell.observability.guest.*` events from the
11//! in-guest [`cellos-telemetry`] agent, host-stamp the non-negotiable
12//! attribution fields (`cell_id`, `run_id`, `host_received_at`,
13//! `spec_signature_hash`, ADG `output`), and produce internal
14//! [`StampedDeclaration`] values the F4b signer projects to `CloudEventV1`
15//! via the [`cellos_core::events`] builders.
16//!
17//! Channel-authenticity model (ADR-0006 §5): the host trusts WHICH UDS path
18//! the bytes arrived on (Firecracker proxies the guest's vsock connection
19//! to a per-cell UDS at `<vsock_uds_base>_<port>`), not a payload signature.
20//! The guest agent must NOT hold a signing key. This crate must NEVER take
21//! a dependency on signing primitives that would let it accept guest-signed
22//! envelopes; the supervisor signs outbound, period.
23//!
24//! Module layout:
25//!
26//! - [`listener`] — per-cell UDS bind + CBOR frame decode + `content_version`
27//!   major-version gate.
28//! - [`host_stamp`] — projects [`GuestDeclaration`] + [`HostStamp`] into
29//!   the internal [`StampedDeclaration`] value type.
30//! - [`keepalive`] — `KeepAlive` tracker, `AgentSilencedTrigger` (fire-once),
31//!   and `watch_for_silence` watcher loop.
32//!
33//! See [docs/adr/0006-in-vm-observability-runner-evidence.md] for the
34//! complete decision record.
35
36#![deny(unsafe_code)]
37#![warn(missing_docs)]
38
39use std::time::SystemTime;
40
41use thiserror::Error;
42
43pub mod host_stamp;
44pub mod keepalive;
45pub mod listener;
46pub mod probes;
47pub mod sign_outbound;
48
49#[doc(inline)]
50pub use probes::{
51    build_host_probe_envelope, emit_reading, HostProbe, ProbeContext, ProbeError, ProbeReading,
52    HOST_PROBE_EVENT_SOURCE, HOST_PROBE_EVENT_TYPE_PREFIX,
53};
54
55/// Vsock port reserved for guest telemetry events.
56///
57/// The supervisor binds the per-cell UDS at `<vsock_uds_base>_9001` BEFORE
58/// the workload's first instruction so the channel-authenticity primitive
59/// holds (ADR-0006 §5). Mirrors the `_9000` exit-code UDS in
60/// `cellos-host-firecracker`.
61pub const VSOCK_TELEMETRY_PORT: u32 = 9001;
62
63/// CBOR wire-format major version. Host rejects unknown majors per
64/// ADR-0006 §12 wire-schema versioning.
65///
66/// Value layout: low byte = minor, high byte = major. The major-version
67/// check in [`listener::decode_frame`] reads `(content_version >> 8) as u8`.
68pub const WIRE_CONTENT_VERSION_MAJOR: u16 = 1;
69
70/// Errors surfaced by the telemetry receiver.
71#[derive(Debug, Error)]
72pub enum TelemetryError {
73    /// Listener could not bind the requested per-cell UDS.
74    #[error("vsock UDS bind failed: {0}")]
75    Bind(String),
76
77    /// Wire payload could not be decoded or violated framing rules.
78    #[error("malformed wire payload: {0}")]
79    Wire(String),
80
81    /// `content_version` in the CBOR header is unknown.
82    #[error("unsupported wire major version: {0}")]
83    UnsupportedVersion(u16),
84}
85
86/// Fields a guest-side agent fills. Anything else is host-stamped on
87/// receipt and overrides what the guest sent (ADR-0006 §6).
88///
89/// **Type-level non-forgeability.** The struct has no `cell_id`/`run_id`/
90/// `spec_signature_hash` fields — the wire decoder (`listener::decode_frame`)
91/// drops any such keys the guest tries to stuff into the CBOR map, and the
92/// stamping layer (`host_stamp::stamp`) reads attribution exclusively from
93/// [`HostStamp`]. This is the structural enforcement of ADR-0006 §6: a
94/// compromised guest cannot forge cross-cell attribution because the
95/// attribution fields don't exist on this type.
96#[derive(Debug, Clone)]
97pub struct GuestDeclaration {
98    /// Probe source identifier (e.g. `"process.spawned"`,
99    /// `"net.connect_attempted"`). The set of valid values is locked in
100    /// `cellos_telemetry::probe_source`.
101    pub probe_source: String,
102    /// Guest-side process id observed by the probe.
103    pub guest_pid: u32,
104    /// Guest-side process command name.
105    pub guest_comm: String,
106    /// Guest-side monotonic timestamp at probe-fire (ns).
107    pub guest_monotonic_ns: u64,
108}
109
110/// Attribution fields stamped supervisor-side on every guest declaration.
111/// Overrides whatever the guest sent — non-negotiable per ADR-0006 §6.
112#[derive(Debug, Clone)]
113pub struct HostStamp {
114    /// Per-cell identifier.
115    pub cell_id: String,
116    /// Per-run identifier.
117    pub run_id: String,
118    /// Wallclock at host receive.
119    pub host_received_at: SystemTime,
120    /// SHA-256 of the spec the cell was admitted under.
121    pub spec_signature_hash: String,
122}
123
124/// Forward-looking F3 host-probe reading shape (ADR-0006 acceptance prep,
125/// 2026-05-16). The richer `probes::HostProbe` / `probes::ProbeReading` API
126/// in this crate is the F3a implementation; this simpler envelope is the
127/// minimal contract documented for future host-side probes that emit
128/// `cellos.events.host.probe.v1` CloudEvents without needing the full
129/// `ProbeContext` / `EventSink` plumbing.
130///
131/// Implementations of `probes::HostProbe` are the canonical path today;
132/// this struct is the additive forward declaration.
133#[derive(Debug, Clone)]
134pub struct HostProbeReading {
135    /// Probe identifier (matches `probes::HostProbe::probe_name`).
136    pub probe: &'static str,
137    /// Probe output as free-form JSON.
138    pub value_json: serde_json::Value,
139    /// Wallclock at probe-fire, milliseconds since Unix epoch.
140    pub timestamp_ms: u64,
141}
142
143/// Internal value type: a guest declaration with host-stamped attribution.
144///
145/// **Internal — not a CloudEvent.** F4b owns signing and projects this to
146/// [`cellos_core::CloudEventV1`] via the existing
147/// `cellos_core::events::observability_*_data_v1` builders. This crate
148/// produces unsigned values; the supervisor signs them on the way out.
149#[derive(Debug, Clone)]
150pub struct StampedDeclaration {
151    // ── Host-stamped (overrides anything the guest sent) ──
152    /// Per-cell identifier (host-stamped).
153    pub cell_id: String,
154    /// Per-run identifier (host-stamped).
155    pub run_id: String,
156    /// Wallclock at host receive (host-stamped, per-frame).
157    pub host_received_at: SystemTime,
158    /// SHA-256 of the spec the cell was admitted under (host-stamped).
159    pub spec_signature_hash: String,
160    // ── Guest-fillable ──
161    /// Probe source identifier.
162    pub probe_source: String,
163    /// Guest-side process id observed by the probe.
164    pub guest_pid: u32,
165    /// Guest-side process command name.
166    pub guest_comm: String,
167    /// Guest-side monotonic timestamp at probe-fire (ns).
168    pub guest_monotonic_ns: u64,
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn vsock_port_is_locked() {
177        assert_eq!(VSOCK_TELEMETRY_PORT, 9001);
178    }
179
180    #[test]
181    fn wire_major_starts_at_one() {
182        assert_eq!(WIRE_CONTENT_VERSION_MAJOR, 1);
183    }
184
185    #[test]
186    fn guest_declaration_has_no_attribution_fields() {
187        // Type-level non-forgeability check: GuestDeclaration must NOT carry
188        // any of the host-stamped attribution fields. If a future commit
189        // adds `cell_id` (or similar) to GuestDeclaration, this test should
190        // be deleted only with a corresponding ADR amendment — the
191        // attribution-fields-not-in-guest-declaration property is a
192        // structural enforcement of ADR-0006 §6.
193        let g = GuestDeclaration {
194            probe_source: "process.spawned".into(),
195            guest_pid: 1,
196            guest_comm: "x".into(),
197            guest_monotonic_ns: 0,
198        };
199        // Compile-time witness: the only fields you can read are these.
200        let _ = (
201            &g.probe_source,
202            &g.guest_pid,
203            &g.guest_comm,
204            &g.guest_monotonic_ns,
205        );
206    }
207}