1use crate::skills::cli_bridge::CliToolConfig;
11use crate::skills::manifest::parse_skill_file;
12use crate::utils::file_utils::read_file_with_context_sync;
13use anyhow::Result;
14use hashbrown::HashMap;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use std::path::{Path, PathBuf};
18use std::time::{Instant, SystemTime};
19use tracing::info;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ValidationConfig {
24 pub enable_security_checks: bool,
26
27 pub enable_performance_checks: bool,
29
30 pub max_validation_time: u64,
32
33 pub max_script_size: usize,
35
36 pub allowed_script_extensions: Vec<String>,
38
39 pub blocked_commands: Vec<String>,
41
42 pub required_metadata_fields: Vec<String>,
44
45 pub enable_schema_validation: bool,
47
48 pub strict_mode: bool,
50}
51
52impl Default for ValidationConfig {
53 fn default() -> Self {
54 Self {
55 enable_security_checks: true,
56 enable_performance_checks: true,
57 max_validation_time: 30,
58 max_script_size: 1024 * 1024, allowed_script_extensions: vec![
60 "py".to_string(),
61 "sh".to_string(),
62 "bash".to_string(),
63 "js".to_string(),
64 "ts".to_string(),
65 "rb".to_string(),
66 "pl".to_string(),
67 "go".to_string(),
68 "rs".to_string(),
69 ],
70 blocked_commands: vec![
71 "rm -rf /".to_string(),
72 "sudo".to_string(),
73 "chmod 777".to_string(),
74 "curl.*|.*sh".to_string(),
75 "wget.*|.*sh".to_string(),
76 ],
77 required_metadata_fields: vec!["name".to_string(), "description".to_string()],
78 enable_schema_validation: true,
79 strict_mode: false,
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ValidationReport {
87 pub status: ValidationStatus,
89
90 pub skill_name: String,
92
93 pub timestamp: chrono::DateTime<chrono::Utc>,
95
96 pub checks: HashMap<String, CheckResult>,
98
99 pub performance: PerformanceMetrics,
101
102 pub security: SecurityAssessment,
104
105 pub recommendations: Vec<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub enum ValidationStatus {
112 Valid,
114 Warning,
116 Invalid,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CheckResult {
123 pub name: String,
125
126 pub status: CheckStatus,
128
129 pub message: String,
131
132 pub details: Option<Value>,
134
135 pub execution_time_ms: u64,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141pub enum CheckStatus {
142 Passed,
144 Warning,
146 Failed,
148 Skipped,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct PerformanceMetrics {
155 pub total_time_ms: u64,
157
158 pub loading_time_ms: u64,
160
161 pub schema_validation_time_ms: u64,
163
164 pub script_validation_time_ms: u64,
166
167 pub memory_usage_bytes: usize,
169
170 pub token_usage_estimate: usize,
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct SecurityAssessment {
177 pub security_level: SecurityLevel,
179
180 pub warnings: Vec<SecurityWarning>,
182
183 pub blocked_content: Vec<String>,
185
186 pub safe_to_execute: bool,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
192pub enum SecurityLevel {
193 #[default]
195 Safe,
196 LowRisk,
198 MediumRisk,
200 HighRisk,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SecurityWarning {
207 pub warning_type: String,
209
210 pub message: String,
212
213 pub severity: SecurityLevel,
215
216 pub suggestion: Option<String>,
218}
219
220pub struct SkillValidator {
222 config: ValidationConfig,
223 schema_validation_cache: HashMap<PathBuf, (SystemTime, CheckResult)>,
226}
227
228impl SkillValidator {
229 pub fn new() -> Self {
231 Self::with_config(ValidationConfig::default())
232 }
233}
234
235impl Default for SkillValidator {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241impl SkillValidator {
242 pub fn with_config(config: ValidationConfig) -> Self {
244 Self { config, schema_validation_cache: HashMap::new() }
245 }
246
247 pub async fn validate_skill_directory(&mut self, skill_path: &Path) -> Result<ValidationReport> {
249 let start_time = Instant::now();
250 let mut checks = HashMap::new();
251 info!("Validating skill directory: {}", skill_path.display());
254
255 let check_result = self.check_directory_exists(skill_path).await;
257 checks.insert("directory_exists".to_string(), check_result);
258
259 let skill_file = skill_path.join("SKILL.md");
261 let check_result = self.validate_skill_file(&skill_file).await;
262 checks.insert("skill_file_valid".to_string(), check_result.clone());
263
264 let skill_name = if let Some(manifest) = &check_result.details {
265 manifest.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string()
266 } else {
267 "unknown".to_string()
268 };
269
270 let scripts_dir = skill_path.join("scripts");
272 if scripts_dir.exists() {
273 let check_result = self.validate_scripts_directory(&scripts_dir).await;
274 checks.insert("scripts_valid".to_string(), check_result);
275 }
276
277 let resources_result = self.validate_resources(skill_path).await;
279 for (name, result) in resources_result {
280 checks.insert(format!("resource_{name}"), result);
281 }
282
283 let security = self.assess_security(&checks);
285
286 let recommendations = self.generate_recommendations(&checks, &security);
288
289 let status = self.determine_overall_status(&checks, &security);
291
292 let performance = PerformanceMetrics {
293 total_time_ms: start_time.elapsed().as_millis() as u64,
294 ..Default::default()
295 };
296
297 Ok(ValidationReport {
298 status,
299 skill_name,
300 timestamp: chrono::Utc::now(),
301 checks,
302 performance,
303 security,
304 recommendations,
305 })
306 }
307
308 pub async fn validate_cli_tool(&mut self, config: &CliToolConfig) -> Result<ValidationReport> {
310 let start_time = Instant::now();
311 let mut checks = HashMap::new();
312
313 info!("Validating CLI tool: {}", config.name);
314
315 let check_result = self.check_executable_exists(&config.executable_path).await;
317 checks.insert("executable_exists".to_string(), check_result);
318
319 let check_result = self.check_executable_permissions(&config.executable_path).await;
321 checks.insert("executable_permissions".to_string(), check_result);
322
323 if let Some(readme_path) = &config.readme_path {
325 let check_result = self.validate_readme_file(readme_path).await;
326 checks.insert("readme_valid".to_string(), check_result);
327 }
328
329 if let Some(schema_path) = &config.schema_path {
331 let check_result = self.validate_json_schema(schema_path).await;
332 checks.insert("schema_valid".to_string(), check_result);
333 }
334
335 let check_result = self.test_tool_execution(config).await;
337 checks.insert("tool_executable".to_string(), check_result);
338
339 let security = self.assess_security(&checks);
341
342 let recommendations = self.generate_recommendations(&checks, &security);
344
345 let status = self.determine_overall_status(&checks, &security);
347
348 let performance = PerformanceMetrics {
349 total_time_ms: start_time.elapsed().as_millis() as u64,
350 ..Default::default()
351 };
352
353 Ok(ValidationReport {
354 status,
355 skill_name: config.name.clone(),
356 timestamp: chrono::Utc::now(),
357 checks,
358 performance,
359 security,
360 recommendations,
361 })
362 }
363
364 async fn check_directory_exists(&self, path: &Path) -> CheckResult {
366 let start_time = Instant::now();
367
368 let status = if tokio::fs::metadata(path).await.is_ok_and(|metadata| metadata.is_dir()) {
369 CheckStatus::Passed
370 } else {
371 CheckStatus::Failed
372 };
373
374 let message = if status == CheckStatus::Passed {
375 format!("Directory exists: {}", path.display())
376 } else {
377 format!("Directory does not exist: {}", path.display())
378 };
379
380 CheckResult {
381 name: "directory_exists".to_string(),
382 status,
383 message,
384 details: None,
385 execution_time_ms: start_time.elapsed().as_millis() as u64,
386 }
387 }
388
389 async fn validate_skill_file(&mut self, skill_file: &Path) -> CheckResult {
391 let start_time = Instant::now();
392
393 if !skill_file.exists() {
394 return CheckResult {
395 name: "skill_file_valid".to_string(),
396 status: CheckStatus::Failed,
397 message: format!("SKILL.md file not found: {}", skill_file.display()),
398 details: None,
399 execution_time_ms: start_time.elapsed().as_millis() as u64,
400 };
401 }
402
403 match parse_skill_file(skill_file.parent().unwrap_or_else(|| Path::new("."))) {
404 Ok((manifest, _instructions)) => {
405 if let Err(err) = manifest.validate() {
406 let mut details = serde_json::Map::new();
407 details.insert("name".to_string(), Value::String(manifest.name.clone()));
408 details.insert("description".to_string(), Value::String(manifest.description.clone()));
409 return CheckResult {
410 name: "skill_file_valid".to_string(),
411 status: CheckStatus::Failed,
412 message: format!("SKILL.md validation failed: {err}"),
413 details: Some(Value::Object(details)),
414 execution_time_ms: start_time.elapsed().as_millis() as u64,
415 };
416 }
417
418 let mut warnings = vec![];
420
421 for field in &self.config.required_metadata_fields {
422 match field.as_str() {
423 "name" => {
424 if manifest.name.is_empty() {
425 warnings.push("Skill name is empty");
426 }
427 }
428 "description" if manifest.description.is_empty() => {
429 warnings.push("Skill description is empty");
430 }
431 _ => {}
432 }
433 }
434
435 let status = if warnings.is_empty() {
436 CheckStatus::Passed
437 } else {
438 CheckStatus::Warning
439 };
440
441 let message = if status == CheckStatus::Passed {
442 format!("SKILL.md is valid: {}", manifest.name)
443 } else {
444 format!("SKILL.md has warnings: {}", warnings.join(", "))
445 };
446
447 let mut details = serde_json::Map::new();
448 details.insert("name".to_string(), Value::String(manifest.name.clone()));
449 details.insert("description".to_string(), Value::String(manifest.description.clone()));
450 details.insert(
451 "warnings".to_string(),
452 serde_json::to_value(&warnings).unwrap_or_else(|_| Value::Array(vec![])),
453 );
454
455 CheckResult {
456 name: "skill_file_valid".to_string(),
457 status,
458 message,
459 details: Some(Value::Object(details)),
460 execution_time_ms: start_time.elapsed().as_millis() as u64,
461 }
462 }
463 Err(e) => CheckResult {
464 name: "skill_file_valid".to_string(),
465 status: CheckStatus::Failed,
466 message: format!("Failed to parse SKILL.md: {e:#}"),
467 details: None,
468 execution_time_ms: start_time.elapsed().as_millis() as u64,
469 },
470 }
471 }
472
473 async fn validate_scripts_directory(&self, scripts_dir: &Path) -> CheckResult {
475 let start_time = Instant::now();
476 let mut issues = vec![];
477
478 let mut entries = match tokio::fs::read_dir(scripts_dir).await {
479 Ok(entries) => entries,
480 Err(error) => {
481 return CheckResult {
482 name: "scripts_valid".to_string(),
483 status: CheckStatus::Failed,
484 message: format!("Failed to read scripts directory {}: {}", scripts_dir.display(), error),
485 details: None,
486 execution_time_ms: start_time.elapsed().as_millis() as u64,
487 };
488 }
489 };
490 loop {
491 let entry = match entries.next_entry().await {
492 Ok(Some(entry)) => entry,
493 Ok(None) => break,
494 Err(e) => {
495 return CheckResult {
496 name: "scripts_valid".to_string(),
497 status: CheckStatus::Failed,
498 message: format!("Failed to read scripts directory entry: {e}"),
499 details: None,
500 execution_time_ms: start_time.elapsed().as_millis() as u64,
501 };
502 }
503 };
504 let path = entry.path();
505 if entry.file_type().await.is_ok_and(|file_type| file_type.is_file()) {
506 if let Some(metadata) = entry
508 .metadata()
509 .await
510 .ok()
511 .filter(|m| m.len() > self.config.max_script_size as u64)
512 {
513 issues.push(format!("Script too large: {} ({} bytes)", path.display(), metadata.len()));
514 }
515
516 if let Some(ext) = path
518 .extension()
519 .and_then(|e| e.to_str())
520 .filter(|e| !self.config.allowed_script_extensions.contains(&e.to_string()))
521 {
522 issues.push(format!("Unsupported script type: {ext}"));
523 }
524
525 if self.config.enable_security_checks
527 && let Ok(content) = read_file_with_context_sync(&path, "skill script")
528 {
529 for blocked in &self.config.blocked_commands {
530 if content.contains(blocked) {
531 issues.push(format!("Potentially dangerous content found: {blocked}"));
532 }
533 }
534 }
535 }
536 }
537
538 let status = if issues.is_empty() {
539 CheckStatus::Passed
540 } else {
541 CheckStatus::Warning
542 };
543
544 let message = if status == CheckStatus::Passed {
545 "Scripts directory is valid".to_string()
546 } else {
547 format!("Scripts directory has issues: {}", issues.join(", "))
548 };
549
550 CheckResult {
551 name: "scripts_valid".to_string(),
552 status,
553 message,
554 details: Some(serde_json::to_value(&issues).unwrap_or_else(|_| Value::Array(vec![]))),
555 execution_time_ms: start_time.elapsed().as_millis() as u64,
556 }
557 }
558
559 async fn validate_resources(&self, skill_path: &Path) -> HashMap<String, CheckResult> {
561 let mut results = HashMap::new();
562
563 for resource_dir in &["templates", "data", "config"] {
565 let dir_path = skill_path.join(resource_dir);
566 if dir_path.exists() {
567 let result = self.validate_resource_directory(&dir_path, resource_dir).await;
568 results.insert(resource_dir.to_string(), result);
569 }
570 }
571
572 results
573 }
574
575 async fn validate_resource_directory(&self, dir_path: &Path, resource_type: &str) -> CheckResult {
577 let start_time = Instant::now();
578
579 let mut issues = vec![];
580
581 let mut entries = match tokio::fs::read_dir(dir_path).await {
582 Ok(entries) => entries,
583 Err(error) => {
584 return CheckResult {
585 name: format!("resource_{resource_type}"),
586 status: CheckStatus::Failed,
587 message: format!("Failed to read resource directory {}: {}", dir_path.display(), error),
588 details: None,
589 execution_time_ms: start_time.elapsed().as_millis() as u64,
590 };
591 }
592 };
593 loop {
594 let entry = match entries.next_entry().await {
595 Ok(Some(entry)) => entry,
596 Ok(None) => break,
597 Err(e) => {
598 return CheckResult {
599 name: format!("resource_{resource_type}"),
600 status: CheckStatus::Failed,
601 message: format!("Failed to read resource directory entry: {e}"),
602 details: None,
603 execution_time_ms: start_time.elapsed().as_millis() as u64,
604 };
605 }
606 };
607 let path = entry.path();
608 if entry.file_type().await.is_ok_and(|file_type| file_type.is_file()) {
609 if let Some(metadata) = entry.metadata().await.ok().filter(|m| m.len() > 10 * 1024 * 1024) {
611 issues.push(format!("Resource file too large: {} ({} bytes)", path.display(), metadata.len()));
613 }
614 }
615 }
616
617 let status = if issues.is_empty() {
618 CheckStatus::Passed
619 } else {
620 CheckStatus::Warning
621 };
622
623 let message = if status == CheckStatus::Passed {
624 format!("{resource_type} directory is valid")
625 } else {
626 format!("{} directory has issues: {}", resource_type, issues.join(", "))
627 };
628
629 CheckResult {
630 name: format!("resource_{resource_type}"),
631 status,
632 message,
633 details: Some(serde_json::to_value(&issues).unwrap_or_else(|_| Value::Array(vec![]))),
634 execution_time_ms: start_time.elapsed().as_millis() as u64,
635 }
636 }
637
638 async fn check_executable_exists(&self, path: &Path) -> CheckResult {
640 let start_time = Instant::now();
641
642 let status = if tokio::fs::try_exists(path).await.unwrap_or(false) {
643 CheckStatus::Passed
644 } else {
645 CheckStatus::Failed
646 };
647
648 let message = if status == CheckStatus::Passed {
649 format!("Executable exists: {}", path.display())
650 } else {
651 format!("Executable not found: {}", path.display())
652 };
653
654 CheckResult {
655 name: "executable_exists".to_string(),
656 status,
657 message,
658 details: None,
659 execution_time_ms: start_time.elapsed().as_millis() as u64,
660 }
661 }
662
663 #[cfg_attr(
665 not(unix),
666 allow(
667 unused_variables,
668 reason = "Intentional compatibility, platform, or test-only suppression."
669 )
670 )]
671 async fn check_executable_permissions(&self, path: &Path) -> CheckResult {
672 let start_time = Instant::now();
673
674 #[cfg(unix)]
675 {
676 use std::os::unix::fs::PermissionsExt;
677
678 if let Ok(metadata) = tokio::fs::metadata(path).await {
679 let permissions = metadata.permissions();
680 let is_executable = permissions.mode() & 0o111 != 0;
681
682 let status = if is_executable {
683 CheckStatus::Passed
684 } else {
685 CheckStatus::Failed
686 };
687
688 let message = if status == CheckStatus::Passed {
689 "Executable has proper permissions".to_string()
690 } else {
691 "Executable lacks execute permissions".to_string()
692 };
693
694 return CheckResult {
695 name: "executable_permissions".to_string(),
696 status,
697 message,
698 details: None,
699 execution_time_ms: start_time.elapsed().as_millis() as u64,
700 };
701 }
702 }
703
704 CheckResult {
706 name: "executable_permissions".to_string(),
707 status: CheckStatus::Passed,
708 message: "Permission check skipped (Windows or metadata error)".to_string(),
709 details: None,
710 execution_time_ms: start_time.elapsed().as_millis() as u64,
711 }
712 }
713
714 async fn validate_readme_file(&self, readme_path: &Path) -> CheckResult {
716 let start_time = Instant::now();
717
718 if !readme_path.exists() {
719 return CheckResult {
720 name: "readme_valid".to_string(),
721 status: CheckStatus::Warning,
722 message: "README file not found".to_string(),
723 details: None,
724 execution_time_ms: start_time.elapsed().as_millis() as u64,
725 };
726 }
727
728 match read_file_with_context_sync(readme_path, "skill README") {
729 Ok(content) => {
730 if content.len() < 100 {
731 CheckResult {
732 name: "readme_valid".to_string(),
733 status: CheckStatus::Warning,
734 message: "README file is very short".to_string(),
735 details: Some(serde_json::json!({"length": content.len()})),
736 execution_time_ms: start_time.elapsed().as_millis() as u64,
737 }
738 } else {
739 CheckResult {
740 name: "readme_valid".to_string(),
741 status: CheckStatus::Passed,
742 message: "README file is valid".to_string(),
743 details: Some(serde_json::json!({"length": content.len()})),
744 execution_time_ms: start_time.elapsed().as_millis() as u64,
745 }
746 }
747 }
748 Err(e) => CheckResult {
749 name: "readme_valid".to_string(),
750 status: CheckStatus::Failed,
751 message: format!("Failed to read README: {e}"),
752 details: None,
753 execution_time_ms: start_time.elapsed().as_millis() as u64,
754 },
755 }
756 }
757
758 async fn validate_json_schema(&mut self, schema_path: &Path) -> CheckResult {
760 let start_time = Instant::now();
761
762 if !schema_path.exists() {
763 return CheckResult {
764 name: "schema_valid".to_string(),
765 status: CheckStatus::Warning,
766 message: "Schema file not found".to_string(),
767 details: None,
768 execution_time_ms: start_time.elapsed().as_millis() as u64,
769 };
770 }
771
772 if let Ok(metadata) = tokio::fs::metadata(schema_path).await
774 && let Ok(mtime) = metadata.modified()
775 && let Some((cached_mtime, cached_result)) = self.schema_validation_cache.get(schema_path)
776 && *cached_mtime == mtime
777 {
778 let mut result = cached_result.clone();
779 result.execution_time_ms = start_time.elapsed().as_millis() as u64;
781 result.message = format!("{} (cached)", result.message);
782 return result;
783 }
784
785 let result = match read_file_with_context_sync(schema_path, "skill JSON schema") {
786 Ok(content) => {
787 match serde_json::from_str::<Value>(&content) {
788 Ok(schema_json) => {
789 match jsonschema::validator_for(&schema_json) {
791 Ok(_validator) => CheckResult {
792 name: "schema_valid".to_string(),
793 status: CheckStatus::Passed,
794 message: "JSON schema is valid and compilable".to_string(),
795 details: None,
796 execution_time_ms: start_time.elapsed().as_millis() as u64,
797 },
798 Err(e) => CheckResult {
799 name: "schema_valid".to_string(),
800 status: CheckStatus::Failed,
801 message: format!("Invalid JSON Schema: {e}"),
802 details: Some(
803 serde_json::json!({"error": format!("Schema compilation failed: {}", e)}),
804 ),
805 execution_time_ms: start_time.elapsed().as_millis() as u64,
806 },
807 }
808 }
809 Err(e) => CheckResult {
810 name: "schema_valid".to_string(),
811 status: CheckStatus::Failed,
812 message: format!("Invalid JSON in schema file: {e}"),
813 details: None,
814 execution_time_ms: start_time.elapsed().as_millis() as u64,
815 },
816 }
817 }
818 Err(e) => CheckResult {
819 name: "schema_valid".to_string(),
820 status: CheckStatus::Failed,
821 message: format!("Failed to read schema file: {e}"),
822 details: None,
823 execution_time_ms: start_time.elapsed().as_millis() as u64,
824 },
825 };
826
827 if let Ok(metadata) = tokio::fs::metadata(schema_path).await
829 && let Ok(mtime) = metadata.modified()
830 {
831 self.schema_validation_cache
832 .insert(schema_path.to_path_buf(), (mtime, result.clone()));
833 }
834
835 result
836 }
837
838 async fn test_tool_execution(&self, config: &CliToolConfig) -> CheckResult {
840 let start_time = Instant::now();
841
842 let exec_path = config.executable_path.clone();
846
847 let output =
849 tokio::task::spawn_blocking(move || std::process::Command::new(&exec_path).arg("--help").output()).await;
850
851 match output {
852 Ok(Ok(output)) if output.status.success() => CheckResult {
853 name: "tool_executable".to_string(),
854 status: CheckStatus::Passed,
855 message: "Tool executed successfully with --help".to_string(),
856 details: None,
857 execution_time_ms: start_time.elapsed().as_millis() as u64,
858 },
859 Ok(Ok(_)) => {
860 let exec_path = config.executable_path.clone();
862 match tokio::task::spawn_blocking(move || std::process::Command::new(&exec_path).arg("-h").output())
863 .await
864 {
865 Ok(Ok(output)) if output.status.success() => CheckResult {
866 name: "tool_executable".to_string(),
867 status: CheckStatus::Passed,
868 message: "Tool executed successfully with -h".to_string(),
869 details: None,
870 execution_time_ms: start_time.elapsed().as_millis() as u64,
871 },
872 Ok(Ok(_)) => CheckResult {
873 name: "tool_executable".to_string(),
874 status: CheckStatus::Warning,
875 message: "Tool executed but returned non-zero exit code".to_string(),
876 details: None,
877 execution_time_ms: start_time.elapsed().as_millis() as u64,
878 },
879 Ok(Err(e)) => CheckResult {
880 name: "tool_executable".to_string(),
881 status: CheckStatus::Failed,
882 message: format!("Failed to execute tool: {e}"),
883 details: None,
884 execution_time_ms: start_time.elapsed().as_millis() as u64,
885 },
886 Err(e) => CheckResult {
887 name: "tool_executable".to_string(),
888 status: CheckStatus::Failed,
889 message: format!("Validation task failed: {e}"),
890 details: None,
891 execution_time_ms: start_time.elapsed().as_millis() as u64,
892 },
893 }
894 }
895 Ok(Err(e)) => CheckResult {
896 name: "tool_executable".to_string(),
897 status: CheckStatus::Failed,
898 message: format!("Failed to execute tool: {e}"),
899 details: None,
900 execution_time_ms: start_time.elapsed().as_millis() as u64,
901 },
902 Err(e) => CheckResult {
903 name: "tool_executable".to_string(),
904 status: CheckStatus::Failed,
905 message: format!("Validation task failed: {e}"),
906 details: None,
907 execution_time_ms: start_time.elapsed().as_millis() as u64,
908 },
909 }
910 }
911
912 fn assess_security(&self, checks: &HashMap<String, CheckResult>) -> SecurityAssessment {
914 let mut warnings = vec![];
915 let blocked_content = vec![];
916 let mut security_level = SecurityLevel::Safe;
917
918 if let Some(scripts_check) = checks.get("scripts_valid")
920 && scripts_check.status == CheckStatus::Warning
921 && let Some(details) = &scripts_check.details
922 && let Some(issues) = details.as_array()
923 {
924 for issue in issues {
925 if let Some(issue_str) = issue.as_str()
926 && issue_str.contains("dangerous")
927 {
928 warnings.push(SecurityWarning {
929 warning_type: "dangerous_content".to_string(),
930 message: issue_str.to_string(),
931 severity: SecurityLevel::HighRisk,
932 suggestion: Some("Review script content for security issues".to_string()),
933 });
934 security_level = SecurityLevel::HighRisk;
935 }
936 }
937 }
938
939 let safe_to_execute = security_level != SecurityLevel::HighRisk;
940
941 SecurityAssessment {
942 security_level,
943 warnings,
944 blocked_content,
945 safe_to_execute,
946 }
947 }
948
949 fn generate_recommendations(
951 &self,
952 checks: &HashMap<String, CheckResult>,
953 security: &SecurityAssessment,
954 ) -> Vec<String> {
955 let mut recommendations = vec![];
956
957 for check in checks.values() {
959 match check.status {
960 CheckStatus::Warning => {
961 recommendations.push(format!("Address warning in {}: {}", check.name, check.message));
962 }
963 CheckStatus::Failed => {
964 recommendations.push(format!("Fix failed check {}: {}", check.name, check.message));
965 }
966 _ => {}
967 }
968 }
969
970 if security.security_level == SecurityLevel::HighRisk {
972 recommendations.push("Review and fix security issues before using this skill".to_string());
973 }
974
975 if let Some(loading_check) = checks.get("skill_file_valid")
977 && loading_check.execution_time_ms > 1000
978 {
979 recommendations.push("Consider optimizing skill file parsing performance".to_string());
980 }
981
982 recommendations
983 }
984
985 fn determine_overall_status(
987 &self,
988 checks: &HashMap<String, CheckResult>,
989 security: &SecurityAssessment,
990 ) -> ValidationStatus {
991 let has_failures = checks.values().any(|check| check.status == CheckStatus::Failed);
992 let has_warnings = checks.values().any(|check| check.status == CheckStatus::Warning);
993 let has_high_risk = security.security_level == SecurityLevel::HighRisk;
994
995 if has_failures || has_high_risk {
996 ValidationStatus::Invalid
997 } else if has_warnings {
998 ValidationStatus::Warning
999 } else {
1000 ValidationStatus::Valid
1001 }
1002 }
1003
1004 pub async fn validate_batch(&mut self, skill_paths: Vec<&Path>) -> Vec<Result<ValidationReport>> {
1006 let mut results = vec![];
1007
1008 for path in skill_paths {
1009 let result = self.validate_skill_directory(path).await;
1010 results.push(result);
1011 }
1012
1013 results
1014 }
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1019pub struct BatchValidationResult {
1020 pub total_skills: usize,
1022
1023 pub valid_skills: Vec<String>,
1025
1026 pub warning_skills: Vec<String>,
1028
1029 pub invalid_skills: Vec<String>,
1031
1032 pub average_validation_time_ms: u64,
1034
1035 pub reports: Vec<ValidationReport>,
1037}
1038
1039pub async fn validate_skill_batch(skill_paths: Vec<&Path>) -> Result<BatchValidationResult> {
1041 let mut validator = SkillValidator::new();
1042 let mut reports = vec![];
1043 let mut total_time = 0u64;
1044
1045 for path in skill_paths {
1046 match validator.validate_skill_directory(path).await {
1047 Ok(report) => {
1048 total_time += report.performance.total_time_ms;
1049 reports.push(report);
1050 }
1051 Err(e) => {
1052 let error_report = ValidationReport {
1054 status: ValidationStatus::Invalid,
1055 skill_name: path.to_string_lossy().to_string(),
1056 timestamp: chrono::Utc::now(),
1057 checks: HashMap::new(),
1058 performance: PerformanceMetrics::default(),
1059 security: SecurityAssessment::default(),
1060 recommendations: vec![format!("Validation failed: {}", e)],
1061 };
1062 reports.push(error_report);
1063 }
1064 }
1065 }
1066
1067 let valid_skills: Vec<String> = reports
1068 .iter()
1069 .filter(|r| r.status == ValidationStatus::Valid)
1070 .map(|r| r.skill_name.clone())
1071 .collect();
1072
1073 let warning_skills: Vec<String> = reports
1074 .iter()
1075 .filter(|r| r.status == ValidationStatus::Warning)
1076 .map(|r| r.skill_name.clone())
1077 .collect();
1078
1079 let invalid_skills: Vec<String> = reports
1080 .iter()
1081 .filter(|r| r.status == ValidationStatus::Invalid)
1082 .map(|r| r.skill_name.clone())
1083 .collect();
1084
1085 let average_time = if !reports.is_empty() {
1086 total_time / reports.len() as u64
1087 } else {
1088 0
1089 };
1090
1091 Ok(BatchValidationResult {
1092 total_skills: reports.len(),
1093 valid_skills,
1094 warning_skills,
1095 invalid_skills,
1096 average_validation_time_ms: average_time,
1097 reports,
1098 })
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use tempfile::TempDir;
1105
1106 #[test]
1107 fn test_validation_config_default() {
1108 let config = ValidationConfig::default();
1109 assert!(config.enable_security_checks);
1110 assert!(config.enable_performance_checks);
1111 assert_eq!(config.max_validation_time, 30);
1112 }
1113
1114 #[tokio::test]
1115 async fn test_validator_creation() {
1116 let _validator = SkillValidator::new();
1117 }
1119
1120 #[tokio::test]
1121 async fn test_invalid_skill_directory() {
1122 let mut validator = SkillValidator::new();
1123 let temp_dir = TempDir::new().unwrap();
1124 let non_existent = temp_dir.path().join("non_existent");
1125
1126 let result = validator.validate_skill_directory(&non_existent).await;
1127 assert!(result.is_ok());
1128
1129 let report = result.unwrap();
1130 assert_eq!(report.status, ValidationStatus::Invalid);
1131 }
1132
1133 #[tokio::test]
1134 async fn test_skill_validation_rejects_hooks() {
1135 let temp_dir = TempDir::new().unwrap();
1136 let skill_dir = temp_dir.path().join("hook-skill");
1137 std::fs::create_dir_all(&skill_dir).unwrap();
1138
1139 let skill_md = r#"---
1140name: hook-skill
1141description: Skill with hooks
1142hooks:
1143 pre_tool_use:
1144 - command: "echo pre"
1145---
1146# Hook Skill
1147"#;
1148 std::fs::write(skill_dir.join("SKILL.md"), skill_md).unwrap();
1149
1150 let mut validator = SkillValidator::new();
1151 let report = validator.validate_skill_directory(&skill_dir).await.unwrap();
1152
1153 assert_eq!(report.status, ValidationStatus::Invalid);
1154 let check = report.checks.get("skill_file_valid").unwrap();
1155 assert_eq!(check.status, CheckStatus::Failed);
1156 assert!(check.message.contains("hooks"));
1157 }
1158
1159 #[tokio::test]
1160 async fn test_schema_validation_caching() {
1161 use std::fs::File;
1162 use std::io::Write;
1163
1164 let temp_dir = TempDir::new().unwrap();
1165 let schema_path = temp_dir.path().join("schema.json");
1166
1167 let sleep_fs = || std::thread::sleep(std::time::Duration::from_millis(50));
1169
1170 {
1172 let mut file = File::create(&schema_path).unwrap();
1173 write!(file, r#"{{"type": "string"}}"#).unwrap();
1174 }
1175 sleep_fs();
1176
1177 let mut validator = SkillValidator::new();
1178
1179 let result1 = validator.validate_json_schema(&schema_path).await;
1181 assert_eq!(result1.status, CheckStatus::Passed);
1182 assert_eq!(validator.schema_validation_cache.len(), 1);
1183
1184 let (cached_mtime, _) = validator.schema_validation_cache.get(&schema_path).unwrap();
1186 let cached_mtime = *cached_mtime;
1187
1188 let result2 = validator.validate_json_schema(&schema_path).await;
1190 assert_eq!(result2.status, CheckStatus::Passed);
1191 assert_eq!(validator.schema_validation_cache.get(&schema_path).unwrap().0, cached_mtime);
1193
1194 sleep_fs();
1196 {
1197 let mut file = File::create(&schema_path).unwrap();
1198 write!(file, r#"{{"type": "integer"}}"#).unwrap();
1199 }
1200 sleep_fs();
1201
1202 let result3 = validator.validate_json_schema(&schema_path).await;
1203 assert_eq!(result3.status, CheckStatus::Passed);
1204
1205 let (new_mtime, _) = validator.schema_validation_cache.get(&schema_path).unwrap();
1206 assert_ne!(*new_mtime, cached_mtime, "Cache should have updated with new mtime");
1207 }
1208}