Skip to main content

codewhale_telemetry/
decision.rs

1//! The one place that decides whether anything may be collected, and the token
2//! that makes that decision unforgeable.
3//!
4//! Every emitting surface calls [`decide`] and then, if and only if it gets
5//! [`TelemetryDecision::Enabled`], hands the contained [`TelemetryConsent`] to
6//! [`crate::init`]. `TelemetryConsent` has no `Default`, no public constructor,
7//! and cannot be built from a `bool`; `init` takes it **by value**. That is what
8//! makes consent enforceable by the type system rather than by six init sites
9//! each remembering to re-check the same five-part predicate.
10
11use std::path::{Path, PathBuf};
12
13use codewhale_config::{ResolvedRuntimeOptions, SetupState, TELEMETRY_NOTICE_VERSION};
14
15use crate::buffer;
16use crate::event::Surface;
17
18/// Directory name under `$CODEWHALE_HOME` that holds every telemetry file.
19pub const TELEMETRY_DIR: &str = "telemetry";
20
21/// The outcome of the emit predicate.
22///
23/// The split between [`Self::OptedOut`] and [`Self::ForcedOff`] is load-bearing,
24/// not cosmetic. "Telemetry resolved to false" is the *default* state of every
25/// installation, so a wipe keyed on it would delete a consenting user's identity
26/// and unflushed buffer every time they ran one `codewhale exec` with a
27/// transient `CODEWHALE_TELEMETRY=0` — the recipe the runtime docs themselves
28/// prescribe.
29#[derive(Debug)]
30pub enum TelemetryDecision {
31    /// The user answered the notice and said yes, and nothing forces off.
32    Enabled(TelemetryConsent),
33    /// A human said no — `--telemetry false`, `CODEWHALE_TELEMETRY=0`,
34    /// `telemetry = false`, or declining the notice. **The only variant that
35    /// touches disk**: it wipes and leaves a tombstone.
36    OptedOut,
37    /// Off for a reason that is not the user's answer: no notice decision
38    /// recorded, an unparseable env value, an unresolvable home, a rejected
39    /// endpoint, or a bumped notice version. Touches nothing, ever. Leaves
40    /// identity and buffer exactly as they were.
41    ForcedOff,
42}
43
44impl TelemetryDecision {
45    /// Whether this decision permits emission.
46    #[must_use]
47    pub fn is_enabled(&self) -> bool {
48        matches!(self, Self::Enabled(_))
49    }
50
51    /// A stable label for logs and tests.
52    #[must_use]
53    pub fn label(&self) -> &'static str {
54        match self {
55            Self::Enabled(_) => "enabled",
56            Self::OptedOut => "opted_out",
57            Self::ForcedOff => "forced_off",
58        }
59    }
60}
61
62/// Proof that a specific machine, at a specific moment, was permitted to
63/// collect.
64///
65/// Constructed only by [`decide`]. Not `Default`, not constructible from a
66/// `bool`, and consumed by value.
67#[derive(Debug)]
68pub struct TelemetryConsent {
69    root: PathBuf,
70    endpoint: Option<String>,
71    surface: Surface,
72    config_path: Option<PathBuf>,
73}
74
75impl TelemetryConsent {
76    /// Remember which config file this process was launched with, so the flush
77    /// path can re-resolve from it.
78    ///
79    /// Without this the documented mid-session opt-out —
80    /// `codewhale config set telemetry false`, an external write by another
81    /// process — would never be observed by a session that is already running.
82    #[must_use]
83    pub fn with_config_path(mut self, config_path: Option<PathBuf>) -> Self {
84        self.config_path = config_path;
85        self
86    }
87
88    /// The config file this process was launched with, if any.
89    #[must_use]
90    pub fn config_path(&self) -> Option<&Path> {
91        self.config_path.as_deref()
92    }
93
94    /// `$CODEWHALE_HOME/telemetry`.
95    #[must_use]
96    pub fn root(&self) -> &Path {
97        &self.root
98    }
99
100    /// The validated endpoint, or `None` for the dry-run sink.
101    #[must_use]
102    pub fn endpoint(&self) -> Option<&str> {
103        self.endpoint.as_deref()
104    }
105
106    /// The surface this consent was resolved for.
107    #[must_use]
108    pub fn surface(&self) -> Surface {
109        self.surface
110    }
111}
112
113/// Why an endpoint was refused.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum EndpointError {
116    /// Not a URL we could parse at all.
117    Unparseable,
118    /// `http://` to something that is not loopback.
119    InsecureScheme,
120    /// A scheme that is neither `http` nor `https`.
121    UnsupportedScheme,
122}
123
124impl EndpointError {
125    /// A stable label for the single `warn` line.
126    #[must_use]
127    pub fn label(self) -> &'static str {
128        match self {
129            Self::Unparseable => "unparseable",
130            Self::InsecureScheme => "plaintext http to a non-loopback host",
131            Self::UnsupportedScheme => "scheme is neither https nor http",
132        }
133    }
134}
135
136/// Validate a configured endpoint.
137///
138/// `https://` is required. Plaintext is permitted **only** for loopback hosts,
139/// where a batch never reaches a wire — that is the staging and dogfood case.
140///
141/// There is deliberately **no environment variable that overrides this**.
142/// `CODEWHALE_ALLOW_INSECURE_HTTP` is not consulted: it authorizes an insecure
143/// *provider* base URL, for harnesses that legitimately intercept model traffic,
144/// and reusing it would let that interception decision also authorize plaintext
145/// telemetry POSTs to an arbitrary host. Two unrelated trust decisions must not
146/// share one switch, least of all in the subsystem whose whole promise is that
147/// the user knows what leaves the machine.
148pub fn validate_endpoint(raw: &str) -> Result<String, EndpointError> {
149    let trimmed = raw.trim();
150    let url = reqwest::Url::parse(trimmed).map_err(|_| EndpointError::Unparseable)?;
151    match url.scheme() {
152        "https" => Ok(trimmed.to_string()),
153        "http" => {
154            if is_loopback_host(url.host_str()) {
155                Ok(trimmed.to_string())
156            } else {
157                Err(EndpointError::InsecureScheme)
158            }
159        }
160        _ => Err(EndpointError::UnsupportedScheme),
161    }
162}
163
164/// Whether a host is one a packet can never leave the machine to reach.
165///
166/// `Url::host_str` returns an IPv6 literal in its bracketed form (`[::1]`), so
167/// the brackets come off before the address is parsed. Anything that parses as
168/// an IP is judged by `is_loopback` — 127.0.0.0/8 and `::1` — and the only
169/// accepted name is `localhost`.
170fn is_loopback_host(host: Option<&str>) -> bool {
171    let Some(host) = host else {
172        return false;
173    };
174    let bare = host.trim_start_matches('[').trim_end_matches(']');
175    match bare.parse::<std::net::IpAddr>() {
176        Ok(address) => address.is_loopback(),
177        Err(_) => bare.eq_ignore_ascii_case("localhost"),
178    }
179}
180
181/// Resolve the emit predicate, reading the Codewhale home from the environment.
182///
183/// See [`decide_in_home`] for the injectable form used by tests.
184pub fn decide(
185    resolved: &ResolvedRuntimeOptions,
186    setup: &SetupState,
187    surface: Surface,
188) -> TelemetryDecision {
189    // `codewhale_home()` returns `Ok(None)` when no home can be resolved, and
190    // an error when an explicit override was unusable. Both are "we have
191    // nowhere to keep state", which is `ForcedOff`, never a wipe.
192    let home = codewhale_paths::codewhale_home().ok().flatten();
193    decide_in_home(home.as_deref(), resolved, setup, surface)
194}
195
196/// Resolve the emit predicate against an explicit Codewhale home.
197///
198/// The predicate, in order:
199///
200/// 1. Telemetry resolved to `false` **and** a human said so → `OptedOut`;
201///    resolved `false` from the unset default → `ForcedOff`.
202/// 2. Notice decision recorded and declined → `OptedOut`.
203/// 3. No notice decision for the current notice version → `ForcedOff`. **A
204///    pre-existing `telemetry = true` is not consent**: the key has been
205///    settable and inert for a long time, so anyone who set it set a no-op. The
206///    notice record is an independent AND condition, never inferred from the
207///    bool.
208/// 4. No resolvable home → `ForcedOff`.
209/// 5. Endpoint configured but refused by [`validate_endpoint`] → `ForcedOff`.
210/// 6. Otherwise `Enabled`.
211///
212/// Consent is **machine-scoped**. The notice is only ever *rendered* on a TTY,
213/// but a decision recorded on a TTY authorizes later non-TTY runs on the same
214/// home. A fresh CI home has no decision, so step 3 fires and nothing is
215/// collected — and nothing is written to disk to find out.
216pub fn decide_in_home(
217    home: Option<&Path>,
218    resolved: &ResolvedRuntimeOptions,
219    setup: &SetupState,
220    surface: Surface,
221) -> TelemetryDecision {
222    let root = home.map(|home| home.join(TELEMETRY_DIR));
223
224    // 1. An explicit "off" from a human is an answer and wipes; the unset
225    //    default is not an answer and must leave every byte alone.
226    if !resolved.telemetry {
227        if resolved.telemetry_explicit_off {
228            return opted_out(root.as_deref());
229        }
230        return TelemetryDecision::ForcedOff;
231    }
232
233    // 2/3. The notice record is an independent condition. Declining is an
234    //      answer; never having been asked is not.
235    if setup.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION) {
236        return TelemetryDecision::ForcedOff;
237    }
238    if !setup.telemetry_opt_in {
239        return opted_out(root.as_deref());
240    }
241
242    // 4. Nowhere to keep an install id or a buffer.
243    let Some(root) = root else {
244        return TelemetryDecision::ForcedOff;
245    };
246
247    // 5. A refused endpoint is a configuration error, not a user answer.
248    let endpoint = match resolved.telemetry_endpoint.as_deref() {
249        Some(raw) if !raw.trim().is_empty() => match validate_endpoint(raw) {
250            Ok(endpoint) => Some(endpoint),
251            Err(error) => {
252                tracing::warn!(
253                    "telemetry endpoint refused ({}); telemetry is off for this run",
254                    error.label()
255                );
256                return TelemetryDecision::ForcedOff;
257            }
258        },
259        _ => None,
260    };
261
262    TelemetryDecision::Enabled(TelemetryConsent {
263        root,
264        endpoint,
265        surface,
266        config_path: None,
267    })
268}
269
270/// Re-run the predicate from the filesystem, for the flush path.
271///
272/// Loads the same config file the process was launched with and the current
273/// setup state, so a `codewhale config set telemetry false` written by another
274/// process between init and flush is honoured. Returns `ForcedOff` if either
275/// load fails: a flush is never the right place to guess.
276#[must_use]
277pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision {
278    let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
279        return TelemetryDecision::ForcedOff;
280    };
281    let resolved = store
282        .config
283        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
284    let setup = SetupState::load().ok().flatten().unwrap_or_default();
285    decide(&resolved, &setup, surface)
286}
287
288/// Perform the opt-out wipe, then report `OptedOut`.
289///
290/// Nothing is created for a user who never opted in: if the telemetry directory
291/// does not exist there is nothing to wipe and nothing to announce, so this
292/// returns without touching the filesystem.
293fn opted_out(root: Option<&Path>) -> TelemetryDecision {
294    if let Some(root) = root
295        && root.is_dir()
296        && let Err(error) = buffer::wipe(root)
297    {
298        // A failed wipe fails **closed**: the tombstone is written first and is
299        // never removed by the wipe, so even a partial failure leaves the
300        // buffer permanently undrainable.
301        tracing::warn!("telemetry opt-out wipe was incomplete: {error}");
302    }
303    TelemetryDecision::OptedOut
304}