From 3a830d7f332bca770366ddabde3f7cf211320bc3 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 16 Sep 2026 02:44:37 -0400 Subject: [PATCH] test(daemon): kill 22 surviving mutants in the licence, secret and tier helpers Covers intelligence/upgrade.rs, policy/secrets.rs and the licence cache in license/mod.rs, from the gate's exact surviving-mutant lists. Every mutant was hand-applied and the suite confirmed to fail: 22 killed, 0 survived. The licence cache is the part that matters most. read_cache_grace decides whether a cached paid tier is still honoured while the licence server is unreachable, and it had no test at all, so all of its mutants survived. Flipping `Utc::now() < valid_until` to `>` honours an expired cache and rejects a valid one; the constant-true variant turns a 24-hour grace period into an unbounded one; dropping the `!` on the HMAC check makes exactly the forged rows the trusted ones. Each is now pinned in both directions - a fresh cache must be honoured, an expired or forged one must not. write_cache is the classic `async fn -> Result<()>` case: the body mutates to Ok(()), which reports success without writing anything, so the test reads the row back rather than trusting the return value. The secret scanner's entropy branch needed fixtures built around the arithmetic. The threshold is 4.5 bits and a 20-character string carries at most log2(20) = 4.32 bits even when every character is distinct, so nothing of the minimum length can ever trip it; the fixtures use 32 distinct characters (5.0 bits) and size their padding to move the measurement across the threshold in a chosen direction. Forty dots either side of a token measure 2.29 bits untrimmed and 5.0 trimmed, which separates four different mutations of the trim predicate at once. Two notes on what these tests record rather than endorse: - `the_base64_alphabet_is_kept_by_the_trim` asserts a NEGATIVE. Sixteen leading '+' pull a real 5.0-bit token down to 4.25 bits, under the threshold, because '+' is deliberately kept by the trim as part of the base64 alphabet. That is a genuine limitation of the heuristic. The test pins the documented rule; it does not bless the gap. - provider_for_model ignores its argument and answers "claude" for everything. Correct only because every model in the upgrade chain is a Claude model - the moment one is not, it mislabels it silently. Two equivalent mutants are documented in place rather than chased: - `Utc::now() < valid_until` -> `<=` differs only when the instant equals the stored expiry to the nanosecond. Unkillable, not uncovered. - in upgrade_model, `max_model == "sonnet" || max_model == "haiku"` -> `&&`. The early return it guards is redundant with the tier check below it: with the cap at sonnet or haiku the only upgrade target from sonnet is opus, and `next_tier > max_tier` already rejects that. Both versions return None for every input. Also worth flagging, left alone because this change is test-only: in model_tier, the `|| lower == "opus"` clause (and its sonnet twin) is dead - any string equal to "opus" also contains it, so the equality can never decide anything. Inline `mod tests` blocks move to `/tests.rs` with existing cases kept verbatim. No production code changes - the source-side diff is 6 inserted lines, all `mod tests;` declarations. --- apps/daemon/src/intelligence/upgrade.rs | 121 +---- apps/daemon/src/intelligence/upgrade/tests.rs | 197 ++++++++ apps/daemon/src/license/mod.rs | 257 +--------- apps/daemon/src/license/tests.rs | 441 ++++++++++++++++++ apps/daemon/src/policy/secrets.rs | 41 +- apps/daemon/src/policy/secrets/tests.rs | 183 ++++++++ 6 files changed, 827 insertions(+), 413 deletions(-) create mode 100644 apps/daemon/src/intelligence/upgrade/tests.rs create mode 100644 apps/daemon/src/license/tests.rs create mode 100644 apps/daemon/src/policy/secrets/tests.rs diff --git a/apps/daemon/src/intelligence/upgrade.rs b/apps/daemon/src/intelligence/upgrade.rs index 1e56071..d49b3d4 100644 --- a/apps/daemon/src/intelligence/upgrade.rs +++ b/apps/daemon/src/intelligence/upgrade.rs @@ -133,123 +133,6 @@ fn provider_for_model(_model: &str) -> String { // ─── Tests ──────────────────────────────────────────────────────────────────── +// Tests live in tests.rs. #[cfg(test)] -mod tests { - use super::*; - use crate::config::ModelIntelligenceConfig; - - fn runner_ok(model: &str) -> RunnerOutput { - RunnerOutput { - content: "Here is the implementation you requested.".to_string(), - tool_call_error: false, - output_truncated: false, - model_id: model.to_string(), - input_tokens: 100, - output_tokens: 200, - } - } - - fn runner_empty() -> RunnerOutput { - RunnerOutput { - content: String::new(), - tool_call_error: false, - output_truncated: false, - model_id: "claude-haiku-4-5".to_string(), - input_tokens: 0, - output_tokens: 0, - } - } - - fn runner_refusal() -> RunnerOutput { - RunnerOutput { - content: "I'm unable to complete this task as an AI.".to_string(), - tool_call_error: false, - output_truncated: false, - model_id: "claude-haiku-4-5".to_string(), - input_tokens: 50, - output_tokens: 20, - } - } - - fn haiku_selection() -> ModelSelection { - ModelSelection { - model_id: "claude-haiku-4-5".to_string(), - provider: "claude".to_string(), - reason: "auto_select:Simple".to_string(), - } - } - - fn sonnet_selection() -> ModelSelection { - ModelSelection { - model_id: "claude-sonnet-4-6".to_string(), - provider: "claude".to_string(), - reason: "auto_select:Moderate".to_string(), - } - } - - #[test] - fn ok_response_no_upgrade() { - let q = evaluate_response(&runner_ok("claude-haiku-4-5")); - assert_eq!(q, ResponseQuality::Ok); - } - - #[test] - fn empty_response_is_poor() { - let q = evaluate_response(&runner_empty()); - assert_eq!(q, ResponseQuality::Poor(PoorReason::EmptyResponse)); - } - - #[test] - fn refusal_is_poor() { - let q = evaluate_response(&runner_refusal()); - assert_eq!(q, ResponseQuality::Poor(PoorReason::ModelRefusal)); - } - - #[test] - fn tool_call_error_is_poor() { - let mut out = runner_ok("claude-haiku-4-5"); - out.tool_call_error = true; - let q = evaluate_response(&out); - assert_eq!(q, ResponseQuality::Poor(PoorReason::ToolCallError)); - } - - #[test] - fn haiku_upgrades_to_sonnet() { - let cfg = ModelIntelligenceConfig::default(); - let sel = upgrade_model(&haiku_selection(), &cfg, 0); - assert!(sel.is_some()); - let sel = sel.unwrap(); - assert!(sel.model_id.contains("sonnet"), "got: {}", sel.model_id); - } - - #[test] - fn max_one_upgrade_per_message() { - let cfg = ModelIntelligenceConfig::default(); - let sel = upgrade_model(&haiku_selection(), &cfg, 1); - assert!(sel.is_none(), "upgrade_count=1 should prevent upgrade"); - } - - #[test] - fn sonnet_upgrades_to_opus_when_allowed() { - let cfg = ModelIntelligenceConfig { - max_model: "opus".to_string(), - ..Default::default() - }; - let sel = upgrade_model(&sonnet_selection(), &cfg, 0); - assert!(sel.is_some()); - assert!(sel.unwrap().model_id.contains("opus")); - } - - #[test] - fn sonnet_cannot_upgrade_when_capped_at_sonnet() { - let cfg = ModelIntelligenceConfig { - max_model: "sonnet".to_string(), - ..Default::default() - }; - let sel = upgrade_model(&sonnet_selection(), &cfg, 0); - assert!( - sel.is_none(), - "sonnet capped at sonnet should block upgrade" - ); - } -} +mod tests; diff --git a/apps/daemon/src/intelligence/upgrade/tests.rs b/apps/daemon/src/intelligence/upgrade/tests.rs new file mode 100644 index 0000000..8a67916 --- /dev/null +++ b/apps/daemon/src/intelligence/upgrade/tests.rs @@ -0,0 +1,197 @@ +//! Tests for the model-upgrade helpers. +//! +//! Written against the surviving-mutant list from the mutation gate. + +use super::*; + +// ─── model_tier ────────────────────────────────────────────────────────────── + +/// Kills the `3` -> `0` and `3` -> `1` return-value mutations, and the +/// `contains("opus") || lower == "opus"` -> `&&` pairing. +/// +/// A real model id contains "opus" without equalling it, so the `&&` pairing +/// drops opus to the fallback tier — and the cap check in `upgrade_model` +/// then stops rejecting upgrades that exceed the configured maximum. +#[test] +fn opus_models_are_tier_three_however_they_are_named() { + assert_eq!(model_tier("opus"), 3); + assert_eq!(model_tier("claude-3-opus-20240229"), 3); + assert_eq!(model_tier("CLAUDE-OPUS-4"), 3); +} + +/// The same shape for the sonnet arm. +#[test] +fn sonnet_models_are_tier_two_however_they_are_named() { + assert_eq!(model_tier("sonnet"), 2); + assert_eq!(model_tier("claude-sonnet-4-6"), 2); + assert_eq!(model_tier("CLAUDE-SONNET-4"), 2); +} + +/// Kills both `==` -> `!=` mutations. +/// +/// With `lower != "opus"` in place of `==`, every model that is not the +/// literal string "opus" reports as tier 3 — haiku included — which would make +/// the cap check wave through every upgrade. Asserting the LOW tier is what +/// catches it; asserting only the high tiers would not. +#[test] +fn anything_below_sonnet_is_tier_one() { + assert_eq!(model_tier("haiku"), 1); + assert_eq!(model_tier("claude-3-5-haiku-20241022"), 1); + assert_eq!(model_tier(""), 1); + assert_eq!(model_tier("gpt-5"), 1); +} + +/// Strict ordering is the property the cap check actually relies on. +#[test] +fn tiers_are_strictly_ordered() { + assert!(model_tier("haiku") < model_tier("sonnet")); + assert!(model_tier("sonnet") < model_tier("opus")); +} + +// NOTE: the `|| lower == "opus"` clause (and its sonnet twin) is redundant — +// any string equal to "opus" also contains it, so the equality can never be +// the deciding test. Left alone here because this change is test-only, but it +// is dead weight, and a reader could reasonably think the function matches +// exact names as well as substrings. + +// ─── provider_for_model ────────────────────────────────────────────────────── + +/// Kills the `String::new()` and `"xyzzy".into()` body replacements. +/// +/// Note what this pins: the function ignores its argument and answers "claude" +/// for everything, which is why the parameter is named `_model`. That is +/// correct only because every model in the upgrade chain is a Claude model — +/// if a non-Claude model ever enters the chain, this silently mislabels it. +#[test] +fn every_upgrade_target_is_attributed_to_claude() { + assert_eq!(provider_for_model("claude-sonnet-4-6"), "claude"); + assert_eq!(provider_for_model("opus"), "claude"); + assert_eq!(provider_for_model(""), "claude"); +} + +// NOTE — equivalent mutant, deliberately not chased: in `upgrade_model`, +// `config.max_model == "sonnet" || config.max_model == "haiku"` -> `&&`. +// The early `return None` it guards is redundant with the tier check below it: +// with the cap at "sonnet" or "haiku" the only upgrade target from sonnet is +// opus (tier 3), and `next_tier > max_tier` already rejects that. Both the +// real code and the mutant return None for every input, so nothing can +// distinguish them. + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use crate::config::ModelIntelligenceConfig; + +fn runner_ok(model: &str) -> RunnerOutput { + RunnerOutput { + content: "Here is the implementation you requested.".to_string(), + tool_call_error: false, + output_truncated: false, + model_id: model.to_string(), + input_tokens: 100, + output_tokens: 200, + } +} + +fn runner_empty() -> RunnerOutput { + RunnerOutput { + content: String::new(), + tool_call_error: false, + output_truncated: false, + model_id: "claude-haiku-4-5".to_string(), + input_tokens: 0, + output_tokens: 0, + } +} + +fn runner_refusal() -> RunnerOutput { + RunnerOutput { + content: "I'm unable to complete this task as an AI.".to_string(), + tool_call_error: false, + output_truncated: false, + model_id: "claude-haiku-4-5".to_string(), + input_tokens: 50, + output_tokens: 20, + } +} + +fn haiku_selection() -> ModelSelection { + ModelSelection { + model_id: "claude-haiku-4-5".to_string(), + provider: "claude".to_string(), + reason: "auto_select:Simple".to_string(), + } +} + +fn sonnet_selection() -> ModelSelection { + ModelSelection { + model_id: "claude-sonnet-4-6".to_string(), + provider: "claude".to_string(), + reason: "auto_select:Moderate".to_string(), + } +} + +#[test] +fn ok_response_no_upgrade() { + let q = evaluate_response(&runner_ok("claude-haiku-4-5")); + assert_eq!(q, ResponseQuality::Ok); +} + +#[test] +fn empty_response_is_poor() { + let q = evaluate_response(&runner_empty()); + assert_eq!(q, ResponseQuality::Poor(PoorReason::EmptyResponse)); +} + +#[test] +fn refusal_is_poor() { + let q = evaluate_response(&runner_refusal()); + assert_eq!(q, ResponseQuality::Poor(PoorReason::ModelRefusal)); +} + +#[test] +fn tool_call_error_is_poor() { + let mut out = runner_ok("claude-haiku-4-5"); + out.tool_call_error = true; + let q = evaluate_response(&out); + assert_eq!(q, ResponseQuality::Poor(PoorReason::ToolCallError)); +} + +#[test] +fn haiku_upgrades_to_sonnet() { + let cfg = ModelIntelligenceConfig::default(); + let sel = upgrade_model(&haiku_selection(), &cfg, 0); + assert!(sel.is_some()); + let sel = sel.unwrap(); + assert!(sel.model_id.contains("sonnet"), "got: {}", sel.model_id); +} + +#[test] +fn max_one_upgrade_per_message() { + let cfg = ModelIntelligenceConfig::default(); + let sel = upgrade_model(&haiku_selection(), &cfg, 1); + assert!(sel.is_none(), "upgrade_count=1 should prevent upgrade"); +} + +#[test] +fn sonnet_upgrades_to_opus_when_allowed() { + let cfg = ModelIntelligenceConfig { + max_model: "opus".to_string(), + ..Default::default() + }; + let sel = upgrade_model(&sonnet_selection(), &cfg, 0); + assert!(sel.is_some()); + assert!(sel.unwrap().model_id.contains("opus")); +} + +#[test] +fn sonnet_cannot_upgrade_when_capped_at_sonnet() { + let cfg = ModelIntelligenceConfig { + max_model: "sonnet".to_string(), + ..Default::default() + }; + let sel = upgrade_model(&sonnet_selection(), &cfg, 0); + assert!( + sel.is_none(), + "sonnet capped at sonnet should block upgrade" + ); +} diff --git a/apps/daemon/src/license/mod.rs b/apps/daemon/src/license/mod.rs index fe7882a..e125db8 100644 --- a/apps/daemon/src/license/mod.rs +++ b/apps/daemon/src/license/mod.rs @@ -238,259 +238,6 @@ async fn read_cache_grace(storage: &Storage) -> LicenseInfo { } } +// Tests live in tests.rs. #[cfg(test)] -mod tests { - use super::*; - use crate::storage::LicenseCacheRow; - - fn make_features(relay: bool, auto_switch: bool, clawde_plus: bool) -> Features { - Features { - relay, - auto_switch, - clawde_plus, - } - } - - fn make_row( - tier: &str, - features: &str, - cached_at: &str, - valid_until: &str, - hmac: Option, - ) -> LicenseCacheRow { - LicenseCacheRow { - id: 1, - tier: tier.to_string(), - features: features.to_string(), - cached_at: cached_at.to_string(), - valid_until: valid_until.to_string(), - hmac, - } - } - - fn independent_hmac( - tier: &str, - features_json: &str, - cached_at: &str, - valid_until: &str, - ) -> String { - use sha2::Digest; - - let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); - let key = sha2::Sha256::digest(seed.as_bytes()).to_vec(); - let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length"); - mac.update(tier.as_bytes()); - mac.update(b"|"); - mac.update(features_json.as_bytes()); - mac.update(b"|"); - mac.update(cached_at.as_bytes()); - mac.update(b"|"); - mac.update(valid_until.as_bytes()); - mac.finalize() - .into_bytes() - .iter() - .map(|b| format!("{b:02x}")) - .collect() - } - - #[test] - fn license_info_free_has_exact_defaults() { - let info = LicenseInfo::free(); - - assert_eq!(info.tier, "free"); - assert!(!info.features.relay); - assert!(!info.features.auto_switch); - assert!(!info.features.clawde_plus); - assert_eq!(info.grace_days_remaining, None); - assert!(!info.is_relay_enabled()); - assert!(!info.is_auto_switch_enabled()); - assert!(!info.is_clawde_plus()); - } - - #[test] - fn license_feature_accessors_return_their_own_flags() { - let relay_only = LicenseInfo { - tier: "personal_remote".to_string(), - features: make_features(true, false, false), - grace_days_remaining: Some(2), - }; - assert!(relay_only.is_relay_enabled()); - assert!(!relay_only.is_auto_switch_enabled()); - assert!(!relay_only.is_clawde_plus()); - - let auto_switch_only = LicenseInfo { - tier: "cloud_pro".to_string(), - features: make_features(false, true, false), - grace_days_remaining: None, - }; - assert!(!auto_switch_only.is_relay_enabled()); - assert!(auto_switch_only.is_auto_switch_enabled()); - assert!(!auto_switch_only.is_clawde_plus()); - - let clawde_plus_only = LicenseInfo { - tier: "clawde_plus".to_string(), - features: make_features(false, false, true), - grace_days_remaining: None, - }; - assert!(!clawde_plus_only.is_relay_enabled()); - assert!(!clawde_plus_only.is_auto_switch_enabled()); - assert!(clawde_plus_only.is_clawde_plus()); - } - - #[test] - fn verify_response_deserializes_camel_case_and_exact_grace_days() { - let body = serde_json::json!({ - "tier": "cloud_pro", - "features": { - "relay": true, - "autoSwitch": true, - "clawdePlus": false - }, - "gracePeriod": { - "daysRemaining": 7 - } - }); - - let response: VerifyResponse = serde_json::from_value(body).unwrap(); - - assert_eq!(response.tier, "cloud_pro"); - assert!(response.features.relay); - assert!(response.features.auto_switch); - assert!(!response.features.clawde_plus); - let grace = response.grace_period.unwrap(); - assert_eq!(grace.days_remaining, 7); - } - - #[test] - fn features_deserialization_defaults_missing_clawde_plus_to_false() { - let body = serde_json::json!({ - "relay": true, - "autoSwitch": false - }); - - let features: Features = serde_json::from_value(body).unwrap(); - - assert!(features.relay); - assert!(!features.auto_switch); - assert!(!features.clawde_plus); - } - - #[test] - fn to_hex_uses_lowercase_and_zero_padding_for_each_byte() { - let bytes = [0x00, 0x01, 0x0a, 0x0f, 0x10, 0xab, 0xff]; - - assert_eq!(to_hex(&bytes), "00010a0f10abff"); - } - - #[test] - fn hmac_key_is_sha256_of_versioned_license_cache_seed() { - use sha2::Digest; - - let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); - let expected = sha2::Sha256::digest(seed.as_bytes()).to_vec(); - - assert_eq!(hmac_key(), expected); - } - - #[test] - fn compute_hmac_matches_exact_payload_contract() { - let tier = "cloud_pro"; - let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; - let cached_at = "2026-03-01T00:00:00+00:00"; - let valid_until = "2026-03-02T00:00:00+00:00"; - - let expected = independent_hmac(tier, features_json, cached_at, valid_until); - - assert_eq!( - compute_hmac(tier, features_json, cached_at, valid_until), - expected - ); - assert_ne!( - compute_hmac("free", features_json, cached_at, valid_until), - expected - ); - assert_ne!( - compute_hmac(tier, r#"{"relay":false}"#, cached_at, valid_until), - expected - ); - assert_ne!( - compute_hmac( - tier, - features_json, - "2026-03-01T00:00:01+00:00", - valid_until - ), - expected - ); - assert_ne!( - compute_hmac(tier, features_json, cached_at, "2026-03-02T00:00:01+00:00"), - expected - ); - } - - #[test] - fn verify_hmac_accepts_only_matching_cached_payload() { - let tier = "cloud_pro"; - let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; - let cached_at = "2026-03-01T00:00:00+00:00"; - let valid_until = "2026-03-02T00:00:00+00:00"; - let hmac = compute_hmac(tier, features_json, cached_at, valid_until); - - let valid = make_row( - tier, - features_json, - cached_at, - valid_until, - Some(hmac.clone()), - ); - assert!(verify_hmac(&valid)); - - let missing_hmac = make_row(tier, features_json, cached_at, valid_until, None); - assert!(!verify_hmac(&missing_hmac)); - - let wrong_hmac = make_row( - tier, - features_json, - cached_at, - valid_until, - Some(format!("0{}", &hmac[1..])), - ); - assert!(!verify_hmac(&wrong_hmac)); - - let changed_tier = make_row( - "free", - features_json, - cached_at, - valid_until, - Some(hmac.clone()), - ); - assert!(!verify_hmac(&changed_tier)); - - let changed_features = make_row( - tier, - r#"{"relay":false}"#, - cached_at, - valid_until, - Some(hmac.clone()), - ); - assert!(!verify_hmac(&changed_features)); - - let changed_cached_at = make_row( - tier, - features_json, - "2026-03-01T00:00:01+00:00", - valid_until, - Some(hmac.clone()), - ); - assert!(!verify_hmac(&changed_cached_at)); - - let changed_valid_until = make_row( - tier, - features_json, - cached_at, - "2026-03-02T00:00:01+00:00", - Some(hmac), - ); - assert!(!verify_hmac(&changed_valid_until)); - } -} +mod tests; diff --git a/apps/daemon/src/license/tests.rs b/apps/daemon/src/license/tests.rs new file mode 100644 index 0000000..df3c9a4 --- /dev/null +++ b/apps/daemon/src/license/tests.rs @@ -0,0 +1,441 @@ +//! Tests for the license cache and its grace window. +//! +//! Written against the surviving-mutant list from the mutation gate. This is +//! the code that decides whether a cached paid tier is still honoured while +//! the licence server is unreachable, so these mutations are not cosmetic: +//! they either hand out paid features from an expired cache, or revoke them +//! from a valid one. + +use super::*; + +async fn storage() -> (tempfile::TempDir, Storage) { + let dir = tempfile::tempdir().unwrap(); + let storage = Storage::new(dir.path()).await.expect("open storage"); + (dir, storage) +} + +fn paid() -> LicenseInfo { + LicenseInfo { + tier: "clawde_plus".to_string(), + features: Features::default(), + grace_days_remaining: None, + } +} + +/// Write a cache row directly, with a correct HMAC, at an arbitrary validity. +async fn seed_cache(storage: &Storage, tier: &str, valid_until: DateTime) { + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let cached_at = (valid_until - Duration::hours(24)).to_rfc3339(); + let valid_until_str = valid_until.to_rfc3339(); + let hmac = compute_hmac(tier, &features_json, &cached_at, &valid_until_str); + storage + .set_license_cache( + tier, + &features_json, + &cached_at, + &valid_until_str, + Some(&hmac), + ) + .await + .unwrap(); +} + +// ─── write_cache ───────────────────────────────────────────────────────────── + +/// Kills the `Ok(())` body replacement and the `now + Duration::hours(24)` +/// -> `-` mutation. +/// +/// The body replacement is the classic `async fn -> Result<()>` mutation: it +/// reports success without writing anything, so the only way to catch it is to +/// read the row back rather than trust the return value. Flipping the `+` to +/// `-` writes a row that expired 24 hours before it was created, making every +/// cached licence useless the instant it is stored. +#[tokio::test] +async fn write_cache_stores_a_row_valid_for_twenty_four_hours() { + let (_dir, storage) = storage().await; + write_cache(&storage, &paid()).await.unwrap(); + + let row = storage + .get_license_cache() + .await + .unwrap() + .expect("a row must actually have been written"); + assert_eq!(row.tier, "clawde_plus"); + + let valid_until = DateTime::parse_from_rfc3339(&row.valid_until) + .unwrap() + .with_timezone(&Utc); + let remaining = valid_until - Utc::now(); + assert!( + remaining > Duration::hours(23) && remaining <= Duration::hours(24), + "expected ~24h of validity, got {remaining}" + ); + + // The row must carry an HMAC that verifies, or read_cache_grace drops it. + assert!(verify_hmac(&row)); +} + +// ─── read_cache_grace ──────────────────────────────────────────────────────── + +/// Kills `Utc::now() < valid_until` -> `>`, `==` and constant `false`, plus +/// the `Default::default()` body replacement. +/// +/// A cache written moments ago is inside its window and must be honoured. +/// Every one of those mutations drops the paid tier on the floor and silently +/// downgrades a paying user to free while their licence is still valid. +#[tokio::test] +async fn a_cache_inside_the_grace_window_is_honoured() { + let (_dir, storage) = storage().await; + write_cache(&storage, &paid()).await.unwrap(); + + assert_eq!(read_cache_grace(&storage).await.tier, "clawde_plus"); +} + +/// Kills `Utc::now() < valid_until` -> constant `true` and -> `>`. +/// +/// This is the direction that matters for the paywall: once the grace window +/// has closed the cached tier must not be served. Both mutations keep +/// honouring a cache that expired a day ago, turning a 24-hour grace period +/// into an unbounded one. +#[tokio::test] +async fn a_cache_past_the_grace_window_is_refused() { + let (_dir, storage) = storage().await; + seed_cache(&storage, "clawde_plus", Utc::now() - Duration::hours(24)).await; + + assert_eq!( + read_cache_grace(&storage).await.tier, + "free", + "an expired cache must not keep granting paid features" + ); +} + +/// Kills the `delete !` mutation on `!verify_hmac(&row)`. +/// +/// A row whose HMAC does not match has been tampered with — someone editing +/// the local database to grant themselves a tier. Dropping the `!` inverts the +/// check, so exactly the forged rows become the trusted ones. +#[tokio::test] +async fn a_cache_row_with_a_bad_hmac_is_refused() { + let (_dir, storage) = storage().await; + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let valid_until = (Utc::now() + Duration::hours(24)).to_rfc3339(); + storage + .set_license_cache( + "clawde_plus", + &features_json, + &Utc::now().to_rfc3339(), + &valid_until, + Some("deadbeef"), + ) + .await + .unwrap(); + + assert_eq!( + read_cache_grace(&storage).await.tier, + "free", + "a forged cache row must not be trusted" + ); +} + +/// A row with no HMAC at all is treated the same as a forged one. +#[tokio::test] +async fn a_cache_row_with_no_hmac_is_refused() { + let (_dir, storage) = storage().await; + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let valid_until = (Utc::now() + Duration::hours(24)).to_rfc3339(); + storage + .set_license_cache( + "clawde_plus", + &features_json, + &Utc::now().to_rfc3339(), + &valid_until, + None, + ) + .await + .unwrap(); + + assert_eq!(read_cache_grace(&storage).await.tier, "free"); +} + +/// An unparseable expiry must fail closed, not open. +#[tokio::test] +async fn a_cache_row_with_an_unparseable_expiry_is_refused() { + let (_dir, storage) = storage().await; + let features_json = serde_json::to_string(&Features::default()).unwrap(); + let cached_at = Utc::now().to_rfc3339(); + let hmac = compute_hmac("clawde_plus", &features_json, &cached_at, "not-a-date"); + storage + .set_license_cache( + "clawde_plus", + &features_json, + &cached_at, + "not-a-date", + Some(&hmac), + ) + .await + .unwrap(); + + assert_eq!(read_cache_grace(&storage).await.tier, "free"); +} + +/// With no cached row at all the answer is the free tier. +#[tokio::test] +async fn an_empty_cache_yields_the_free_tier() { + let (_dir, storage) = storage().await; + assert_eq!(read_cache_grace(&storage).await.tier, "free"); +} + +// NOTE — equivalent mutant, deliberately not chased: `Utc::now() < valid_until` +// -> `<=`. The two differ only when the current instant equals the stored +// expiry to the nanosecond, which no test can arrange deterministically and no +// real run will hit. It is unkillable rather than uncovered. + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use crate::storage::LicenseCacheRow; + +fn make_features(relay: bool, auto_switch: bool, clawde_plus: bool) -> Features { + Features { + relay, + auto_switch, + clawde_plus, + } +} + +fn make_row( + tier: &str, + features: &str, + cached_at: &str, + valid_until: &str, + hmac: Option, +) -> LicenseCacheRow { + LicenseCacheRow { + id: 1, + tier: tier.to_string(), + features: features.to_string(), + cached_at: cached_at.to_string(), + valid_until: valid_until.to_string(), + hmac, + } +} + +fn independent_hmac(tier: &str, features_json: &str, cached_at: &str, valid_until: &str) -> String { + use sha2::Digest; + + let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); + let key = sha2::Sha256::digest(seed.as_bytes()).to_vec(); + let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length"); + mac.update(tier.as_bytes()); + mac.update(b"|"); + mac.update(features_json.as_bytes()); + mac.update(b"|"); + mac.update(cached_at.as_bytes()); + mac.update(b"|"); + mac.update(valid_until.as_bytes()); + mac.finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +#[test] +fn license_info_free_has_exact_defaults() { + let info = LicenseInfo::free(); + + assert_eq!(info.tier, "free"); + assert!(!info.features.relay); + assert!(!info.features.auto_switch); + assert!(!info.features.clawde_plus); + assert_eq!(info.grace_days_remaining, None); + assert!(!info.is_relay_enabled()); + assert!(!info.is_auto_switch_enabled()); + assert!(!info.is_clawde_plus()); +} + +#[test] +fn license_feature_accessors_return_their_own_flags() { + let relay_only = LicenseInfo { + tier: "personal_remote".to_string(), + features: make_features(true, false, false), + grace_days_remaining: Some(2), + }; + assert!(relay_only.is_relay_enabled()); + assert!(!relay_only.is_auto_switch_enabled()); + assert!(!relay_only.is_clawde_plus()); + + let auto_switch_only = LicenseInfo { + tier: "cloud_pro".to_string(), + features: make_features(false, true, false), + grace_days_remaining: None, + }; + assert!(!auto_switch_only.is_relay_enabled()); + assert!(auto_switch_only.is_auto_switch_enabled()); + assert!(!auto_switch_only.is_clawde_plus()); + + let clawde_plus_only = LicenseInfo { + tier: "clawde_plus".to_string(), + features: make_features(false, false, true), + grace_days_remaining: None, + }; + assert!(!clawde_plus_only.is_relay_enabled()); + assert!(!clawde_plus_only.is_auto_switch_enabled()); + assert!(clawde_plus_only.is_clawde_plus()); +} + +#[test] +fn verify_response_deserializes_camel_case_and_exact_grace_days() { + let body = serde_json::json!({ + "tier": "cloud_pro", + "features": { + "relay": true, + "autoSwitch": true, + "clawdePlus": false + }, + "gracePeriod": { + "daysRemaining": 7 + } + }); + + let response: VerifyResponse = serde_json::from_value(body).unwrap(); + + assert_eq!(response.tier, "cloud_pro"); + assert!(response.features.relay); + assert!(response.features.auto_switch); + assert!(!response.features.clawde_plus); + let grace = response.grace_period.unwrap(); + assert_eq!(grace.days_remaining, 7); +} + +#[test] +fn features_deserialization_defaults_missing_clawde_plus_to_false() { + let body = serde_json::json!({ + "relay": true, + "autoSwitch": false + }); + + let features: Features = serde_json::from_value(body).unwrap(); + + assert!(features.relay); + assert!(!features.auto_switch); + assert!(!features.clawde_plus); +} + +#[test] +fn to_hex_uses_lowercase_and_zero_padding_for_each_byte() { + let bytes = [0x00, 0x01, 0x0a, 0x0f, 0x10, 0xab, 0xff]; + + assert_eq!(to_hex(&bytes), "00010a0f10abff"); +} + +#[test] +fn hmac_key_is_sha256_of_versioned_license_cache_seed() { + use sha2::Digest; + + let seed = format!("clawd-license-cache-{}", env!("CARGO_PKG_VERSION")); + let expected = sha2::Sha256::digest(seed.as_bytes()).to_vec(); + + assert_eq!(hmac_key(), expected); +} + +#[test] +fn compute_hmac_matches_exact_payload_contract() { + let tier = "cloud_pro"; + let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; + let cached_at = "2026-03-01T00:00:00+00:00"; + let valid_until = "2026-03-02T00:00:00+00:00"; + + let expected = independent_hmac(tier, features_json, cached_at, valid_until); + + assert_eq!( + compute_hmac(tier, features_json, cached_at, valid_until), + expected + ); + assert_ne!( + compute_hmac("free", features_json, cached_at, valid_until), + expected + ); + assert_ne!( + compute_hmac(tier, r#"{"relay":false}"#, cached_at, valid_until), + expected + ); + assert_ne!( + compute_hmac( + tier, + features_json, + "2026-03-01T00:00:01+00:00", + valid_until + ), + expected + ); + assert_ne!( + compute_hmac(tier, features_json, cached_at, "2026-03-02T00:00:01+00:00"), + expected + ); +} + +#[test] +fn verify_hmac_accepts_only_matching_cached_payload() { + let tier = "cloud_pro"; + let features_json = r#"{"relay":true,"autoSwitch":false,"clawdePlus":true}"#; + let cached_at = "2026-03-01T00:00:00+00:00"; + let valid_until = "2026-03-02T00:00:00+00:00"; + let hmac = compute_hmac(tier, features_json, cached_at, valid_until); + + let valid = make_row( + tier, + features_json, + cached_at, + valid_until, + Some(hmac.clone()), + ); + assert!(verify_hmac(&valid)); + + let missing_hmac = make_row(tier, features_json, cached_at, valid_until, None); + assert!(!verify_hmac(&missing_hmac)); + + let wrong_hmac = make_row( + tier, + features_json, + cached_at, + valid_until, + Some(format!("0{}", &hmac[1..])), + ); + assert!(!verify_hmac(&wrong_hmac)); + + let changed_tier = make_row( + "free", + features_json, + cached_at, + valid_until, + Some(hmac.clone()), + ); + assert!(!verify_hmac(&changed_tier)); + + let changed_features = make_row( + tier, + r#"{"relay":false}"#, + cached_at, + valid_until, + Some(hmac.clone()), + ); + assert!(!verify_hmac(&changed_features)); + + let changed_cached_at = make_row( + tier, + features_json, + "2026-03-01T00:00:01+00:00", + valid_until, + Some(hmac.clone()), + ); + assert!(!verify_hmac(&changed_cached_at)); + + let changed_valid_until = make_row( + tier, + features_json, + cached_at, + "2026-03-02T00:00:01+00:00", + Some(hmac), + ); + assert!(!verify_hmac(&changed_valid_until)); +} diff --git a/apps/daemon/src/policy/secrets.rs b/apps/daemon/src/policy/secrets.rs index 9209a9f..9d3ed27 100644 --- a/apps/daemon/src/policy/secrets.rs +++ b/apps/daemon/src/policy/secrets.rs @@ -120,43 +120,6 @@ fn check_string(_tool: &str, s: &str, path: &str) -> Result<(), PolicyViolation> Ok(()) } +// Tests live in tests.rs. #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn clean_args_pass() { - let args = json!({ "path": "src/main.rs", "content": "fn main() {}" }); - assert!(check_tool_args("read_file", &args).is_ok()); - } - - #[test] - fn openai_key_in_args_blocked() { - let args = json!({ "key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" }); - let result = check_tool_args("apply_patch", &args); - assert!(result.is_err()); - assert!(matches!( - result, - Err(PolicyViolation::SecretDetected { .. }) - )); - } - - #[test] - fn nested_secret_blocked() { - let args = json!({ - "config": { - "api_key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" - } - }); - let result = check_tool_args("apply_patch", &args); - assert!(result.is_err()); - } - - #[test] - fn aws_key_blocked() { - let args = json!({ "credentials": "AKIAIOSFODNN7EXAMPLE1234" }); - let result = check_tool_args("run_tests", &args); - assert!(result.is_err()); - } -} +mod tests; diff --git a/apps/daemon/src/policy/secrets/tests.rs b/apps/daemon/src/policy/secrets/tests.rs new file mode 100644 index 0000000..08f0d99 --- /dev/null +++ b/apps/daemon/src/policy/secrets/tests.rs @@ -0,0 +1,183 @@ +//! Tests for the tool-argument secret scanner. +//! +//! Written against the surviving-mutant list from the mutation gate. The +//! high-entropy branch needs care: the threshold is 4.5 bits, and a +//! 20-character string carries at most log2(20) = 4.32 bits even when every +//! character is distinct. So the shortest string that can possibly trip that +//! branch is 23 characters. The fixtures below use 32 distinct characters +//! (exactly 5.0 bits) with padding sized to move the measurement across the +//! threshold in a controlled direction. + +use super::*; + +/// 32 distinct alphanumerics — 5.0 bits of entropy, over the 4.5 threshold, +/// and matching none of the six NEVER_EXPOSE_PATTERNS. +const HIGH_ENTROPY: &str = "abcdefghijklmnopqrstuvwxyz012345"; + +fn detected(v: serde_json::Value) -> bool { + check_tool_args("bash", &v).is_err() +} + +// ─── the high-entropy branch ───────────────────────────────────────────────── + +/// Kills `token.len() >= 20` -> `<`. +/// +/// `is_high_entropy` returns false below 20 characters, so `len < 20 && ...` +/// can never be true and the entropy branch stops firing entirely — every +/// credential that is not one of the six literal patterns walks through. +#[test] +fn a_bare_high_entropy_token_is_detected() { + assert!(detected(serde_json::json!({ "arg": HIGH_ENTROPY }))); +} + +/// Kills `>= 20 && is_high_entropy(..)` -> `||`. +/// +/// With `||`, any token of 20+ characters is flagged regardless of entropy, so +/// every long ordinary word becomes a false positive and legitimate tool calls +/// start getting rejected. +#[test] +fn a_long_low_entropy_token_is_not_detected() { + assert!(!detected(serde_json::json!({ "arg": "a".repeat(30) }))); + assert!(!detected( + serde_json::json!({ "arg": "aaaabbbbccccddddeeeeffffgggg" }) + )); +} + +// ─── the trim rule ─────────────────────────────────────────────────────────── + +/// Kills four mutations of the `trim_matches` predicate at once: the +/// `delete !`, both `!=` -> `==` comparisons, and the first `&&` -> `||`. +/// +/// The real predicate trims surrounding punctuation while keeping the base64 +/// alphabet. Wrapped in 40 dots either side, the untrimmed word measures 2.29 +/// bits and would be waved through; trimmed, it is the 5.0-bit token it +/// actually contains. +/// +/// * dropping the `!` trims alphanumerics instead, so the scan stops at the +/// first dot and the token stays padded; +/// * `c == '+'` or `c == '/'` narrows the trim to that one character, so the +/// dots are never removed; +/// * the first `&&` -> `||` makes the predicate true for alphanumerics too, so +/// the token is trimmed away to nothing. +/// +/// All four leave a real credential sitting in the tool arguments. +#[test] +fn a_token_buried_in_punctuation_is_still_detected() { + let padded = format!("{}{}{}", ".".repeat(40), HIGH_ENTROPY, ".".repeat(40)); + assert!(detected(serde_json::json!({ "arg": padded }))); +} + +/// Kills the second `&&` -> `||` in the same predicate. +/// +/// `+` and `/` are deliberately KEPT by the trim: they are part of the base64 +/// alphabet and belong to the token rather than around it. Sixteen leading `+` +/// therefore count towards the measurement and pull it to 4.25 bits, below the +/// threshold. The mutation trims them off, leaving the bare 5.0-bit token, and +/// the scan fires. +/// +/// So this asserts a NEGATIVE, and pins the documented trim rule rather than +/// an ideal outcome. A padded token slipping under the entropy threshold is a +/// real limitation of the heuristic; this test records it, it does not bless +/// it. +#[test] +fn the_base64_alphabet_is_kept_by_the_trim() { + let plus_padded = format!("{}{}", "+".repeat(16), HIGH_ENTROPY); + assert!(!detected(serde_json::json!({ "arg": plus_padded }))); +} + +// ─── recursion into containers ─────────────────────────────────────────────── + +/// Kills the deletion of the `Value::Array` match arm — with it gone, arrays +/// fall through to `_ => {}` and anything inside one is never scanned. +#[test] +fn secrets_inside_arrays_are_detected() { + assert!(detected( + serde_json::json!({ "args": ["ok", HIGH_ENTROPY] }) + )); + // Nested deeper, so the arm must recurse rather than peek one level. + assert!(detected( + serde_json::json!([["ok"], [{ "k": HIGH_ENTROPY }]]) + )); +} + +#[test] +fn secrets_inside_nested_objects_are_detected() { + assert!(detected( + serde_json::json!({ "outer": { "inner": HIGH_ENTROPY } }) + )); +} + +/// The negative direction for the container walk: ordinary structured +/// arguments must pass untouched. +#[test] +fn clean_arguments_of_every_shape_are_allowed() { + assert!(!detected(serde_json::json!({ "command": "cargo test" }))); + assert!(!detected(serde_json::json!({ "files": ["a.rs", "b.rs"] }))); + assert!(!detected( + serde_json::json!({ "n": 42, "ok": true, "x": null }) + )); + assert!(!detected(serde_json::json!({}))); +} + +// ─── the pattern list ──────────────────────────────────────────────────────── + +/// Each NEVER_EXPOSE pattern is pinned separately, so none can be dropped from +/// the list unnoticed. +#[test] +fn every_never_expose_pattern_is_matched() { + let ghp = format!("ghp_{}", "A".repeat(36)); + let pat = format!("github_pat_{}", "A".repeat(82)); + let cases = [ + "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAA", + ghp.as_str(), + pat.as_str(), + "AKIAIOSFODNN7EXAMPLE", + "-----BEGIN RSA PRIVATE KEY-----", + "password: hunter2hunter2", + ]; + for c in cases { + assert!( + detected(serde_json::json!({ "arg": c })), + "should have detected {c:?}" + ); + } +} + +// ─── pre-existing tests, kept verbatim ────────────────────────────────────── + +use serde_json::json; + +#[test] +fn clean_args_pass() { + let args = json!({ "path": "src/main.rs", "content": "fn main() {}" }); + assert!(check_tool_args("read_file", &args).is_ok()); +} + +#[test] +fn openai_key_in_args_blocked() { + let args = json!({ "key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" }); + let result = check_tool_args("apply_patch", &args); + assert!(result.is_err()); + assert!(matches!( + result, + Err(PolicyViolation::SecretDetected { .. }) + )); +} + +#[test] +fn nested_secret_blocked() { + let args = json!({ + "config": { + "api_key": "sk-abcdefghijklmnopqrstuvwxyz1234567890" + } + }); + let result = check_tool_args("apply_patch", &args); + assert!(result.is_err()); +} + +#[test] +fn aws_key_blocked() { + let args = json!({ "credentials": "AKIAIOSFODNN7EXAMPLE1234" }); + let result = check_tool_args("run_tests", &args); + assert!(result.is_err()); +}