返回 CodeWhale
decision.rs
根目录 / crates / telemetry / src / decision.rs
1 //! The one place that decides whether anonymous usage counting may run, and the
2 //! token 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 the permission decision enforceable by the type system rather than by six init sites
9 //! each remembering to re-check the same five-part predicate.
10
11 use std::path::{Path, PathBuf};
12
13 use codewhale_config::{ResolvedRuntimeOptions, SetupState, TelemetrySource};
14
15 use crate::buffer;
16 use crate::event::Surface;
17
18 /// Directory name under `$CODEWHALE_HOME` that holds every telemetry file.
19 pub 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. A run-scoped kill switch also resolves telemetry to false, so
25 /// a wipe keyed on that value would delete a user's identity and unflushed
26 /// 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)]
30 pub enum TelemetryDecision {
31 /// Anonymous usage counting is enabled and nothing forces it off.
32 Enabled(TelemetryConsent),
33 /// A human persistently said no — `telemetry = false` in durable config or
34 /// declining the notice. **The only variant that touches disk**: it wipes
35 /// and leaves a tombstone. CLI and environment false values are run-scoped
36 /// kill switches and produce [`Self::ForcedOff`] instead.
37 OptedOut,
38 /// Off for a run-scoped or environmental reason: an unparseable env value,
39 /// an unresolvable home, or a rejected endpoint. Touches nothing, ever.
40 /// Leaves identity and buffer exactly as they were.
41 ForcedOff,
42 }
43
44 impl 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)]
68 pub struct TelemetryConsent {
69 root: PathBuf,
70 endpoint: Option<String>,
71 surface: Surface,
72 config_path: Option<PathBuf>,
73 tombstone_generation: Option<buffer::TombstoneGeneration>,
74 }
75
76 impl TelemetryConsent {
77 /// Remember which config file this process was launched with, so the flush
78 /// path can re-resolve from it.
79 ///
80 /// Without this the documented mid-session opt-out —
81 /// `codewhale config set telemetry false`, an external write by another
82 /// process — would never be observed by a session that is already running.
83 #[must_use]
84 pub fn with_config_path(mut self, config_path: Option<PathBuf>) -> Self {
85 self.config_path = config_path;
86 self
87 }
88
89 /// The config file this process was launched with, if any.
90 #[must_use]
91 pub fn config_path(&self) -> Option<&Path> {
92 self.config_path.as_deref()
93 }
94
95 /// `$CODEWHALE_HOME/telemetry`.
96 #[must_use]
97 pub fn root(&self) -> &Path {
98 &self.root
99 }
100
101 /// The validated endpoint, or `None` for the dry-run sink.
102 #[must_use]
103 pub fn endpoint(&self) -> Option<&str> {
104 self.endpoint.as_deref()
105 }
106
107 /// The surface this consent was resolved for.
108 #[must_use]
109 pub fn surface(&self) -> Surface {
110 self.surface
111 }
112
113 /// Exact opt-out generation this decision observed.
114 pub(crate) fn tombstone_generation(&self) -> Option<&buffer::TombstoneGeneration> {
115 self.tombstone_generation.as_ref()
116 }
117 }
118
119 enum TelemetryEvaluation {
120 Enabled {
121 root: PathBuf,
122 endpoint: Option<String>,
123 tombstone_generation: Option<buffer::TombstoneGeneration>,
124 },
125 OptedOut(Option<PathBuf>),
126 ForcedOff,
127 }
128
129 /// Why an endpoint was refused.
130 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
131 pub enum EndpointError {
132 /// Not a URL we could parse at all.
133 Unparseable,
134 /// `http://` to something that is not loopback.
135 InsecureScheme,
136 /// A scheme that is neither `http` nor `https`.
137 UnsupportedScheme,
138 }
139
140 impl EndpointError {
141 /// A stable label for the single `warn` line.
142 #[must_use]
143 pub fn label(self) -> &'static str {
144 match self {
145 Self::Unparseable => "unparseable",
146 Self::InsecureScheme => "plaintext http to a non-loopback host",
147 Self::UnsupportedScheme => "scheme is neither https nor http",
148 }
149 }
150 }
151
152 /// Validate a configured endpoint.
153 ///
154 /// `https://` is required. Plaintext is permitted **only** for loopback hosts,
155 /// where a batch never reaches a wire — that is the staging and dogfood case.
156 ///
157 /// There is deliberately **no environment variable that overrides this**.
158 /// `CODEWHALE_ALLOW_INSECURE_HTTP` is not consulted: it authorizes an insecure
159 /// *provider* base URL, for harnesses that legitimately intercept model traffic,
160 /// and reusing it would let that interception decision also authorize plaintext
161 /// telemetry POSTs to an arbitrary host. Two unrelated trust decisions must not
162 /// share one switch, least of all in the subsystem whose whole promise is that
163 /// the user knows what leaves the machine.
164 pub fn validate_endpoint(raw: &str) -> Result<String, EndpointError> {
165 let trimmed = raw.trim();
166 let url = reqwest::Url::parse(trimmed).map_err(|_| EndpointError::Unparseable)?;
167 match url.scheme() {
168 "https" => Ok(trimmed.to_string()),
169 "http" => {
170 if is_loopback_host(url.host_str()) {
171 Ok(trimmed.to_string())
172 } else {
173 Err(EndpointError::InsecureScheme)
174 }
175 }
176 _ => Err(EndpointError::UnsupportedScheme),
177 }
178 }
179
180 /// Whether a host is one a packet can never leave the machine to reach.
181 ///
182 /// `Url::host_str` returns an IPv6 literal in its bracketed form (`[::1]`), so
183 /// the brackets come off before the address is parsed. Anything that parses as
184 /// an IP is judged by `is_loopback` — 127.0.0.0/8 and `::1` — and the only
185 /// accepted name is `localhost`.
186 fn is_loopback_host(host: Option<&str>) -> bool {
187 let Some(host) = host else {
188 return false;
189 };
190 let bare = host.trim_start_matches('[').trim_end_matches(']');
191 match bare.parse::<std::net::IpAddr>() {
192 Ok(address) => address.is_loopback(),
193 Err(_) => bare.eq_ignore_ascii_case("localhost"),
194 }
195 }
196
197 /// Resolve the emit predicate, reading the Codewhale home from the environment.
198 ///
199 /// See [`decide_in_home`] for the injectable form used by tests.
200 pub fn decide(
201 resolved: &ResolvedRuntimeOptions,
202 setup: &SetupState,
203 surface: Surface,
204 ) -> TelemetryDecision {
205 // `codewhale_home()` returns `Ok(None)` when no home can be resolved, and
206 // an error when an explicit override was unusable. Both are "we have
207 // nowhere to keep state", which is `ForcedOff`, never a wipe.
208 let home = codewhale_paths::codewhale_home().ok().flatten();
209 decide_in_home(home.as_deref(), resolved, setup, surface)
210 }
211
212 /// Load the privacy-bearing setup record for a telemetry decision.
213 ///
214 /// A genuinely missing record is a fresh installation and therefore uses the
215 /// documented default. An existing record that cannot be read or parsed may
216 /// contain a durable decline, so it fails closed instead of being replaced by
217 /// a fresh default.
218 #[must_use]
219 pub fn load_setup_state_for_decision() -> Option<SetupState> {
220 let path = SetupState::path().ok()?;
221 load_setup_state_for_decision_at(&path)
222 }
223
224 /// Injectable form of [`load_setup_state_for_decision`] used by every surface
225 /// and by regression tests.
226 #[must_use]
227 pub fn load_setup_state_for_decision_at(path: &Path) -> Option<SetupState> {
228 match path.try_exists() {
229 Ok(false) => Some(SetupState::default()),
230 Ok(true) => SetupState::load_from(path),
231 Err(_) => None,
232 }
233 }
234
235 /// Resolve the emit predicate against an explicit Codewhale home.
236 ///
237 /// The predicate, in order:
238 ///
239 /// 1. Telemetry resolved to `false` from persistent config → `OptedOut`;
240 /// resolved `false` from a run-scoped or invalid-value floor → `ForcedOff`.
241 /// 2. Any recorded notice decline → `OptedOut`, including a decline recorded
242 /// by the former opt-in notice.
243 /// 3. No resolvable home → `ForcedOff`.
244 /// 4. Endpoint configured but refused by [`validate_endpoint`] → `ForcedOff`.
245 /// 5. Otherwise `Enabled`, including an absent configuration preference.
246 ///
247 /// The disclosure is policy metadata, not a consent gate. Every armed surface
248 /// presents a startup disclosure; the TUI also has a localized notice.
249 pub fn decide_in_home(
250 home: Option<&Path>,
251 resolved: &ResolvedRuntimeOptions,
252 setup: &SetupState,
253 surface: Surface,
254 ) -> TelemetryDecision {
255 match evaluate_in_home(home, resolved, setup) {
256 TelemetryEvaluation::Enabled {
257 root,
258 endpoint,
259 tombstone_generation,
260 } => TelemetryDecision::Enabled(TelemetryConsent {
261 root,
262 endpoint,
263 surface,
264 config_path: None,
265 tombstone_generation,
266 }),
267 TelemetryEvaluation::OptedOut(root) => opted_out(root.as_deref()),
268 TelemetryEvaluation::ForcedOff => TelemetryDecision::ForcedOff,
269 }
270 }
271
272 /// Evaluate the permission predicate without performing the opt-out wipe.
273 ///
274 /// Keeping the classification pure lets `init` re-check it while holding the
275 /// privacy lock. The public decision path maps `OptedOut` to the destructive
276 /// wipe exactly once, outside that already-held lock.
277 fn evaluate_in_home(
278 home: Option<&Path>,
279 resolved: &ResolvedRuntimeOptions,
280 setup: &SetupState,
281 ) -> TelemetryEvaluation {
282 let root = home.map(|home| home.join(TELEMETRY_DIR));
283
284 // 1. An explicit persistent "off" is an opt-out and wipes. Run-scoped or
285 // invalid-value false is only a kill switch and leaves disk alone.
286 if !resolved.telemetry {
287 if resolved.telemetry_explicit_off
288 || (resolved.telemetry_source == TelemetrySource::Default
289 && setup.telemetry_opted_out())
290 {
291 // A run-scoped preference must not hide a durable decline in
292 // the sidecar when the config register was never written.
293 return TelemetryEvaluation::OptedOut(root);
294 }
295 return TelemetryEvaluation::ForcedOff;
296 }
297
298 // 2. A historical or current decline remains a durable opt-out. Notice
299 // version bumps may update disclosure, never reverse a user's "no".
300 if setup.telemetry_opted_out() {
301 return TelemetryEvaluation::OptedOut(root);
302 }
303
304 // Nowhere to keep an install id or a buffer.
305 let Some(root) = root else {
306 return TelemetryEvaluation::ForcedOff;
307 };
308
309 // 4. A refused endpoint is a configuration error, not a user answer.
310 let endpoint = match resolved.telemetry_endpoint.as_deref() {
311 Some(raw) if !raw.trim().is_empty() => match validate_endpoint(raw) {
312 Ok(endpoint) => Some(endpoint),
313 Err(error) => {
314 tracing::warn!(
315 "telemetry endpoint refused ({}); telemetry is off for this run",
316 error.label()
317 );
318 return TelemetryEvaluation::ForcedOff;
319 }
320 },
321 _ => None,
322 };
323
324 let Ok(tombstone_generation) = buffer::tombstone_generation(&root) else {
325 return TelemetryEvaluation::ForcedOff;
326 };
327 TelemetryEvaluation::Enabled {
328 root,
329 endpoint,
330 tombstone_generation,
331 }
332 }
333
334 /// Re-check the current durable permission without wiping or clearing state.
335 ///
336 /// Called only while `init` holds the telemetry privacy lock. A stale consent
337 /// token may arm only when the config, setup-state answer, home, and endpoint
338 /// still classify as enabled.
339 pub(crate) fn permission_still_enabled(config_path: Option<&Path>, expected_root: &Path) -> bool {
340 let Ok(setup_path) = SetupState::path() else {
341 return false;
342 };
343 let home = codewhale_paths::codewhale_home().ok().flatten();
344 permission_still_enabled_in_home(config_path, &setup_path, home.as_deref(), expected_root)
345 }
346
347 pub(crate) fn permission_still_enabled_in_home(
348 config_path: Option<&Path>,
349 setup_path: &Path,
350 home: Option<&Path>,
351 expected_root: &Path,
352 ) -> bool {
353 let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
354 return false;
355 };
356 let resolved = store
357 .config
358 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
359 let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
360 return false;
361 };
362 matches!(
363 evaluate_in_home(home, &resolved, &setup),
364 TelemetryEvaluation::Enabled { root, .. } if root == expected_root
365 )
366 }
367
368 /// Re-run the predicate from the filesystem, for the flush path.
369 ///
370 /// Loads the same config file the process was launched with and the current
371 /// setup state, so a `codewhale config set telemetry false` written by another
372 /// process between init and flush is honoured. Returns `ForcedOff` if either
373 /// load fails: a flush is never the right place to guess.
374 #[must_use]
375 pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision {
376 let Ok(setup_path) = SetupState::path() else {
377 return TelemetryDecision::ForcedOff;
378 };
379 re_decide_with_setup_path(config_path, &setup_path, surface)
380 }
381
382 pub(crate) fn re_decide_with_setup_path(
383 config_path: Option<&Path>,
384 setup_path: &Path,
385 surface: Surface,
386 ) -> TelemetryDecision {
387 let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
388 return TelemetryDecision::ForcedOff;
389 };
390 let resolved = store
391 .config
392 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
393 let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
394 return TelemetryDecision::ForcedOff;
395 };
396 decide(&resolved, &setup, surface)
397 }
398
399 /// Perform the opt-out wipe, then report `OptedOut`.
400 ///
401 /// Nothing is created for a user who never opted in: if the telemetry directory
402 /// does not exist there is nothing to wipe and nothing to announce, so this
403 /// returns without touching the filesystem.
404 fn opted_out(root: Option<&Path>) -> TelemetryDecision {
405 if let Some(root) = root
406 && root.is_dir()
407 && let Err(error) = buffer::wipe(root)
408 {
409 // A failed wipe fails **closed**: the tombstone is written first and is
410 // never removed by the wipe, so even a partial failure leaves the
411 // buffer permanently undrainable.
412 tracing::warn!("telemetry opt-out wipe was incomplete: {error}");
413 }
414 TelemetryDecision::OptedOut
415 }
416
416 lines RUST