fix(config): make the persistent telemetry off a floor and a run-scoped off harmless

Two adversary findings against the shipped telemetry build, both about the
same confusion: the resolver had one flag for "a human said no" and used it
for two jobs it cannot do at once.

`--telemetry true` beat `telemetry = false` in the config file, and the
dispatcher then forwarded the resolved `true` as `CODEWHALE_TELEMETRY=true`,
which outranked the child's own copy of that same file. The first-run notice
and docs/TELEMETRY.md both advertise `codewhale config set telemetry false` as
the *permanent* off switch; any wrapper script, alias, or agent harness passing
the flag re-enabled a user who had used it. The persisted value is now a floor,
like the environment one. Re-enabling is writing the same durable register the
off was written in.

`telemetry_explicit_off` — the flag that authorizes the destructive opt-out
wipe — was set by `--telemetry false` and `CODEWHALE_TELEMETRY=0` as well. So
the recipe docs/AGENT_RUNTIME.md prescribes for a single command deleted a
consenting user's install id and truncated their own dry-run records, every
time. It now means only "the config file says false", which is the one signal
that is durable, is the user's own, and is re-asserted on the next run.

That narrowing exposes a distinction the child process could not draw for
itself: the dispatcher forwards a resolved `CODEWHALE_TELEMETRY=false` on every
ordinary run, so a declared operator kill switch and the shipped default arrive
identically. `CODEWHALE_TELEMETRY_FLOOR` is the dispatcher stating which one it
is, and `telemetry_floor_in_force()` reads the statement where it exists and the
raw environment where it does not. The first-run notice is the caller that
needs it.
This commit is contained in:
Hmbown
2026-08-03 22:14:04 -07:00
parent 7a18d792dc
commit 8d1074f7a5
3 changed files with 218 additions and 1675 deletions
+78 -19
View File
@@ -3313,19 +3313,35 @@ impl ConfigToml {
.or(env.telemetry)
.or(self.telemetry)
.unwrap_or(false);
// Off is sticky: an explicit env "off", or an env value we could not
// parse, forces off regardless of CLI flag or config file. A kill
// switch that a later flag can re-enable is not a kill switch, and a
// typo in `CODEWHALE_TELEMETRY` must never resolve to "on".
let telemetry =
telemetry_allowed && env.telemetry != Some(false) && !env.telemetry_env_invalid;
// Distinguish "the user said no" from "nobody said anything". Only the
// former is an answer; the unset default must never be read as one.
let telemetry_explicit_off = cli.telemetry == Some(false)
|| env.telemetry == Some(false)
|| (cli.telemetry.is_none()
&& env.telemetry.is_none()
&& self.telemetry == Some(false));
// `telemetry = false` written to the config file is the off switch the
// first-run notice and `docs/TELEMETRY.md` both advertise as the
// *persistent* one, so it is a floor and not merely the last term of a
// precedence chain. Before this it lost to `--telemetry true`, and the
// dispatcher then laundered that per-run flag into the child's
// `CODEWHALE_TELEMETRY`, where it also outranked the child's own copy
// of the same file: any wrapper script, alias, or agent harness that
// passed the flag silently re-enabled a user who had turned telemetry
// off. Re-enabling is `codewhale config set telemetry true`, which is
// the same durable register the off was written in.
let telemetry_persisted_off = self.telemetry == Some(false);
// Off is sticky: an explicit env "off", an env value we could not
// parse, or a floor declared by the dispatcher forces off regardless of
// CLI flag or config file. A kill switch that a later flag can
// re-enable is not a kill switch, and a typo in `CODEWHALE_TELEMETRY`
// must never resolve to "on".
let telemetry = telemetry_allowed
&& env.telemetry != Some(false)
&& !env.telemetry_env_invalid
&& !env.telemetry_floor
&& !telemetry_persisted_off;
// Only a *persisted* off is an answer. `--telemetry false` and
// `CODEWHALE_TELEMETRY=0` are run-scoped kill switches: they must stop
// this run without deleting the identity and buffered events of a user
// who never revoked consent — the dispatcher forwards a resolved
// `false` on every ordinary run, so treating an environment "off" as an
// answer would also make the default state indistinguishable from a
// revocation.
let telemetry_explicit_off = telemetry_persisted_off;
let telemetry_endpoint = env
.telemetry_endpoint
.clone()
@@ -3378,6 +3394,39 @@ fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &Provi
}
}
/// The dispatcher's statement to the TUI child about *why* telemetry is off.
///
/// Private to the `codewhale` → `codewhale-tui` hop, in the same spirit as
/// `DEEPSEEK_API_KEY_SOURCE`. Set to `1`/`0` on every delegated run.
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
/// Whether an environment-level kill switch forces telemetry off here.
///
/// A floor is *not* the same as "telemetry resolved to false": off is the
/// default, and the dispatcher forwards a resolved `CODEWHALE_TELEMETRY=false`
/// on every ordinary run, so a child reading only that value cannot tell an
/// operator's declared kill switch from the shipped default. That distinction
/// matters exactly once — the first-run notice must not ask a question whose
/// answer this environment overrides — so the dispatcher states it outright in
/// [`TELEMETRY_FLOOR_ENV`] and the child believes the statement.
///
/// With no statement (a directly launched `codewhale-tui`) the raw environment
/// is read instead, where an explicit "off" or an unreadable value is a floor.
#[must_use]
pub fn telemetry_floor_in_force() -> bool {
if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
&& let Ok(declared) = parse_bool(&raw)
{
return declared;
}
let Ok(raw) =
std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
else {
return false;
};
!matches!(parse_bool(&raw), Ok(true))
}
#[must_use]
pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
let Some(project_rank) = approval_policy_rank(project) else {
@@ -4692,13 +4741,16 @@ pub struct ResolvedRuntimeOptions {
pub output_mode: Option<String>,
pub log_level: Option<String>,
pub telemetry: bool,
/// A human explicitly turned telemetry off — via `--telemetry false`,
/// `CODEWHALE_TELEMETRY=0`, or `telemetry = false` in the config file.
/// A human wrote `telemetry = false` into the config file.
///
/// This is *not* the same as [`Self::telemetry`] being `false`, which is
/// also the value when nobody has said anything at all. Consumers that
/// treat an answer differently from a default must read this flag rather
/// than infer intent from the resolved boolean.
/// This is the *persistent* opt-out, and it is deliberately narrower than
/// "telemetry resolved to false". `false` is also the value when nobody has
/// said anything at all, and it is what the dispatcher forwards to the TUI
/// on every ordinary run; a consumer that reads those as a revocation would
/// destroy the identity and buffered events of a consenting user who merely
/// set `CODEWHALE_TELEMETRY=0` for one command. Run-scoped kill switches
/// (`--telemetry false`, the environment variable) stop the run and leave
/// every byte on disk alone; only this flag authorizes the wipe.
pub telemetry_explicit_off: bool,
/// Where a telemetry batch would be sent, if telemetry were on.
///
@@ -6366,6 +6418,11 @@ struct EnvRuntimeOverrides {
/// resolve to "on", so this forces telemetry off the same way an explicit
/// `false` does.
telemetry_env_invalid: bool,
/// An environment-level kill switch is in force for this process.
///
/// See [`telemetry_floor_in_force`] for what sets it and why the dispatcher
/// has to state it rather than let the child infer it.
telemetry_floor: bool,
/// `CODEWHALE_TELEMETRY_ENDPOINT`/`DEEPSEEK_TELEMETRY_ENDPOINT`. Overrides
/// the config file. A workspace `.env` cannot reach this — the dotenv
/// allowlist admits only built-in provider credential names.
@@ -6438,6 +6495,7 @@ impl EnvRuntimeOverrides {
fn load() -> Self {
let (provider, provider_source) = Self::load_provider();
let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
let telemetry_floor = telemetry_floor_in_force();
Self {
provider,
provider_source,
@@ -6494,6 +6552,7 @@ impl EnvRuntimeOverrides {
.ok(),
telemetry,
telemetry_env_invalid,
telemetry_floor,
telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
.or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
.ok()
+140 -6
View File
@@ -7918,6 +7918,7 @@ max_spawn_depth = 1
struct TelemetryEnvGuard {
codewhale: Option<OsString>,
deepseek: Option<OsString>,
floor: Option<OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
@@ -7927,12 +7928,14 @@ impl TelemetryEnvGuard {
let guard = Self {
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
floor: env::var_os(TELEMETRY_FLOOR_ENV),
_lock: lock,
};
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
}
guard
}
@@ -7943,6 +7946,22 @@ impl TelemetryEnvGuard {
env::set_var("CODEWHALE_TELEMETRY", value);
}
}
fn set_floor(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
env::set_var(TELEMETRY_FLOOR_ENV, value);
}
}
fn clear(&self) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
}
}
}
impl Drop for TelemetryEnvGuard {
@@ -7957,6 +7976,10 @@ impl Drop for TelemetryEnvGuard {
Some(value) => env::set_var("DEEPSEEK_TELEMETRY", value),
None => env::remove_var("DEEPSEEK_TELEMETRY"),
}
match self.floor.take() {
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
None => env::remove_var(TELEMETRY_FLOOR_ENV),
}
}
}
}
@@ -7980,7 +8003,117 @@ fn env_telemetry_off_is_a_floor_over_cli_on() {
// `--telemetry true` must not be able to climb back over an explicit
// `CODEWHALE_TELEMETRY=0`. Off is a floor, not one more precedence rung.
assert!(!resolved.telemetry);
// …but the environment is a run-scoped switch, not a revocation. See
// `a_run_scoped_off_is_a_kill_switch_and_not_a_revocation`.
assert!(!resolved.telemetry_explicit_off);
}
#[test]
fn persisted_telemetry_off_is_a_floor_over_cli_on() {
// Regression: `--telemetry true` used to beat `telemetry = false` in the
// config file, and the dispatcher then forwarded the resolved `true` as
// `CODEWHALE_TELEMETRY=true`, which also outranked the child's own copy of
// that file. Any wrapper script, alias, or agent harness passing the flag
// silently re-enabled a user who had turned telemetry off through the one
// switch the first-run notice advertises as permanent.
let guard = TelemetryEnvGuard::take();
let config = ConfigToml {
telemetry: Some(false),
..ConfigToml::default()
};
let cli = CliRuntimeOverrides {
telemetry: Some(true),
..CliRuntimeOverrides::default()
};
let resolved = config.resolve_runtime_options(&cli);
assert!(!resolved.telemetry);
// And it stays an answer, so the run re-asserts the tombstone.
assert!(resolved.telemetry_explicit_off);
// An environment "on" loses to it as well: re-enabling is writing the
// durable register the off was written in.
guard.set("1");
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert!(!resolved.telemetry);
assert!(resolved.telemetry_explicit_off);
}
#[test]
fn a_run_scoped_off_is_a_kill_switch_and_not_a_revocation() {
// Regression: an explicit `CODEWHALE_TELEMETRY=0` marked the run as an
// *answer*, so the telemetry crate took its destructive opt-out branch —
// deleting the install id and truncating the user's own dry-run records —
// on a recipe the runtime docs prescribe for one command. Worse, the
// dispatcher forwards a resolved `false` on every ordinary run, so the
// shipped default was indistinguishable from a revocation.
let guard = TelemetryEnvGuard::take();
for value in ["0", "false", "off", "disabled", "no"] {
guard.set(value);
let config = ConfigToml {
telemetry: Some(true),
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert!(!resolved.telemetry, "{value} must stop the run");
assert!(
!resolved.telemetry_explicit_off,
"{value} must not read as a revocation"
);
}
// The same for the per-run flag, with the environment back to silent.
guard.clear();
let cli = CliRuntimeOverrides {
telemetry: Some(false),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml {
telemetry: Some(true),
..ConfigToml::default()
}
.resolve_runtime_options(&cli);
assert!(!resolved.telemetry);
assert!(!resolved.telemetry_explicit_off);
}
#[test]
fn the_dispatcher_states_the_floor_rather_than_letting_the_child_infer_it() {
// The child cannot tell an operator's declared kill switch from the
// shipped default: both arrive as `CODEWHALE_TELEMETRY=false`. So the
// dispatcher states it, and the statement outranks the inference — which
// is what lets the first-run notice refuse to ask under a real floor while
// still asking on an ordinary first run.
let guard = TelemetryEnvGuard::take();
assert!(!telemetry_floor_in_force());
guard.set("0");
assert!(telemetry_floor_in_force());
// A forwarded resolved `false` with the dispatcher saying "no floor" is
// the ordinary first run.
guard.set("false");
guard.set_floor("0");
assert!(!telemetry_floor_in_force());
// A declared floor holds even where the value alone would not show it.
guard.set("true");
guard.set_floor("1");
assert!(telemetry_floor_in_force());
let resolved = ConfigToml {
telemetry: Some(true),
..ConfigToml::default()
}
.resolve_runtime_options(&CliRuntimeOverrides::default());
assert!(!resolved.telemetry);
// An unreadable value is a floor: a typo in a kill switch never resolves
// to "on".
guard.clear();
guard.set("maybe");
assert!(telemetry_floor_in_force());
}
#[test]
@@ -8039,24 +8172,25 @@ fn telemetry_explicit_off_distinguishes_an_answer_from_the_default() {
assert!(!resolved.telemetry);
assert!(resolved.telemetry_explicit_off);
// A CLI `--telemetry true` overrides the file's `false`, so the file is no
// longer the answer and telemetry is on.
// A CLI `--telemetry true` does not override the file's `false`: the
// persistent switch is a floor, and the answer stands.
let cli = CliRuntimeOverrides {
telemetry: Some(true),
..CliRuntimeOverrides::default()
};
let resolved = config.resolve_runtime_options(&cli);
assert!(resolved.telemetry);
assert!(!resolved.telemetry_explicit_off);
assert!(!resolved.telemetry);
assert!(resolved.telemetry_explicit_off);
// A CLI `--telemetry false` is an answer.
// A CLI `--telemetry false` stops the run without being an answer: it is
// scoped to the run, and a run-scoped switch must not delete state.
let cli = CliRuntimeOverrides {
telemetry: Some(false),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
assert!(!resolved.telemetry);
assert!(resolved.telemetry_explicit_off);
assert!(!resolved.telemetry_explicit_off);
}
#[test]
File diff suppressed because it is too large Load Diff