返回 CodeWhale
decision.rs
根目录 / crates / telemetry / src / 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
11 use std::path::{Path, PathBuf};
12
13 use codewhale_config::{ResolvedRuntimeOptions, SetupState, TELEMETRY_NOTICE_VERSION};
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. "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)]
30 pub 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
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 }
74
75 impl 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)]
115 pub 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
124 impl 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.
148 pub 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`.
170 fn 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.
184 pub 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.
216 pub 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]
277 pub 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.
293 fn 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 }
305
305 lines RUST