1use super::types::{SkillDefinition, SkillRiskLevel};
7
8#[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#[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
28const 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
42pub 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 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 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 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 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 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
99fn 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
109fn 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); assert_eq!(result.risk_level, SkillRiskLevel::Critical); 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}