diff --git a/crates/thumos/src/kardia.rs b/crates/thumos/src/kardia.rs index 6a1e46ca..2af4b67c 100644 --- a/crates/thumos/src/kardia.rs +++ b/crates/thumos/src/kardia.rs @@ -53,7 +53,7 @@ use crate::screen_privacy::PrivacyScreen; use crate::screen_radio::RadioControlScreen; use crate::screen_search::SearchScreen; use crate::screen_settings::SettingsMenuScreen; -use crate::screen_threat::{ThreatLevel, ThreatMonitor}; +use crate::screen_threat::ThreatMonitor; // WHY(#737): the alert constructors are reachable only from the qemu boot // smoke; production has no detector feeding this screen yet (see the // no-detector-vs-no-alerts gap filed alongside this change). @@ -531,13 +531,13 @@ impl KernelState { mode_badge: Some(self.mode.status_badge()), mode_badge_color: Some(self.mode.status_badge_color()), // #874: CCCI boot-path availability is not a threat level or a - // modem-rail observation. Only an online detector's High/Critical - // state drives the threat indicator. - threat_high: self.threat.detector_online() - && matches!( - self.threat.threat_level(), - ThreatLevel::High | ThreatLevel::Critical - ), + // modem-rail observation. The rule lives in screen_threat, where a + // cross-product test guards it -- inline here it was a correct + // expression nothing could catch reverting. + threat_high: crate::screen_threat::threat_indicator( + self.threat.detector_online(), + self.threat.threat_level(), + ), ..StatusBarState::default() }; self.home.update_state(HomeScreenState { diff --git a/crates/thumos/src/screen_threat.rs b/crates/thumos/src/screen_threat.rs index ca91a65e..a9df3c93 100644 --- a/crates/thumos/src/screen_threat.rs +++ b/crates/thumos/src/screen_threat.rs @@ -121,6 +121,23 @@ const COLOR_ORANGE: u16 = color::from_rgb(255, 165, 0); /// below; semantics stay in one place. pub use sema_core::ThreatLevel; +/// Whether the status bar should show the threat indicator (#874). +/// +/// Two inputs, and both matter. An OFFLINE detector has no opinion, so its +/// last-known level is stale rather than reassuring or alarming -- the +/// indicator must be dark, not stuck at whatever it read before it stopped. +/// An ONLINE detector's High/Critical band is the only thing that lights it. +/// +/// WHY this is a function rather than the expression it replaced: the badge +/// previously derived from CCCI boot-path absence, which is a boot artefact +/// and not a threat observation at all. Correcting that in place left a fix +/// nothing guarded -- reverting the expression would have passed the whole +/// suite and the boot witness, because neither asserted on it. A named +/// function with a cross-product test cannot regress silently. +pub(crate) const fn threat_indicator(detector_online: bool, level: ThreatLevel) -> bool { + detector_online && matches!(level, ThreatLevel::High | ThreatLevel::Critical) +} + /// Screen-side presentation for the canonical [`ThreatLevel`]. pub(crate) trait ThreatLevelScreenExt { /// RGB565 color for this threat level. @@ -873,6 +890,28 @@ impl Screen for ThreatMonitor { #[cfg(test)] mod tests { + #[test] + fn the_threat_indicator_lights_only_for_an_online_high_or_critical_detector() { + // The full cross product, not the cases someone remembered. The + // offline row is the one that matters: a detector that has stopped + // reporting must not leave the badge asserting whatever it last saw. + for level in [ + ThreatLevel::Low, + ThreatLevel::Medium, + ThreatLevel::High, + ThreatLevel::Critical, + ] { + assert!( + !threat_indicator(false, level), + "an offline detector must never light the indicator, including at {level}" + ); + } + assert!(!threat_indicator(true, ThreatLevel::Low)); + assert!(!threat_indicator(true, ThreatLevel::Medium)); + assert!(threat_indicator(true, ThreatLevel::High)); + assert!(threat_indicator(true, ThreatLevel::Critical)); + } + use alloc::string::ToString; use super::*; diff --git a/crates/thumos/src/security_mode.rs b/crates/thumos/src/security_mode.rs index b7bebc5f..beb2ec62 100644 --- a/crates/thumos/src/security_mode.rs +++ b/crates/thumos/src/security_mode.rs @@ -788,45 +788,116 @@ pub enum ThreatResponse { FirewallRestricted, /// A modem-cut request was recorded; no PMIC transaction was attempted. ModemCutRequested, + /// The score crossed the threshold and nothing was actuated, because + /// policy forbids acting on it. Carries which policy refused. + Advisory(AdvisoryReason), /// No action needed (threat below threshold). None, } +/// Why a threshold crossing produced advice instead of an action (#874). +/// +/// These are different states and a caller must be able to tell them apart: +/// one says the detector cannot be believed yet, the other says it can and +/// nobody has agreed to let it cut a radio. Collapsing them would make the +/// eventual arrival of calibration look like nothing changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use] +#[non_exhaustive] +pub enum AdvisoryReason { + /// The score came from an uncalibrated detector, so it names a band and + /// not a validated severity. + Uncalibrated, + /// The detector is calibrated, but no operator-accepted authorization + /// covers a radio-affecting action. + NotAuthorized, +} + +impl fmt::Display for AdvisoryReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Uncalibrated => write!(f, "detector uncalibrated"), + Self::NotAuthorized => write!(f, "no operator authorization"), + } + } +} + impl fmt::Display for ThreatResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::FirewallRestricted => write!(f, "firewall restricted"), Self::ModemCutRequested => write!(f, "modem cut requested (unapplied)"), + Self::Advisory(reason) => write!(f, "advisory only ({reason})"), Self::None => write!(f, "none"), } } } -/// Legacy uncalibrated threat-score response path. +/// Whether an operator has accepted that a detector may drive a radio-affecting +/// action (#874). /// -/// - Score >= `critical_threshold`: records an unapplied modem-cut request. -/// - Sentinel mode active: restrict firewall to Sentinel whitelist. -/// - Below threshold: no action. +/// There is deliberately no `Default` and no constructor that derives this from +/// a score: the whole failure this prevents is a detector authorizing itself. +/// `OperatorAccepted` exists so the plumbing is written and testable; no +/// production path can produce one today, because no operator-acceptance +/// mechanism exists yet. When one lands it constructs this, and nothing else +/// needs to change to let the action through. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[must_use] +pub(crate) enum RadioCutAuthorization { + /// No authorization. Radio-affecting actions are refused. + Withheld, + /// An operator accepted this class of action. + OperatorAccepted, +} + +/// Decide what a threat score may do. +/// +/// Two gates stand between a score and any radio-affecting action, and both +/// must pass. `sema_core::Calibration`'s own doc states the first as canon: +/// "Any future automatic response must match on `Calibrated` before it may +/// act." An uncalibrated score names a band, not a validated severity -- the +/// bands are provisional score ranges (#555), so a threshold crossing is a +/// statement about arithmetic and not about the world. +/// +/// The second gate is authorization. A calibrated detector that is right most +/// of the time is still not a thing that gets to disconnect someone's phone on +/// its own; that is an operator's decision, and [`RadioCutAuthorization`] is +/// where it enters. Both refusals return [`ThreatResponse::Advisory`] with the +/// reason, because "not believable yet" and "believable but unauthorized" are +/// different states that will resolve at different times. +/// +/// Sentinel-mode firewall restriction is NOT gated: it follows the operating +/// mode the operator selected rather than a detector score, restricts rather +/// than severs, and is reversible by leaving the mode. /// -/// This compiled path is not a safe accepted policy: #874 owns removing or -/// redesigning the automatic action. #862 keeps the request fail-closed until -/// a source-grounded PMIC transaction exists. It is not evidence that any -/// threshold is calibrated or any modem rail changed. Returns the policy -/// request for audit logging. +/// Returns the outcome for audit logging. It is not evidence that any modem +/// rail changed -- #862 keeps the recorded request unapplied until a +/// source-grounded PMIC transaction exists. pub(crate) fn evaluate_threat( mode: SecurityMode, threat_score: u32, critical_threshold: u32, + calibration: &sema_core::Calibration, + authorization: RadioCutAuthorization, firewall: &mut crate::ccci_logger::CcciFirewall, power_manager: &mut PowerManager, ) -> ThreatResponse { use crate::ccci_logger::FirewallMode; - // FIXME(#874): an uncalibrated score must not autonomously request a cut. if threat_score >= critical_threshold { + // WHY the calibration check precedes the authorization check: an + // operator who authorized action on a calibrated detector did not + // thereby authorize action on an uncalibrated one, and reporting + // NotAuthorized for an uncalibrated score would name the wrong + // missing thing. + if matches!(calibration, sema_core::Calibration::Uncalibrated) { + return ThreatResponse::Advisory(AdvisoryReason::Uncalibrated); + } + if authorization != RadioCutAuthorization::OperatorAccepted { + return ThreatResponse::Advisory(AdvisoryReason::NotAuthorized); + } firewall.apply_mode(FirewallMode::Panic); - // FIXME(#874): remove this automatic policy path before any M7 - // execution. #862 keeps the recorded request unapplied meanwhile. power_manager.request_modem_power_cut(); return ThreatResponse::ModemCutRequested; } @@ -1475,8 +1546,23 @@ mod tests { // Threat response tests (Phase 10 Wave 3) // ----------------------------------------------------------------------- + /// A calibrated detector fixture. The values name a corpus rather than + /// describing one -- what is under test is the GATE, and the gate matches + /// on the variant. + fn calibrated() -> sema_core::Calibration { + use alloc::string::ToString as _; + sema_core::Calibration::Calibrated { + corpus: "test-corpus-v1".to_string(), + operating_point: "test-operating-point".to_string(), + error_budget_per_mille: (10, 20), + } + } + #[test] - fn evaluate_threat_critical_records_unapplied_cut_request() { + fn an_uncalibrated_score_is_advisory_however_high_it_is() { + // The canon is sema_core::Calibration's own doc: "Any future automatic + // response must match on Calibrated before it may act." A band edge is + // a statement about arithmetic until a corpus says what it separates. use crate::ccci_logger::{CcciFirewall, FirewallMode}; let mut fw = CcciFirewall::new(FirewallMode::Daily); @@ -1485,30 +1571,137 @@ mod tests { let response = evaluate_threat( SecurityMode::Daily, - 100, // score - 80, // threshold + u32::MAX, + 80, + &sema_core::Calibration::Uncalibrated, + RadioCutAuthorization::OperatorAccepted, &mut fw, &mut pm, ); assert_eq!( response, - ThreatResponse::ModemCutRequested, - "legacy critical branch currently records a modem-cut request (#874)" + ThreatResponse::Advisory(AdvisoryReason::Uncalibrated), + "an uncalibrated score is advice, even at the maximum and even with \ + an operator authorization standing" + ); + assert_eq!( + fw.mode(), + FirewallMode::Daily, + "an uncalibrated score must not drive the firewall either" + ); + assert!( + !pm.is_modem_cut_requested(), + "no cut request may be recorded from an uncalibrated score" + ); + } + + #[test] + fn a_calibrated_score_without_authorization_is_advisory() { + // The second gate, and the one that outlives calibration: a detector + // that is right most of the time still does not get to disconnect + // someone's phone by itself. + use crate::ccci_logger::{CcciFirewall, FirewallMode}; + + let mut fw = CcciFirewall::new(FirewallMode::Daily); + let mut pm = PowerManager::new(); + pm.apply_mode(crate::power::PowerMode::Full); + + let response = evaluate_threat( + SecurityMode::Daily, + 100, + 80, + &calibrated(), + RadioCutAuthorization::Withheld, + &mut fw, + &mut pm, ); + + assert_eq!( + response, + ThreatResponse::Advisory(AdvisoryReason::NotAuthorized), + "a believable score still needs an operator to have accepted the action" + ); + assert_eq!(fw.mode(), FirewallMode::Daily); + assert!(!pm.is_modem_cut_requested()); + } + + #[test] + fn the_two_refusals_are_distinguishable() { + // They resolve at different times -- calibration arrives from an + // evaluation harness, authorization from an operator -- so a caller + // that could not tell them apart would report no change when the first + // one landed. + use crate::ccci_logger::{CcciFirewall, FirewallMode}; + + let mut fw = CcciFirewall::new(FirewallMode::Daily); + let mut pm = PowerManager::new(); + + let uncalibrated = evaluate_threat( + SecurityMode::Daily, + 100, + 80, + &sema_core::Calibration::Uncalibrated, + RadioCutAuthorization::Withheld, + &mut fw, + &mut pm, + ); + let unauthorized = evaluate_threat( + SecurityMode::Daily, + 100, + 80, + &calibrated(), + RadioCutAuthorization::Withheld, + &mut fw, + &mut pm, + ); + + assert_ne!(uncalibrated, unauthorized); + assert_eq!( + uncalibrated, + ThreatResponse::Advisory(AdvisoryReason::Uncalibrated), + "an uncalibrated score names the missing calibration, not the \ + missing authorization -- an operator who authorized action on a \ + calibrated detector did not authorize it on this one" + ); + } + + #[test] + fn a_calibrated_and_authorized_critical_score_records_an_unapplied_cut_request() { + use crate::ccci_logger::{CcciFirewall, FirewallMode}; + + let mut fw = CcciFirewall::new(FirewallMode::Daily); + let mut pm = PowerManager::new(); + pm.apply_mode(crate::power::PowerMode::Full); + + let response = evaluate_threat( + SecurityMode::Daily, + 100, // score + 80, // threshold + &calibrated(), + RadioCutAuthorization::OperatorAccepted, + &mut fw, + &mut pm, + ); + + assert_eq!(response, ThreatResponse::ModemCutRequested); assert_eq!( fw.mode(), FirewallMode::Panic, - "firewall must switch to Panic on critical" + "firewall must switch to Panic on an authorized critical score" ); assert!( pm.is_modem_cut_requested(), - "legacy branch records the sticky cut-request marker, not PMIC readback" + "the branch records the sticky cut-request marker, not PMIC readback" ); } #[test] fn evaluate_threat_sentinel_restricts_firewall() { + // Sentinel restriction is NOT gated: it follows the operating mode the + // operator selected, restricts rather than severs, and is reversible by + // leaving the mode. An uncalibrated detector is passed here on purpose + // to pin that the gates cover radio-affecting action, not every effect. use crate::ccci_logger::{CcciFirewall, FirewallMode}; let mut fw = CcciFirewall::new(FirewallMode::Daily); @@ -1518,6 +1711,8 @@ mod tests { SecurityMode::Sentinel, 10, // score below threshold 80, + &sema_core::Calibration::Uncalibrated, + RadioCutAuthorization::Withheld, &mut fw, &mut pm, ); @@ -1541,12 +1736,20 @@ mod tests { let mut fw = CcciFirewall::new(FirewallMode::Daily); let mut pm = PowerManager::new(); - let response = evaluate_threat(SecurityMode::Daily, 10, 80, &mut fw, &mut pm); + let response = evaluate_threat( + SecurityMode::Daily, + 10, + 80, + &calibrated(), + RadioCutAuthorization::OperatorAccepted, + &mut fw, + &mut pm, + ); assert_eq!( response, ThreatResponse::None, - "below threshold in Daily must take no action" + "below threshold in Daily must take no action even when both gates pass" ); assert_eq!( fw.mode(), @@ -1571,6 +1774,8 @@ mod tests { SecurityMode::Sentinel, 80, // score == threshold 80, // threshold + &calibrated(), + RadioCutAuthorization::OperatorAccepted, &mut fw, &mut pm, ); @@ -1587,7 +1792,7 @@ mod tests { ); assert!( pm.is_modem_cut_requested(), - "legacy path records an unapplied modem-cut request at the exact threshold" + "an authorized path records an unapplied modem-cut request at the exact threshold" ); } diff --git a/docs/target-test-ledger.toml b/docs/target-test-ledger.toml index 3ed6623a..761fac38 100644 --- a/docs/target-test-ledger.toml +++ b/docs/target-test-ledger.toml @@ -573,7 +573,7 @@ mechanism = "host" [[module]] name = "screen_threat" -tests = 23 +tests = 24 mechanism = "host" [[module]] @@ -601,7 +601,7 @@ notes = "The Argon2id block matrix comes from a host allocation under cfg(test): [[module]] name = "security_mode" -tests = 36 +tests = 39 mechanism = "both" witness = "boot.sh" fidelity = "Policy transitions are host/QEMU-covered, but #874 blocks automatic modem-cut semantics and requested/applied/observed conflation; #881 owns the absent physical side-button/PTT producer and safe gesture policy"