Skip to main content

skilllite_core/skill/
trust.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
4#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
5pub enum TrustTier {
6    Trusted,
7    Reviewed,
8    Community,
9    #[default]
10    Unknown,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum TrustDecision {
16    Allow,
17    RequireConfirm,
18    Deny,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum IntegritySignal {
23    Ok,
24    HashChanged,
25    SignatureInvalid,
26    Unsigned,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum SignatureSignal {
31    Unsigned,
32    Valid,
33    Invalid,
34}
35
36#[derive(Debug, Clone)]
37pub struct TrustAssessment {
38    pub tier: TrustTier,
39    pub score: u8,
40    pub reasons: Vec<String>,
41    pub decision: TrustDecision,
42}
43
44pub fn assess_skill_trust(
45    source: Option<&str>,
46    signature: SignatureSignal,
47    integrity: IntegritySignal,
48    has_critical_scan: bool,
49    has_high_scan: bool,
50) -> TrustAssessment {
51    let mut reasons = Vec::new();
52
53    if matches!(
54        integrity,
55        IntegritySignal::HashChanged | IntegritySignal::SignatureInvalid
56    ) || matches!(signature, SignatureSignal::Invalid)
57        || has_critical_scan
58    {
59        if matches!(integrity, IntegritySignal::HashChanged) {
60            reasons.push("content hash drift detected".to_string());
61        }
62        if matches!(
63            integrity,
64            IntegritySignal::SignatureInvalid | IntegritySignal::Unsigned
65        ) && matches!(signature, SignatureSignal::Invalid)
66        {
67            reasons.push("signature validation failed".to_string());
68        }
69        if has_critical_scan {
70            reasons.push("critical security scan findings".to_string());
71        }
72        return TrustAssessment {
73            tier: TrustTier::Unknown,
74            score: 0,
75            reasons,
76            decision: TrustDecision::Deny,
77        };
78    }
79
80    let mut score: i32 = 0;
81    let src = source.unwrap_or("").to_lowercase();
82    if src.contains("clawhub:") || src.contains("github.com/exboys/skilllite") {
83        score += 25;
84        reasons.push("official source".to_string());
85    } else if src.contains("github.com/") || src.contains('/') {
86        score += 15;
87        reasons.push("known repository source".to_string());
88    } else if !src.is_empty() {
89        score += 8;
90        reasons.push("local/custom source".to_string());
91    }
92
93    match signature {
94        SignatureSignal::Valid => {
95            score += 25;
96            reasons.push("signature verified".to_string());
97        }
98        SignatureSignal::Unsigned => {
99            score += 8;
100            reasons.push("unsigned package".to_string());
101        }
102        SignatureSignal::Invalid => {}
103    }
104
105    match integrity {
106        IntegritySignal::Ok => score += 20,
107        IntegritySignal::Unsigned => score += 20, // hash baseline matches in manifest path
108        IntegritySignal::HashChanged | IntegritySignal::SignatureInvalid => {}
109    }
110
111    if has_high_scan {
112        score += 8;
113        reasons.push("high-risk scan findings present".to_string());
114    } else {
115        score += 20;
116    }
117
118    if score > 100 {
119        score = 100;
120    }
121    let score_u8 = score as u8;
122
123    let (tier, decision) = if score_u8 >= 85 {
124        (TrustTier::Trusted, TrustDecision::Allow)
125    } else if score_u8 >= 65 {
126        (TrustTier::Reviewed, TrustDecision::Allow)
127    } else if score_u8 >= 40 {
128        (TrustTier::Community, TrustDecision::RequireConfirm)
129    } else {
130        (TrustTier::Unknown, TrustDecision::RequireConfirm)
131    };
132
133    TrustAssessment {
134        tier,
135        score: score_u8,
136        reasons,
137        decision,
138    }
139}