Skip to main content

cleanlib_client/
customer_state.rs

1//! CLEANLIB-178 — canonical customer-facing state taxonomy (Rust surfaces).
2//!
3//! Single source of truth for the **8 customer states** that the malicious-vs-
4//! vulnerable cluster (CLEANLIB-176 wire-shape + 177 policy overlay + 178
5//! aesthetic) surfaces. The design contract lives at
6//! `decisions/2026-06-12-cleanlib-178-state-aesthetic-mapping.md`; this module
7//! is its executable form for the Rust render surfaces — `cleanlib-cli`,
8//! `cleanlib-lsp` (CLEANLIB-209), and any consumer of `cleanlib-client`. The
9//! TS extension / Python MCP / JS+Go+Py SDKs mirror this table in their own
10//! languages, each with a contract test asserting the same wire→state map
11//! (anti-drift, sister of the silent-schema-drift discipline).
12//!
13//! ## Why this lives in `cleanlib-client`, not `cleanlib-core`
14//!
15//! `cleanlib-core` is `publish = false`; the customer-state taxonomy is a
16//! *presentation* concern consumed only by published render surfaces (the App
17//! emits the wire `source`; it never renders a customer state). Keying on the
18//! wire string (`Verdict.source: String`) — rather than `cleanlib-core`'s
19//! `VerdictSource` enum — keeps this crate publishable to crates.io and avoids
20//! a render-side dependency on the service-internal core crate.
21//!
22//! ## Design rules baked in (§0 of the spec — non-negotiable)
23//!
24//! 1. **Customer-language only** — wire/engine codenames never appear in
25//!    `label()` / `copy()`.
26//! 2. **Soft severity tokens** (dark-theme-safe); brand cyan `#50C0E0` is
27//!    reserved for CLEAN.
28//! 3. **Three derive tiers** (block / warn / clean) → exit codes (1 / 2 / 0).
29//!
30//! ## Derive-once + fail-closed
31//!
32//! Derive the state ONCE from the wire `source` ([`CustomerState::from_wire`]).
33//! The App applies severity precedence server-side when it picks the single
34//! `source`, so this map is 1:1 — no precedence is re-implemented here. An
35//! unrecognized / future `source` fails CLOSED to `NotYetAssessed` (warn,
36//! needs-review) — never silently Clean.
37
38/// The three derive tiers — drive exit codes (block=1, warn=2, clean=0) and
39/// the colour family, and map onto the universal ALLOW/WARN/DENY render bucket.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum Tier {
42    /// exit 1 — DENY. Customer must not install / must remove.
43    Block,
44    /// exit 2 — WARN. Review / upgrade (fail-closed for unknown).
45    Warn,
46    /// exit 0 — ALLOW. No known issues.
47    Clean,
48}
49
50impl Tier {
51    /// Process exit code for the CLI gate (block=1, warn=2, clean=0).
52    pub fn exit_code(self) -> i32 {
53        match self {
54            Tier::Block => 1,
55            Tier::Warn => 2,
56            Tier::Clean => 0,
57        }
58    }
59}
60
61/// The 8 canonical customer-facing states, severity order (highest first) per
62/// the BD-ratified ordering in §1: Blocked-by-policy, Compromised, Malicious,
63/// Ransomware-linked, Actively-exploited, Vulnerable, Not-yet-assessed, Clean.
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
65pub enum CustomerState {
66    /// Customer's OWN policy rule blocked this package (operative gate).
67    BlockedByPolicy,
68    /// Tampered-with in a supply-chain attack (curated compromise list).
69    Compromised,
70    /// Known-malicious package (malware / typosquat).
71    Malicious,
72    /// Vulnerability tied to active ransomware campaigns.
73    RansomwareLinked,
74    /// Vulnerability being exploited in the wild (CISA KEV).
75    ActivelyExploited,
76    /// Has known CVEs (review + upgrade).
77    Vulnerable,
78    /// No verdict on file yet — fail-closed needs-review.
79    NotYetAssessed,
80    /// No known issues found.
81    Clean,
82}
83
84impl CustomerState {
85    /// Derive the customer state from the wire `source` string (the values in
86    /// `crate::envelope::ALL_VERDICT_SOURCES`). Unknown / future variants fail
87    /// CLOSED to `NotYetAssessed` (warn) — never silently Clean.
88    ///
89    /// Two variants carry a note:
90    /// - `DM_THRESHOLD_BLOCK` → `BlockedByPolicy` (the customer's own rule).
91    ///   The spec also routes a `DM_THRESHOLD_BLOCK` from the *curated*
92    ///   supply-chain-compromise list (CLEANLIB-177) to `Compromised`, but that
93    ///   distinction is carried by which rule fired, not by `source` alone, and
94    ///   177 is not yet shipped. Until 177 lands + the App tags curated-list
95    ///   blocks, every `DM_THRESHOLD_BLOCK` is the customer's policy. See
96    ///   [`from_block_origin`](CustomerState::from_block_origin).
97    /// - `VECTOR_VERDICT` is the legacy pre-176 full-eval source, preserved for
98    ///   backward-compat. Post-176 the App projects findings into the specific
99    ///   `CVE_FINDING*` variants, so a bare `VECTOR_VERDICT` reaching a render
100    ///   surface is a legacy non-clean signal → `Vulnerable` (fail-safe).
101    pub fn from_wire(source: &str) -> CustomerState {
102        match source {
103            "ALLOWED_NO_FINDINGS" => CustomerState::Clean,
104            "CVE_FINDING" => CustomerState::Vulnerable,
105            "CVE_FINDING_ON_KEV" => CustomerState::ActivelyExploited,
106            "CVE_FINDING_ON_RANSOMWARE" => CustomerState::RansomwareLinked,
107            "MALICIOUS_TRIAGE" => CustomerState::Malicious,
108            "DM_THRESHOLD_BLOCK" => CustomerState::BlockedByPolicy,
109            "INSUFFICIENT_DATA" => CustomerState::NotYetAssessed,
110            // Legacy backward-compat — fail-safe to Vulnerable, never Clean.
111            "VECTOR_VERDICT" => CustomerState::Vulnerable,
112            // Unknown / future variant — fail CLOSED to needs-review.
113            _ => CustomerState::NotYetAssessed,
114        }
115    }
116
117    /// Same as [`from_wire`](CustomerState::from_wire) but lets the caller
118    /// resolve the `DM_THRESHOLD_BLOCK` ambiguity when it knows the block
119    /// origin. Use once CLEANLIB-177 ships and the App tags curated-list
120    /// blocks: `curated_compromise = true` routes `DM_THRESHOLD_BLOCK` to
121    /// `Compromised` instead of `BlockedByPolicy`.
122    pub fn from_block_origin(source: &str, curated_compromise: bool) -> CustomerState {
123        match (source, curated_compromise) {
124            ("DM_THRESHOLD_BLOCK", true) => CustomerState::Compromised,
125            _ => CustomerState::from_wire(source),
126        }
127    }
128
129    /// Stable machine string for `--output json` `state` field + structured
130    /// MCP/SDK fields. snake_case, customer-vocabulary, NOT the wire variant.
131    pub fn as_str(self) -> &'static str {
132        match self {
133            CustomerState::BlockedByPolicy => "blocked_by_policy",
134            CustomerState::Compromised => "compromised",
135            CustomerState::Malicious => "malicious",
136            CustomerState::RansomwareLinked => "ransomware_linked",
137            CustomerState::ActivelyExploited => "actively_exploited",
138            CustomerState::Vulnerable => "vulnerable",
139            CustomerState::NotYetAssessed => "not_yet_assessed",
140            CustomerState::Clean => "clean",
141        }
142    }
143
144    /// Customer-facing display label (Title-case, no codenames). §2 of spec.
145    pub fn label(self) -> &'static str {
146        match self {
147            CustomerState::BlockedByPolicy => "Blocked by policy",
148            CustomerState::Compromised => "Compromised",
149            CustomerState::Malicious => "Malicious",
150            CustomerState::RansomwareLinked => "Ransomware-linked",
151            CustomerState::ActivelyExploited => "Actively exploited",
152            CustomerState::Vulnerable => "Vulnerable",
153            CustomerState::NotYetAssessed => "Not yet assessed",
154            CustomerState::Clean => "Clean",
155        }
156    }
157
158    /// One-line, action-first customer copy (no codenames, no raw tier words).
159    /// §2 of spec — verbatim.
160    pub fn copy(self) -> &'static str {
161        match self {
162            CustomerState::BlockedByPolicy => {
163                "Blocked by your organization's policy. Contact your security \
164                 owner or request an exception."
165            }
166            CustomerState::Compromised => {
167                "This version was tampered with in a supply-chain attack. Do \
168                 not install — use a known-good version."
169            }
170            CustomerState::Malicious => {
171                "Known-malicious package (malware / typosquat). Do not install."
172            }
173            CustomerState::RansomwareLinked => {
174                "Carries a vulnerability tied to active ransomware campaigns. \
175                 Upgrade before shipping."
176            }
177            CustomerState::ActivelyExploited => {
178                "Has a vulnerability that's being exploited in the wild (on the \
179                 U.S. CISA exploited list). Upgrade urgently."
180            }
181            CustomerState::Vulnerable => {
182                "Has known vulnerabilities (CVEs). Review and upgrade to a \
183                 fixed version."
184            }
185            CustomerState::NotYetAssessed => {
186                "No verdict on file yet — treat as needs-review until assessed."
187            }
188            CustomerState::Clean => "No known issues found.",
189        }
190    }
191
192    /// Derive tier (block / warn / clean). `NotYetAssessed` is fail-closed →
193    /// Warn. Per §1 "Tier" column.
194    pub fn tier(self) -> Tier {
195        match self {
196            CustomerState::BlockedByPolicy
197            | CustomerState::Compromised
198            | CustomerState::Malicious
199            | CustomerState::RansomwareLinked
200            | CustomerState::ActivelyExploited => Tier::Block,
201            CustomerState::Vulnerable | CustomerState::NotYetAssessed => Tier::Warn,
202            CustomerState::Clean => Tier::Clean,
203        }
204    }
205
206    /// Process exit code for the CLI gate.
207    pub fn exit_code(self) -> i32 {
208        self.tier().exit_code()
209    }
210
211    /// CSS custom-property name for the soft severity token (extension + SDKs
212    /// consume these for theming). §1 "Soft colour token" column.
213    pub fn color_token(self) -> &'static str {
214        match self {
215            CustomerState::BlockedByPolicy => "--cl-policy",
216            CustomerState::Compromised => "--cl-compromised",
217            CustomerState::Malicious => "--cl-malicious",
218            CustomerState::RansomwareLinked => "--cl-ransomware",
219            CustomerState::ActivelyExploited => "--cl-exploited",
220            CustomerState::Vulnerable => "--cl-vulnerable",
221            CustomerState::NotYetAssessed => "--cl-unknown",
222            CustomerState::Clean => "--cl-clean",
223        }
224    }
225
226    /// Soft severity hex (draft-ratified working palette, §5). Brand cyan for
227    /// Clean; distinct mauve for policy so "your rule" reads apart from "our
228    /// finding" reds.
229    pub fn color_hex(self) -> &'static str {
230        match self {
231            CustomerState::BlockedByPolicy => "#C77DBB",
232            CustomerState::Compromised => "#D16D6A",
233            CustomerState::Malicious => "#D9534F",
234            CustomerState::RansomwareLinked => "#DB7B57",
235            CustomerState::ActivelyExploited => "#E0934A",
236            CustomerState::Vulnerable => "#D9A441",
237            CustomerState::NotYetAssessed => "#7C8696",
238            CustomerState::Clean => "#50C0E0",
239        }
240    }
241
242    /// Text-surface emoji for MCP `human_message` + CLI text output. §1
243    /// "MCP/CLI emoji" column. One concept per tier; states differentiate by
244    /// label + colour, not a bespoke glyph family (§5 — reuse, don't cut).
245    pub fn emoji(self) -> &'static str {
246        match self {
247            CustomerState::BlockedByPolicy => "🛑",
248            CustomerState::Compromised => "⛔",
249            CustomerState::Malicious => "⛔",
250            CustomerState::RansomwareLinked => "🔴",
251            CustomerState::ActivelyExploited => "🔴",
252            CustomerState::Vulnerable => "⚠️",
253            CustomerState::NotYetAssessed => "❔",
254            CustomerState::Clean => "✅",
255        }
256    }
257
258    /// All 8 states, severity order (highest first). For exhaustive contract
259    /// tests + doc generation.
260    pub fn all() -> [CustomerState; 8] {
261        [
262            CustomerState::BlockedByPolicy,
263            CustomerState::Compromised,
264            CustomerState::Malicious,
265            CustomerState::RansomwareLinked,
266            CustomerState::ActivelyExploited,
267            CustomerState::Vulnerable,
268            CustomerState::NotYetAssessed,
269            CustomerState::Clean,
270        ]
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::envelope::ALL_VERDICT_SOURCES;
278
279    // ── The wire→state mappings (the cross-surface contract) ────────────────
280    #[test]
281    fn wire_to_state_mapping_is_canonical() {
282        use CustomerState::*;
283        assert_eq!(CustomerState::from_wire("ALLOWED_NO_FINDINGS"), Clean);
284        assert_eq!(CustomerState::from_wire("CVE_FINDING"), Vulnerable);
285        assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_KEV"), ActivelyExploited);
286        assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE"), RansomwareLinked);
287        assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE"), Malicious);
288        assert_eq!(CustomerState::from_wire("DM_THRESHOLD_BLOCK"), BlockedByPolicy);
289        assert_eq!(CustomerState::from_wire("INSUFFICIENT_DATA"), NotYetAssessed);
290        assert_eq!(CustomerState::from_wire("VECTOR_VERDICT"), Vulnerable);
291    }
292
293    #[test]
294    fn every_known_wire_source_maps_without_falling_through() {
295        // Anti-drift: every value the envelope declares must have an explicit
296        // mapping (not silently hit the fail-closed `_` arm). If 176/177 adds
297        // a wire source, this fails until the map is extended.
298        for &src in ALL_VERDICT_SOURCES {
299            let state = CustomerState::from_wire(src);
300            // ALLOWED_NO_FINDINGS is the only known source that may be Clean.
301            if src != "ALLOWED_NO_FINDINGS" {
302                assert_ne!(
303                    state,
304                    CustomerState::Clean,
305                    "known non-clean source {src} mapped to Clean"
306                );
307            }
308        }
309    }
310
311    #[test]
312    fn unknown_source_fails_closed_to_needs_review() {
313        assert_eq!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::NotYetAssessed);
314        assert_eq!(CustomerState::from_wire(""), CustomerState::NotYetAssessed);
315        assert_ne!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::Clean);
316    }
317
318    #[test]
319    fn curated_compromise_routes_dm_block_to_compromised() {
320        assert_eq!(
321            CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", true),
322            CustomerState::Compromised
323        );
324        assert_eq!(
325            CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", false),
326            CustomerState::BlockedByPolicy
327        );
328        assert_eq!(
329            CustomerState::from_block_origin("CVE_FINDING", true),
330            CustomerState::Vulnerable
331        );
332    }
333
334    #[test]
335    fn tier_and_exit_codes() {
336        assert_eq!(CustomerState::Clean.exit_code(), 0);
337        assert_eq!(CustomerState::Vulnerable.exit_code(), 2);
338        assert_eq!(CustomerState::NotYetAssessed.exit_code(), 2); // fail-closed
339        for s in [
340            CustomerState::ActivelyExploited,
341            CustomerState::RansomwareLinked,
342            CustomerState::Malicious,
343            CustomerState::Compromised,
344            CustomerState::BlockedByPolicy,
345        ] {
346            assert_eq!(s.exit_code(), 1, "{s:?} must be block-tier");
347        }
348    }
349
350    #[test]
351    fn no_internal_codenames_in_customer_strings() {
352        let banned = [
353            "Vector", "VECTOR", "Tricorder", "DmThreshold", "DM_THRESHOLD",
354            "CveFinding", "CVE_FINDING", "MaliciousTriage", "MALICIOUS_TRIAGE",
355            "InsufficientData", "INSUFFICIENT_DATA", "vuln_xref", "mock", "fixture",
356        ];
357        for s in CustomerState::all() {
358            for text in [s.label(), s.copy()] {
359                for b in banned {
360                    assert!(
361                        !text.contains(b),
362                        "codename {b:?} leaked into customer string {text:?} for {s:?}"
363                    );
364                }
365            }
366        }
367    }
368
369    #[test]
370    fn no_raw_tier_words_in_customer_copy() {
371        for s in CustomerState::all() {
372            for b in ["DENY", "WARN", "ALLOW"] {
373                assert!(!s.copy().contains(b), "raw tier word {b} in copy for {s:?}");
374                assert!(!s.label().contains(b), "raw tier word {b} in label for {s:?}");
375            }
376        }
377    }
378
379    #[test]
380    fn every_state_has_complete_token_set() {
381        for s in CustomerState::all() {
382            assert!(!s.as_str().is_empty());
383            assert!(!s.label().is_empty());
384            assert!(!s.copy().is_empty());
385            assert!(s.color_token().starts_with("--cl-"));
386            assert!(s.color_hex().starts_with('#') && s.color_hex().len() == 7);
387            assert!(!s.emoji().is_empty());
388        }
389    }
390
391    #[test]
392    fn clean_uses_brand_cyan_others_do_not() {
393        assert_eq!(CustomerState::Clean.color_hex(), "#50C0E0");
394        for s in CustomerState::all() {
395            if s != CustomerState::Clean {
396                assert_ne!(s.color_hex(), "#50C0E0", "{s:?} must not reuse brand cyan");
397            }
398        }
399    }
400
401    #[test]
402    fn machine_strings_are_unique() {
403        let mut seen = std::collections::HashSet::new();
404        for s in CustomerState::all() {
405            assert!(seen.insert(s.as_str()), "duplicate state string {}", s.as_str());
406        }
407    }
408}