Skip to main content

rustant_core/skills/
validator.rs

1//! Skill security validation.
2//!
3//! Validates skill definitions for security risks: checks required secrets exist,
4//! tool dependencies resolve, and scans for dangerous patterns.
5
6use super::types::{SkillDefinition, SkillRiskLevel};
7
8/// Errors from security validation.
9#[derive(Debug, thiserror::Error)]
10pub enum ValidationError {
11    #[error("Missing required secret: {0}")]
12    MissingSecret(String),
13    #[error("Missing required tool: {0}")]
14    MissingTool(String),
15    #[error("Dangerous pattern detected: {0}")]
16    DangerousPattern(String),
17}
18
19/// Result of validating a skill.
20#[derive(Debug)]
21pub struct ValidationResult {
22    pub is_valid: bool,
23    pub risk_level: SkillRiskLevel,
24    pub warnings: Vec<String>,
25    pub errors: Vec<ValidationError>,
26}
27
28/// Dangerous patterns that increase risk level.
29const DANGEROUS_PATTERNS: &[(&str, &str)] = &[
30    ("shell_exec", "Uses shell execution"),
31    ("sudo", "Uses privilege escalation"),
32    ("rm -rf", "Contains recursive delete"),
33    ("chmod", "Modifies file permissions"),
34    ("curl", "Makes network requests"),
35    ("wget", "Downloads files"),
36    ("eval", "Uses eval (code injection risk)"),
37    ("exec", "Uses exec"),
38    ("/etc/passwd", "Accesses system files"),
39    ("DROP TABLE", "Contains SQL destructive command"),
40];
41
42/// Validate a skill definition for security.
43pub fn validate_skill(
44    skill: &SkillDefinition,
45    available_tools: &[String],
46    available_secrets: &[String],
47) -> ValidationResult {
48    let mut errors = Vec::new();
49    let mut warnings = Vec::new();
50    let mut max_risk = SkillRiskLevel::Low;
51
52    // Check required tools
53    for req in &skill.requires {
54        if req.req_type == "tool" && !available_tools.contains(&req.name) {
55            errors.push(ValidationError::MissingTool(req.name.clone()));
56        }
57    }
58
59    // Check required secrets
60    for req in &skill.requires {
61        if req.req_type == "secret" && !available_secrets.contains(&req.name) {
62            errors.push(ValidationError::MissingSecret(req.name.clone()));
63        }
64    }
65
66    // Scan tool bodies for dangerous patterns
67    for tool in &skill.tools {
68        for (pattern, description) in DANGEROUS_PATTERNS {
69            if tool.body.contains(pattern) {
70                warnings.push(format!(
71                    "Tool '{}': {} (pattern: '{}')",
72                    tool.name, description, pattern
73                ));
74                // Escalate risk level based on pattern
75                let pattern_risk = pattern_risk_level(pattern);
76                if risk_priority(&pattern_risk) > risk_priority(&max_risk) {
77                    max_risk = pattern_risk;
78                }
79            }
80        }
81    }
82
83    // Check if skill has any secrets (elevates risk to at least Medium)
84    let has_secrets = skill.requires.iter().any(|r| r.req_type == "secret");
85    if has_secrets && risk_priority(&max_risk) < risk_priority(&SkillRiskLevel::Medium) {
86        max_risk = SkillRiskLevel::Medium;
87    }
88
89    let is_valid = errors.is_empty();
90
91    ValidationResult {
92        is_valid,
93        risk_level: max_risk,
94        warnings,
95        errors,
96    }
97}
98
99/// Determine risk level for a specific dangerous pattern.
100fn pattern_risk_level(pattern: &str) -> SkillRiskLevel {
101    match pattern {
102        "sudo" | "rm -rf" | "DROP TABLE" | "/etc/passwd" => SkillRiskLevel::Critical,
103        "shell_exec" | "exec" | "eval" => SkillRiskLevel::High,
104        "curl" | "wget" | "chmod" => SkillRiskLevel::Medium,
105        _ => SkillRiskLevel::Low,
106    }
107}
108
109/// Convert risk level to a numeric priority for comparison.
110fn risk_priority(level: &SkillRiskLevel) -> u8 {
111    match level {
112        SkillRiskLevel::Low => 0,
113        SkillRiskLevel::Medium => 1,
114        SkillRiskLevel::High => 2,
115        SkillRiskLevel::Critical => 3,
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::skills::types::{SkillRequirement, SkillToolDef};
123
124    fn make_skill(
125        name: &str,
126        requires: Vec<SkillRequirement>,
127        tools: Vec<SkillToolDef>,
128    ) -> SkillDefinition {
129        SkillDefinition {
130            name: name.into(),
131            version: "1.0.0".into(),
132            description: "test".into(),
133            author: None,
134            requires,
135            tools,
136            config: Default::default(),
137            risk_level: SkillRiskLevel::Low,
138            source_path: None,
139        }
140    }
141
142    #[test]
143    fn test_validate_all_deps_met() {
144        let skill = make_skill(
145            "test",
146            vec![
147                SkillRequirement {
148                    req_type: "tool".into(),
149                    name: "shell_exec".into(),
150                },
151                SkillRequirement {
152                    req_type: "secret".into(),
153                    name: "API_KEY".into(),
154                },
155            ],
156            vec![SkillToolDef {
157                name: "safe_tool".into(),
158                description: "Safe".into(),
159                parameters: serde_json::json!({}),
160                body: "echo hello".into(),
161            }],
162        );
163
164        let result = validate_skill(&skill, &["shell_exec".into()], &["API_KEY".into()]);
165        assert!(result.is_valid);
166    }
167
168    #[test]
169    fn test_validate_missing_secret() {
170        let skill = make_skill(
171            "test",
172            vec![SkillRequirement {
173                req_type: "secret".into(),
174                name: "MISSING_KEY".into(),
175            }],
176            vec![],
177        );
178
179        let result = validate_skill(&skill, &[], &[]);
180        assert!(!result.is_valid);
181        assert!(
182            result
183                .errors
184                .iter()
185                .any(|e| matches!(e, ValidationError::MissingSecret(_)))
186        );
187    }
188
189    #[test]
190    fn test_validate_missing_tool() {
191        let skill = make_skill(
192            "test",
193            vec![SkillRequirement {
194                req_type: "tool".into(),
195                name: "nonexistent_tool".into(),
196            }],
197            vec![],
198        );
199
200        let result = validate_skill(&skill, &[], &[]);
201        assert!(!result.is_valid);
202        assert!(
203            result
204                .errors
205                .iter()
206                .any(|e| matches!(e, ValidationError::MissingTool(_)))
207        );
208    }
209
210    #[test]
211    fn test_validate_dangerous_shell_exec() {
212        let skill = make_skill(
213            "test",
214            vec![],
215            vec![SkillToolDef {
216                name: "risky".into(),
217                description: "Risky tool".into(),
218                parameters: serde_json::json!({}),
219                body: "shell_exec: rm -rf /tmp/data".into(),
220            }],
221        );
222
223        let result = validate_skill(&skill, &[], &[]);
224        assert!(result.is_valid); // No missing deps
225        assert_eq!(result.risk_level, SkillRiskLevel::Critical); // rm -rf is critical
226        assert!(!result.warnings.is_empty());
227    }
228
229    #[test]
230    fn test_validate_read_only_low_risk() {
231        let skill = make_skill(
232            "test",
233            vec![],
234            vec![SkillToolDef {
235                name: "safe".into(),
236                description: "Safe read-only tool".into(),
237                parameters: serde_json::json!({}),
238                body: "Read the file contents and summarize".into(),
239            }],
240        );
241
242        let result = validate_skill(&skill, &[], &[]);
243        assert!(result.is_valid);
244        assert_eq!(result.risk_level, SkillRiskLevel::Low);
245        assert!(result.warnings.is_empty());
246    }
247
248    #[test]
249    fn test_validate_secret_elevates_risk() {
250        let skill = make_skill(
251            "test",
252            vec![SkillRequirement {
253                req_type: "secret".into(),
254                name: "API_KEY".into(),
255            }],
256            vec![SkillToolDef {
257                name: "api_call".into(),
258                description: "API caller".into(),
259                parameters: serde_json::json!({}),
260                body: "Use API key to fetch data".into(),
261            }],
262        );
263
264        let result = validate_skill(&skill, &[], &["API_KEY".into()]);
265        assert!(result.is_valid);
266        assert_eq!(result.risk_level, SkillRiskLevel::Medium);
267    }
268
269    #[test]
270    fn test_validate_sudo_is_critical() {
271        let skill = make_skill(
272            "test",
273            vec![],
274            vec![SkillToolDef {
275                name: "admin".into(),
276                description: "Admin tool".into(),
277                parameters: serde_json::json!({}),
278                body: "sudo apt-get update".into(),
279            }],
280        );
281
282        let result = validate_skill(&skill, &[], &[]);
283        assert_eq!(result.risk_level, SkillRiskLevel::Critical);
284    }
285
286    #[test]
287    fn test_validate_network_is_medium() {
288        let skill = make_skill(
289            "test",
290            vec![],
291            vec![SkillToolDef {
292                name: "fetch".into(),
293                description: "Fetcher".into(),
294                parameters: serde_json::json!({}),
295                body: "curl https://api.example.com/data".into(),
296            }],
297        );
298
299        let result = validate_skill(&skill, &[], &[]);
300        assert_eq!(result.risk_level, SkillRiskLevel::Medium);
301    }
302}