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 9 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, Range-not-resolved,
64/// Not-yet-assessed, Clean. CLEANLIB-518(§3) added Range-not-resolved (8→9).
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum CustomerState {
67    /// Customer's OWN policy rule blocked this package (operative gate).
68    BlockedByPolicy,
69    /// Tampered-with in a supply-chain attack (curated compromise list).
70    Compromised,
71    /// Known-malicious package (malware / typosquat).
72    Malicious,
73    /// Vulnerability tied to active ransomware campaigns.
74    RansomwareLinked,
75    /// Vulnerability being exploited in the wild (CISA KEV).
76    ActivelyExploited,
77    /// Has known CVEs (review + upgrade).
78    Vulnerable,
79    /// CLEANLIB-518 (§3): the requested semver range could not be resolved to a
80    /// concrete version (`source_state = RANGE_NOT_RESOLVED`). Distinct from
81    /// `NotYetAssessed` — the package is not un-assessed, the *range* is; the
82    /// customer must pin an exact version. Fail-closed needs-input (Warn).
83    RangeNotResolved,
84    /// No verdict on file yet — fail-closed needs-review.
85    NotYetAssessed,
86    /// No known issues found.
87    Clean,
88}
89
90impl CustomerState {
91    /// Derive the customer state from the wire `source` string (the values in
92    /// `crate::envelope::ALL_VERDICT_SOURCES`). Unknown / future variants fail
93    /// CLOSED to `NotYetAssessed` (warn) — never silently Clean.
94    ///
95    /// Two variants carry a note:
96    /// - `DM_THRESHOLD_BLOCK` → `BlockedByPolicy` (the customer's own rule).
97    ///   The spec also routes a `DM_THRESHOLD_BLOCK` from the *curated*
98    ///   supply-chain-compromise list (CLEANLIB-177) to `Compromised`, but that
99    ///   distinction is carried by which rule fired, not by `source` alone, and
100    ///   177 is not yet shipped. Until 177 lands + the App tags curated-list
101    ///   blocks, every `DM_THRESHOLD_BLOCK` is the customer's policy. See
102    ///   [`from_block_origin`](CustomerState::from_block_origin).
103    /// - `VECTOR_VERDICT` is the legacy pre-176 full-eval source, preserved for
104    ///   backward-compat. Post-176 the App projects findings into the specific
105    ///   `CVE_FINDING*` variants, so a bare `VECTOR_VERDICT` reaching a render
106    ///   surface is a legacy non-clean signal → `Vulnerable` (fail-safe).
107    pub fn from_wire(source: &str) -> CustomerState {
108        match source {
109            "ALLOWED_NO_FINDINGS" => CustomerState::Clean,
110            "CVE_FINDING" => CustomerState::Vulnerable,
111            "CVE_FINDING_ON_KEV" => CustomerState::ActivelyExploited,
112            "CVE_FINDING_ON_RANSOMWARE" => CustomerState::RansomwareLinked,
113            "MALICIOUS_TRIAGE" => CustomerState::Malicious,
114            "DM_THRESHOLD_BLOCK" => CustomerState::BlockedByPolicy,
115            "INSUFFICIENT_DATA" => CustomerState::NotYetAssessed,
116            // Legacy backward-compat — fail-safe to Vulnerable, never Clean.
117            "VECTOR_VERDICT" => CustomerState::Vulnerable,
118            // Unknown / future variant — fail CLOSED to needs-review.
119            _ => CustomerState::NotYetAssessed,
120        }
121    }
122
123    /// Same as [`from_wire`](CustomerState::from_wire) but lets the caller
124    /// resolve the `DM_THRESHOLD_BLOCK` ambiguity when it knows the block
125    /// origin. Use once CLEANLIB-177 ships and the App tags curated-list
126    /// blocks: `curated_compromise = true` routes `DM_THRESHOLD_BLOCK` to
127    /// `Compromised` instead of `BlockedByPolicy`.
128    pub fn from_block_origin(source: &str, curated_compromise: bool) -> CustomerState {
129        match (source, curated_compromise) {
130            ("DM_THRESHOLD_BLOCK", true) => CustomerState::Compromised,
131            _ => CustomerState::from_wire(source),
132        }
133    }
134
135    /// CLEANLIB-518 (§3): resolve the customer state taking the wire
136    /// `source_state` into account. A `RANGE_NOT_RESOLVED` source_state
137    /// (CLEANLIB-513 — App could not resolve the requested semver range) maps to
138    /// `RangeNotResolved` so the render banner reads "Range not resolved" and
139    /// agrees with the reasoning body, instead of collapsing to the generic
140    /// `NotYetAssessed` banner. All other source_states defer to
141    /// [`from_wire`](CustomerState::from_wire) on `source`.
142    pub fn from_wire_with_source_state(source: &str, source_state: Option<&str>) -> CustomerState {
143        match source_state {
144            // `RANGE_NOT_RESOLVED` (CLEANLIB-513/§3) is a source_state-only value
145            // — not a VerdictSource `from_wire` knows — so it keeps its explicit
146            // arm.
147            Some(s) if s.eq_ignore_ascii_case("RANGE_NOT_RESOLVED") => {
148                CustomerState::RangeNotResolved
149            }
150            // CLEANLIB-543: `source_state` is the envelope-v2 canonical status key
151            // (the v2 rename of `source`, carrying the refined origin —
152            // CVE_FINDING_ON_KEV / CVE_FINDING_ON_RANSOMWARE / MALICIOUS_TRIAGE /
153            // …). When present, derive from IT, not the coarse `source` (which the
154            // App may emit as a flat VECTOR_VERDICT on the v2 shape). This is what
155            // keeps `actively_exploited` / `ransomware_linked` from flattening to
156            // `vulnerable` when the origin rides in source_state. `from_wire`
157            // already maps the full VerdictSource corpus and fail-closes an
158            // unknown value to `not_yet_assessed`, so this stays fail-safe. Note
159            // the App emits `customer_state == from_wire(source_state)` server-side
160            // (§3), so preferring source_state is equivalent to trusting the
161            // pre-resolved customer_state.
162            Some(s) if !s.is_empty() => CustomerState::from_wire(s),
163            // v1 back-compat: no source_state → the origin rides in `source`.
164            _ => CustomerState::from_wire(source),
165        }
166    }
167
168    /// Stable machine string for `--output json` `state` field + structured
169    /// MCP/SDK fields. snake_case, customer-vocabulary, NOT the wire variant.
170    pub fn as_str(self) -> &'static str {
171        match self {
172            CustomerState::BlockedByPolicy => "blocked_by_policy",
173            CustomerState::Compromised => "compromised",
174            CustomerState::Malicious => "malicious",
175            CustomerState::RansomwareLinked => "ransomware_linked",
176            CustomerState::ActivelyExploited => "actively_exploited",
177            CustomerState::Vulnerable => "vulnerable",
178            CustomerState::RangeNotResolved => "range_not_resolved",
179            CustomerState::NotYetAssessed => "not_yet_assessed",
180            CustomerState::Clean => "clean",
181        }
182    }
183
184    /// Customer-facing display label (Title-case, no codenames). §2 of spec.
185    pub fn label(self) -> &'static str {
186        match self {
187            CustomerState::BlockedByPolicy => "Blocked by policy",
188            CustomerState::Compromised => "Compromised",
189            CustomerState::Malicious => "Malicious",
190            CustomerState::RansomwareLinked => "Ransomware-linked",
191            CustomerState::ActivelyExploited => "Actively exploited",
192            CustomerState::Vulnerable => "Vulnerable",
193            CustomerState::RangeNotResolved => "Range not resolved",
194            CustomerState::NotYetAssessed => "Not yet assessed",
195            CustomerState::Clean => "Clean",
196        }
197    }
198
199    /// One-line, action-first customer copy (no codenames, no raw tier words).
200    /// §2 of spec — verbatim.
201    pub fn copy(self) -> &'static str {
202        match self {
203            CustomerState::BlockedByPolicy => {
204                "Blocked by your organization's policy. Contact your security \
205                 owner or request an exception."
206            }
207            CustomerState::Compromised => {
208                "This version was tampered with in a supply-chain attack. Do \
209                 not install — use a known-good version."
210            }
211            CustomerState::Malicious => {
212                "Known-malicious package (malware / typosquat). Do not install."
213            }
214            CustomerState::RansomwareLinked => {
215                "Carries a vulnerability tied to active ransomware campaigns. \
216                 Upgrade before shipping."
217            }
218            CustomerState::ActivelyExploited => {
219                "Has a vulnerability that's being exploited in the wild (on the \
220                 U.S. CISA exploited list). Upgrade urgently."
221            }
222            CustomerState::Vulnerable => {
223                "Has known vulnerabilities (CVEs). Review and upgrade to a \
224                 fixed version."
225            }
226            CustomerState::RangeNotResolved => {
227                "The requested version range couldn't be resolved to a concrete \
228                 version. Pin an exact version and re-check."
229            }
230            CustomerState::NotYetAssessed => {
231                "No verdict on file yet — treat as needs-review until assessed."
232            }
233            CustomerState::Clean => "No known issues found.",
234        }
235    }
236
237    /// Derive tier (block / warn / clean). `NotYetAssessed` is fail-closed →
238    /// Warn. Per §1 "Tier" column.
239    pub fn tier(self) -> Tier {
240        match self {
241            CustomerState::BlockedByPolicy
242            | CustomerState::Compromised
243            | CustomerState::Malicious
244            | CustomerState::RansomwareLinked
245            | CustomerState::ActivelyExploited => Tier::Block,
246            CustomerState::Vulnerable
247            | CustomerState::RangeNotResolved
248            | CustomerState::NotYetAssessed => Tier::Warn,
249            CustomerState::Clean => Tier::Clean,
250        }
251    }
252
253    /// Process exit code for the CLI gate.
254    pub fn exit_code(self) -> i32 {
255        self.tier().exit_code()
256    }
257
258    /// CSS custom-property name for the soft severity token (extension + SDKs
259    /// consume these for theming). §1 "Soft colour token" column.
260    pub fn color_token(self) -> &'static str {
261        match self {
262            CustomerState::BlockedByPolicy => "--cl-policy",
263            CustomerState::Compromised => "--cl-compromised",
264            CustomerState::Malicious => "--cl-malicious",
265            CustomerState::RansomwareLinked => "--cl-ransomware",
266            CustomerState::ActivelyExploited => "--cl-exploited",
267            CustomerState::Vulnerable => "--cl-vulnerable",
268            // §5 reuse: range-not-resolved shares the needs-input token family;
269            // states differentiate by label, not a bespoke colour.
270            CustomerState::RangeNotResolved => "--cl-unknown",
271            CustomerState::NotYetAssessed => "--cl-unknown",
272            CustomerState::Clean => "--cl-clean",
273        }
274    }
275
276    /// Soft severity hex (draft-ratified working palette, §5). Brand cyan for
277    /// Clean; distinct mauve for policy so "your rule" reads apart from "our
278    /// finding" reds.
279    pub fn color_hex(self) -> &'static str {
280        match self {
281            CustomerState::BlockedByPolicy => "#C77DBB",
282            CustomerState::Compromised => "#D16D6A",
283            CustomerState::Malicious => "#D9534F",
284            CustomerState::RansomwareLinked => "#DB7B57",
285            CustomerState::ActivelyExploited => "#E0934A",
286            CustomerState::Vulnerable => "#D9A441",
287            CustomerState::RangeNotResolved => "#7C8696",
288            CustomerState::NotYetAssessed => "#7C8696",
289            CustomerState::Clean => "#50C0E0",
290        }
291    }
292
293    /// Text-surface emoji for MCP `human_message` + CLI text output. §1
294    /// "MCP/CLI emoji" column. One concept per tier; states differentiate by
295    /// label + colour, not a bespoke glyph family (§5 — reuse, don't cut).
296    pub fn emoji(self) -> &'static str {
297        match self {
298            CustomerState::BlockedByPolicy => "🛑",
299            CustomerState::Compromised => "⛔",
300            CustomerState::Malicious => "⛔",
301            CustomerState::RansomwareLinked => "🔴",
302            CustomerState::ActivelyExploited => "🔴",
303            CustomerState::Vulnerable => "⚠️",
304            CustomerState::RangeNotResolved => "❔",
305            CustomerState::NotYetAssessed => "❔",
306            CustomerState::Clean => "✅",
307        }
308    }
309
310    /// All 9 states, severity order (highest first). For exhaustive contract
311    /// tests + doc generation. CLEANLIB-518(§3) added RangeNotResolved (8→9).
312    pub fn all() -> [CustomerState; 9] {
313        [
314            CustomerState::BlockedByPolicy,
315            CustomerState::Compromised,
316            CustomerState::Malicious,
317            CustomerState::RansomwareLinked,
318            CustomerState::ActivelyExploited,
319            CustomerState::Vulnerable,
320            CustomerState::RangeNotResolved,
321            CustomerState::NotYetAssessed,
322            CustomerState::Clean,
323        ]
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::envelope::ALL_VERDICT_SOURCES;
331
332    // ── The wire→state mappings (the cross-surface contract) ────────────────
333    #[test]
334    fn wire_to_state_mapping_is_canonical() {
335        use CustomerState::*;
336        assert_eq!(CustomerState::from_wire("ALLOWED_NO_FINDINGS"), Clean);
337        assert_eq!(CustomerState::from_wire("CVE_FINDING"), Vulnerable);
338        assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_KEV"), ActivelyExploited);
339        assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE"), RansomwareLinked);
340        assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE"), Malicious);
341        assert_eq!(CustomerState::from_wire("DM_THRESHOLD_BLOCK"), BlockedByPolicy);
342        assert_eq!(CustomerState::from_wire("INSUFFICIENT_DATA"), NotYetAssessed);
343        assert_eq!(CustomerState::from_wire("VECTOR_VERDICT"), Vulnerable);
344    }
345
346    #[test]
347    fn every_known_wire_source_maps_without_falling_through() {
348        // Anti-drift: every value the envelope declares must have an explicit
349        // mapping (not silently hit the fail-closed `_` arm). If 176/177 adds
350        // a wire source, this fails until the map is extended.
351        for &src in ALL_VERDICT_SOURCES {
352            let state = CustomerState::from_wire(src);
353            // ALLOWED_NO_FINDINGS is the only known source that may be Clean.
354            if src != "ALLOWED_NO_FINDINGS" {
355                assert_ne!(
356                    state,
357                    CustomerState::Clean,
358                    "known non-clean source {src} mapped to Clean"
359                );
360            }
361        }
362    }
363
364    #[test]
365    fn cleanlib_518_range_not_resolved_source_state_maps_distinctly() {
366        // §3: a RANGE_NOT_RESOLVED source_state yields the distinct
367        // RangeNotResolved state (not the generic NotYetAssessed), so the CLI
368        // banner label agrees with the reasoning body. Case-insensitive.
369        assert_eq!(
370            CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("RANGE_NOT_RESOLVED")),
371            CustomerState::RangeNotResolved
372        );
373        assert_eq!(
374            CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("range_not_resolved")),
375            CustomerState::RangeNotResolved
376        );
377        // Banner↔body coherence: the label is no longer "Not yet assessed".
378        assert_eq!(CustomerState::RangeNotResolved.label(), "Range not resolved");
379        assert_ne!(
380            CustomerState::RangeNotResolved.label(),
381            CustomerState::NotYetAssessed.label()
382        );
383        // Fail-closed: an unresolved range is a WARN, never an ALLOW.
384        assert_eq!(CustomerState::RangeNotResolved.exit_code(), 2);
385        // No source_state (or a different one) still defers to `source`.
386        assert_eq!(
387            CustomerState::from_wire_with_source_state("CVE_FINDING", None),
388            CustomerState::Vulnerable
389        );
390        assert_eq!(
391            CustomerState::from_wire_with_source_state("CVE_FINDING", Some("CVE_FINDING")),
392            CustomerState::Vulnerable
393        );
394    }
395
396    #[test]
397    fn unknown_source_fails_closed_to_needs_review() {
398        assert_eq!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::NotYetAssessed);
399        assert_eq!(CustomerState::from_wire(""), CustomerState::NotYetAssessed);
400        assert_ne!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::Clean);
401    }
402
403    #[test]
404    fn curated_compromise_routes_dm_block_to_compromised() {
405        assert_eq!(
406            CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", true),
407            CustomerState::Compromised
408        );
409        assert_eq!(
410            CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", false),
411            CustomerState::BlockedByPolicy
412        );
413        assert_eq!(
414            CustomerState::from_block_origin("CVE_FINDING", true),
415            CustomerState::Vulnerable
416        );
417    }
418
419    #[test]
420    fn tier_and_exit_codes() {
421        assert_eq!(CustomerState::Clean.exit_code(), 0);
422        assert_eq!(CustomerState::Vulnerable.exit_code(), 2);
423        assert_eq!(CustomerState::NotYetAssessed.exit_code(), 2); // fail-closed
424        for s in [
425            CustomerState::ActivelyExploited,
426            CustomerState::RansomwareLinked,
427            CustomerState::Malicious,
428            CustomerState::Compromised,
429            CustomerState::BlockedByPolicy,
430        ] {
431            assert_eq!(s.exit_code(), 1, "{s:?} must be block-tier");
432        }
433    }
434
435    #[test]
436    fn no_internal_codenames_in_customer_strings() {
437        let banned = [
438            "Vector", "VECTOR", "Tricorder", "DmThreshold", "DM_THRESHOLD",
439            "CveFinding", "CVE_FINDING", "MaliciousTriage", "MALICIOUS_TRIAGE",
440            "InsufficientData", "INSUFFICIENT_DATA", "vuln_xref", "mock", "fixture",
441        ];
442        for s in CustomerState::all() {
443            for text in [s.label(), s.copy()] {
444                for b in banned {
445                    assert!(
446                        !text.contains(b),
447                        "codename {b:?} leaked into customer string {text:?} for {s:?}"
448                    );
449                }
450            }
451        }
452    }
453
454    #[test]
455    fn no_raw_tier_words_in_customer_copy() {
456        for s in CustomerState::all() {
457            for b in ["DENY", "WARN", "ALLOW"] {
458                assert!(!s.copy().contains(b), "raw tier word {b} in copy for {s:?}");
459                assert!(!s.label().contains(b), "raw tier word {b} in label for {s:?}");
460            }
461        }
462    }
463
464    #[test]
465    fn every_state_has_complete_token_set() {
466        for s in CustomerState::all() {
467            assert!(!s.as_str().is_empty());
468            assert!(!s.label().is_empty());
469            assert!(!s.copy().is_empty());
470            assert!(s.color_token().starts_with("--cl-"));
471            assert!(s.color_hex().starts_with('#') && s.color_hex().len() == 7);
472            assert!(!s.emoji().is_empty());
473        }
474    }
475
476    #[test]
477    fn clean_uses_brand_cyan_others_do_not() {
478        assert_eq!(CustomerState::Clean.color_hex(), "#50C0E0");
479        for s in CustomerState::all() {
480            if s != CustomerState::Clean {
481                assert_ne!(s.color_hex(), "#50C0E0", "{s:?} must not reuse brand cyan");
482            }
483        }
484    }
485
486    #[test]
487    fn machine_strings_are_unique() {
488        let mut seen = std::collections::HashSet::new();
489        for s in CustomerState::all() {
490            assert!(seen.insert(s.as_str()), "duplicate state string {}", s.as_str());
491        }
492    }
493}