From cfb1282830ab0c7aa6cc38aecffb0f76432e5804 Mon Sep 17 00:00:00 2001 From: forkwright Date: Fri, 21 Aug 2026 05:59:19 -0500 Subject: [PATCH] fix(security): gate radio-affecting threat action on calibration and authorization evaluate_threat compared a caller-supplied score to a caller-supplied threshold and, above it, drove the firewall into Panic and recorded a modem power-cut request. Two FIXME(#874) markers sat on those exact lines. sema_core::Calibration's own doc already states the rule this violated: "Any future automatic response must match on Calibrated before it may act." The threat bands are provisional score ranges (#555), so a threshold crossing on an uncalibrated detector is a statement about arithmetic, not about the world. It now returns Advisory(Uncalibrated) and touches neither the firewall nor the power manager. A second gate outlives calibration. A detector that is right most of the time still does not get to disconnect someone's phone on its own; that is an operator's decision, and RadioCutAuthorization is where it enters. There is deliberately no Default and no constructor deriving it from a score -- the failure being prevented is a detector authorizing itself. No production path can build OperatorAccepted today because no operator-acceptance mechanism exists; when one lands it constructs this and nothing else changes. The two refusals are separate variants because they resolve at different times and from different sources. Collapsing them would make the eventual arrival of calibration look like nothing had changed. The calibration check runs FIRST: an operator who authorized action on a calibrated detector did not thereby authorize it on an uncalibrated one, so reporting NotAuthorized there would name the wrong missing thing. Sentinel firewall restriction stays ungated. It follows the operating mode the operator selected rather than a detector score, restricts rather than severs, and is reversible by leaving the mode. A test passes an uncalibrated detector through that path on purpose, to pin that the gates cover radio-affecting action and not every effect. Separately, the kardia threat-indicator derivation had no regression coverage, and that was the actual defect rather than the expression. It had been corrected from a CCCI boot-path artefact to detector_online x threat_level, but nothing guarded the correction: reverting it would have passed the whole suite AND the boot witness, since neither asserts on threat_high. Per VERIFICATION.md a claim without a failing fixture cannot be passed, so this extracts screen_threat::threat_indicator and covers the full cross product. The offline row is the one that matters -- a detector that has stopped reporting must not leave the badge asserting whatever it last saw. This does not close #874: source, uncertainty, freshness and evidence provenance on detector output, and the spoofed/stale/replayed evidence coverage, remain its open work. What lands here is the authorization gap that gates #862's callers. --- crates/thumos/src/kardia.rs | 16 +- crates/thumos/src/screen_threat.rs | 39 +++++ crates/thumos/src/security_mode.rs | 249 ++++++++++++++++++++++++++--- docs/target-test-ledger.toml | 4 +- 4 files changed, 276 insertions(+), 32 deletions(-) diff --git a/crates/thumos/src/kardia.rs b/crates/thumos/src/kardia.rs index 12fac7de..07f5bef1 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 119e2867..04f4867b 100644 --- a/crates/thumos/src/security_mode.rs +++ b/crates/thumos/src/security_mode.rs @@ -764,45 +764,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; } @@ -1451,8 +1522,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); @@ -1461,30 +1547,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); @@ -1494,6 +1687,8 @@ mod tests { SecurityMode::Sentinel, 10, // score below threshold 80, + &sema_core::Calibration::Uncalibrated, + RadioCutAuthorization::Withheld, &mut fw, &mut pm, ); @@ -1517,12 +1712,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(), @@ -1547,6 +1750,8 @@ mod tests { SecurityMode::Sentinel, 80, // score == threshold 80, // threshold + &calibrated(), + RadioCutAuthorization::OperatorAccepted, &mut fw, &mut pm, ); @@ -1563,7 +1768,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 8fccd891..354f97e0 100644 --- a/docs/target-test-ledger.toml +++ b/docs/target-test-ledger.toml @@ -561,7 +561,7 @@ mechanism = "host" [[module]] name = "screen_threat" -tests = 23 +tests = 24 mechanism = "host" [[module]] @@ -588,7 +588,7 @@ mechanism = "host" [[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"