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 {
245 config,
246 schema_validation_cache: HashMap::new(),
247 }
248 }
249
250 pub async fn validate_skill_directory(
252 &mut self,
253 skill_path: &Path,
254 ) -> Result<ValidationReport> {
255 let start_time = Instant::now();
256 let mut checks = HashMap::new();
257 info!("Validating skill directory: {}", skill_path.display());
260
261 let check_result = self.check_directory_exists(skill_path).await;
263 checks.insert("directory_exists".to_string(), check_result);
264
265 let skill_file = skill_path.join("SKILL.md");
267 let check_result = self.validate_skill_file(&skill_file).await;
268 checks.insert("skill_file_valid".to_string(), check_result.clone());
269
270 let skill_name = if let Some(manifest) = &check_result.details {
271 manifest
272 .get("name")
273 .and_then(|v| v.as_str())
274 .unwrap_or("unknown")
275 .to_string()
276 } else {
277 "unknown".to_string()
278 };
279
280 let scripts_dir = skill_path.join("scripts");
282 if scripts_dir.exists() {
283 let check_result = self.validate_scripts_directory(&scripts_dir).await;
284 checks.insert("scripts_valid".to_string(), check_result);
285 }
286
287 let resources_result = self.validate_resources(skill_path).await;
289 for (name, result) in resources_result {
290 checks.insert(format!("resource_{}", name), result);
291 }
292
293 let security = self.assess_security(&checks);
295
296 let recommendations = self.generate_recommendations(&checks, &security);
298
299 let status = self.determine_overall_status(&checks, &security);
301
302 let performance = PerformanceMetrics {
303 total_time_ms: start_time.elapsed().as_millis() as u64,
304 ..Default::default()
305 };
306
307 Ok(ValidationReport {
308 status,
309 skill_name,
310 timestamp: chrono::Utc::now(),
311 checks,
312 performance,
313 security,
314 recommendations,
315 })
316 }
317
318 pub async fn validate_cli_tool(&mut self, config: &CliToolConfig) -> Result<ValidationReport> {
320 let start_time = Instant::now();
321 let mut checks = HashMap::new();
322
323 info!("Validating CLI tool: {}", config.name);
324
325 let check_result = self.check_executable_exists(&config.executable_path).await;
327 checks.insert("executable_exists".to_string(), check_result);
328
329 let check_result = self
331 .check_executable_permissions(&config.executable_path)
332 .await;
333 checks.insert("executable_permissions".to_string(), check_result);
334
335 if let Some(readme_path) = &config.readme_path {
337 let check_result = self.validate_readme_file(readme_path).await;
338 checks.insert("readme_valid".to_string(), check_result);
339 }
340
341 if let Some(schema_path) = &config.schema_path {
343 let check_result = self.validate_json_schema(schema_path).await;
344 checks.insert("schema_valid".to_string(), check_result);
345 }
346
347 let check_result = self.test_tool_execution(config).await;
349 checks.insert("tool_executable".to_string(), check_result);
350
351 let security = self.assess_security(&checks);
353
354 let recommendations = self.generate_recommendations(&checks, &security);
356
357 let status = self.determine_overall_status(&checks, &security);
359
360 let performance = PerformanceMetrics {
361 total_time_ms: start_time.elapsed().as_millis() as u64,
362 ..Default::default()
363 };
364
365 Ok(ValidationReport {
366 status,
367 skill_name: config.name.clone(),
368 timestamp: chrono::Utc::now(),
369 checks,
370 performance,
371 security,
372 recommendations,
373 })
374 }
375
376 async fn check_directory_exists(&self, path: &Path) -> CheckResult {
378 let start_time = Instant::now();
379
380 let status = if path.exists() && path.is_dir() {
381 CheckStatus::Passed
382 } else {
383 CheckStatus::Failed
384 };
385
386 let message = if status == CheckStatus::Passed {
387 format!("Directory exists: {}", path.display())
388 } else {
389 format!("Directory does not exist: {}", path.display())
390 };
391
392 CheckResult {
393 name: "directory_exists".to_string(),
394 status,
395 message,
396 details: None,
397 execution_time_ms: start_time.elapsed().as_millis() as u64,
398 }
399 }
400
401 async fn validate_skill_file(&mut self, skill_file: &Path) -> CheckResult {
403 let start_time = Instant::now();
404
405 if !skill_file.exists() {
406 return CheckResult {
407 name: "skill_file_valid".to_string(),
408 status: CheckStatus::Failed,
409 message: format!("SKILL.md file not found: {}", skill_file.display()),
410 details: None,
411 execution_time_ms: start_time.elapsed().as_millis() as u64,
412 };
413 }
414
415 match parse_skill_file(skill_file.parent().unwrap_or_else(|| Path::new("."))) {
416 Ok((manifest, _instructions)) => {
417 if let Err(err) = manifest.validate() {
418 let mut details = serde_json::Map::new();
419 details.insert("name".to_string(), Value::String(manifest.name.clone()));
420 details.insert(
421 "description".to_string(),
422 Value::String(manifest.description.clone()),
423 );
424 return CheckResult {
425 name: "skill_file_valid".to_string(),
426 status: CheckStatus::Failed,
427 message: format!("SKILL.md validation failed: {}", err),
428 details: Some(Value::Object(details)),
429 execution_time_ms: start_time.elapsed().as_millis() as u64,
430 };
431 }
432
433 let mut warnings = vec![];
435
436 for field in &self.config.required_metadata_fields {
437 match field.as_str() {
438 "name" => {
439 if manifest.name.is_empty() {
440 warnings.push("Skill name is empty");
441 }
442 }
443 "description" => {
444 if manifest.description.is_empty() {
445 warnings.push("Skill description is empty");
446 }
447 }
448 _ => {}
449 }
450 }
451
452 let status = if warnings.is_empty() {
453 CheckStatus::Passed
454 } else {
455 CheckStatus::Warning
456 };
457
458 let message = if status == CheckStatus::Passed {
459 format!("SKILL.md is valid: {}", manifest.name)
460 } else {
461 format!("SKILL.md has warnings: {}", warnings.join(", "))
462 };
463
464 let mut details = serde_json::Map::new();
465 details.insert("name".to_string(), Value::String(manifest.name.clone()));
466 details.insert(
467 "description".to_string(),
468 Value::String(manifest.description.clone()),
469 );
470 details.insert(
471 "warnings".to_string(),
472 serde_json::to_value(&warnings).unwrap_or_else(|_| Value::Array(vec![])),
473 );
474
475 CheckResult {
476 name: "skill_file_valid".to_string(),
477 status,
478 message,
479 details: Some(Value::Object(details)),
480 execution_time_ms: start_time.elapsed().as_millis() as u64,
481 }
482 }
483 Err(e) => CheckResult {
484 name: "skill_file_valid".to_string(),
485 status: CheckStatus::Failed,
486 message: format!("Failed to parse SKILL.md: {:#}", e),
487 details: None,
488 execution_time_ms: start_time.elapsed().as_millis() as u64,
489 },
490 }
491 }
492
493 async fn validate_scripts_directory(&self, scripts_dir: &Path) -> CheckResult {
495 let start_time = Instant::now();
496 let mut issues = vec![];
497
498 let mut entries = match tokio::fs::read_dir(scripts_dir).await {
499 Ok(entries) => entries,
500 Err(error) => {
501 return CheckResult {
502 name: "scripts_valid".to_string(),
503 status: CheckStatus::Failed,
504 message: format!(
505 "Failed to read scripts directory {}: {}",
506 scripts_dir.display(),
507 error
508 ),
509 details: None,
510 execution_time_ms: start_time.elapsed().as_millis() as u64,
511 };
512 }
513 };
514 loop {
515 let entry = match entries.next_entry().await {
516 Ok(Some(entry)) => entry,
517 Ok(None) => break,
518 Err(e) => {
519 return CheckResult {
520 name: "scripts_valid".to_string(),
521 status: CheckStatus::Failed,
522 message: format!("Failed to read scripts directory entry: {}", e),
523 details: None,
524 execution_time_ms: start_time.elapsed().as_millis() as u64,
525 };
526 }
527 };
528 let path = entry.path();
529 if path.is_file() {
530 if let Some(metadata) = entry
532 .metadata()
533 .await
534 .ok()
535 .filter(|m| m.len() > self.config.max_script_size as u64)
536 {
537 issues.push(format!(
538 "Script too large: {} ({} bytes)",
539 path.display(),
540 metadata.len()
541 ));
542 }
543
544 if let Some(ext) = path.extension().and_then(|e| e.to_str()).filter(|e| {
546 !self
547 .config
548 .allowed_script_extensions
549 .contains(&e.to_string())
550 }) {
551 issues.push(format!("Unsupported script type: {}", ext));
552 }
553
554 if self.config.enable_security_checks
556 && let Ok(content) = read_file_with_context_sync(&path, "skill script")
557 {
558 for blocked in &self.config.blocked_commands {
559 if content.contains(blocked) {
560 issues
561 .push(format!("Potentially dangerous content found: {}", blocked));
562 }
563 }
564 }
565 }
566 }
567
568 let status = if issues.is_empty() {
569 CheckStatus::Passed
570 } else {
571 CheckStatus::Warning
572 };
573
574 let message = if status == CheckStatus::Passed {
575 "Scripts directory is valid".to_string()
576 } else {
577 format!("Scripts directory has issues: {}", issues.join(", "))
578 };
579
580 CheckResult {
581 name: "scripts_valid".to_string(),
582 status,
583 message,
584 details: Some(serde_json::to_value(&issues).unwrap_or_else(|_| Value::Array(vec![]))),
585 execution_time_ms: start_time.elapsed().as_millis() as u64,
586 }
587 }
588
589 async fn validate_resources(&self, skill_path: &Path) -> HashMap<String, CheckResult> {
591 let mut results = HashMap::new();
592
593 for resource_dir in &["templates", "data", "config"] {
595 let dir_path = skill_path.join(resource_dir);
596 if dir_path.exists() {
597 let result = self
598 .validate_resource_directory(&dir_path, resource_dir)
599 .await;
600 results.insert(resource_dir.to_string(), result);
601 }
602 }
603
604 results
605 }
606
607 async fn validate_resource_directory(
609 &self,
610 dir_path: &Path,
611 resource_type: &str,
612 ) -> CheckResult {
613 let start_time = Instant::now();
614
615 let mut issues = vec![];
616
617 let mut entries = match tokio::fs::read_dir(dir_path).await {
618 Ok(entries) => entries,
619 Err(error) => {
620 return CheckResult {
621 name: format!("resource_{}", resource_type),
622 status: CheckStatus::Failed,
623 message: format!(
624 "Failed to read resource directory {}: {}",
625 dir_path.display(),
626 error
627 ),
628 details: None,
629 execution_time_ms: start_time.elapsed().as_millis() as u64,
630 };
631 }
632 };
633 loop {
634 let entry = match entries.next_entry().await {
635 Ok(Some(entry)) => entry,
636 Ok(None) => break,
637 Err(e) => {
638 return CheckResult {
639 name: format!("resource_{}", resource_type),
640 status: CheckStatus::Failed,
641 message: format!("Failed to read resource directory entry: {}", e),
642 details: None,
643 execution_time_ms: start_time.elapsed().as_millis() as u64,
644 };
645 }
646 };
647 let path = entry.path();
648 if path.is_file() {
649 if let Some(metadata) = entry
651 .metadata()
652 .await
653 .ok()
654 .filter(|m| m.len() > 10 * 1024 * 1024)
655 {
656 issues.push(format!(
658 "Resource file too large: {} ({} bytes)",
659 path.display(),
660 metadata.len()
661 ));
662 }
663 }
664 }
665
666 let status = if issues.is_empty() {
667 CheckStatus::Passed
668 } else {
669 CheckStatus::Warning
670 };
671
672 let message = if status == CheckStatus::Passed {
673 format!("{} directory is valid", resource_type)
674 } else {
675 format!(
676 "{} directory has issues: {}",
677 resource_type,
678 issues.join(", ")
679 )
680 };
681
682 CheckResult {
683 name: format!("resource_{}", resource_type),
684 status,
685 message,
686 details: Some(serde_json::to_value(&issues).unwrap_or_else(|_| Value::Array(vec![]))),
687 execution_time_ms: start_time.elapsed().as_millis() as u64,
688 }
689 }
690
691 async fn check_executable_exists(&self, path: &Path) -> CheckResult {
693 let start_time = Instant::now();
694
695 let status = if path.exists() {
696 CheckStatus::Passed
697 } else {
698 CheckStatus::Failed
699 };
700
701 let message = if status == CheckStatus::Passed {
702 format!("Executable exists: {}", path.display())
703 } else {
704 format!("Executable not found: {}", path.display())
705 };
706
707 CheckResult {
708 name: "executable_exists".to_string(),
709 status,
710 message,
711 details: None,
712 execution_time_ms: start_time.elapsed().as_millis() as u64,
713 }
714 }
715
716 async fn check_executable_permissions(&self, path: &Path) -> CheckResult {
718 let start_time = Instant::now();
719
720 #[cfg(unix)]
721 {
722 use std::os::unix::fs::PermissionsExt;
723
724 if let Ok(metadata) = tokio::fs::metadata(path).await {
725 let permissions = metadata.permissions();
726 let is_executable = permissions.mode() & 0o111 != 0;
727
728 let status = if is_executable {
729 CheckStatus::Passed
730 } else {
731 CheckStatus::Failed
732 };
733
734 let message = if status == CheckStatus::Passed {
735 "Executable has proper permissions".to_string()
736 } else {
737 "Executable lacks execute permissions".to_string()
738 };
739
740 return CheckResult {
741 name: "executable_permissions".to_string(),
742 status,
743 message,
744 details: None,
745 execution_time_ms: start_time.elapsed().as_millis() as u64,
746 };
747 }
748 }
749
750 CheckResult {
752 name: "executable_permissions".to_string(),
753 status: CheckStatus::Passed,
754 message: "Permission check skipped (Windows or metadata error)".to_string(),
755 details: None,
756 execution_time_ms: start_time.elapsed().as_millis() as u64,
757 }
758 }
759
760 async fn validate_readme_file(&self, readme_path: &Path) -> CheckResult {
762 let start_time = Instant::now();
763
764 if !readme_path.exists() {
765 return CheckResult {
766 name: "readme_valid".to_string(),
767 status: CheckStatus::Warning,
768 message: "README file not found".to_string(),
769 details: None,
770 execution_time_ms: start_time.elapsed().as_millis() as u64,
771 };
772 }
773
774 match read_file_with_context_sync(readme_path, "skill README") {
775 Ok(content) => {
776 if content.len() < 100 {
777 CheckResult {
778 name: "readme_valid".to_string(),
779 status: CheckStatus::Warning,
780 message: "README file is very short".to_string(),
781 details: Some(serde_json::json!({"length": content.len()})),
782 execution_time_ms: start_time.elapsed().as_millis() as u64,
783 }
784 } else {
785 CheckResult {
786 name: "readme_valid".to_string(),
787 status: CheckStatus::Passed,
788 message: "README file is valid".to_string(),
789 details: Some(serde_json::json!({"length": content.len()})),
790 execution_time_ms: start_time.elapsed().as_millis() as u64,
791 }
792 }
793 }
794 Err(e) => CheckResult {
795 name: "readme_valid".to_string(),
796 status: CheckStatus::Failed,
797 message: format!("Failed to read README: {}", e),
798 details: None,
799 execution_time_ms: start_time.elapsed().as_millis() as u64,
800 },
801 }
802 }
803
804 async fn validate_json_schema(&mut self, schema_path: &Path) -> CheckResult {
806 let start_time = Instant::now();
807
808 if !schema_path.exists() {
809 return CheckResult {
810 name: "schema_valid".to_string(),
811 status: CheckStatus::Warning,
812 message: "Schema file not found".to_string(),
813 details: None,
814 execution_time_ms: start_time.elapsed().as_millis() as u64,
815 };
816 }
817
818 if let Ok(metadata) = tokio::fs::metadata(schema_path).await
820 && let Ok(mtime) = metadata.modified()
821 && let Some((cached_mtime, cached_result)) =
822 self.schema_validation_cache.get(schema_path)
823 && *cached_mtime == mtime
824 {
825 let mut result = cached_result.clone();
826 result.execution_time_ms = start_time.elapsed().as_millis() as u64;
828 result.message = format!("{} (cached)", result.message);
829 return result;
830 }
831
832 let result = match read_file_with_context_sync(schema_path, "skill JSON schema") {
833 Ok(content) => {
834 match serde_json::from_str::<Value>(&content) {
835 Ok(schema_json) => {
836 match jsonschema::validator_for(&schema_json) {
838 Ok(_validator) => CheckResult {
839 name: "schema_valid".to_string(),
840 status: CheckStatus::Passed,
841 message: "JSON schema is valid and compilable".to_string(),
842 details: None,
843 execution_time_ms: start_time.elapsed().as_millis() as u64,
844 },
845 Err(e) => CheckResult {
846 name: "schema_valid".to_string(),
847 status: CheckStatus::Failed,
848 message: format!("Invalid JSON Schema: {}", e),
849 details: Some(
850 serde_json::json!({"error": format!("Schema compilation failed: {}", e)}),
851 ),
852 execution_time_ms: start_time.elapsed().as_millis() as u64,
853 },
854 }
855 }
856 Err(e) => CheckResult {
857 name: "schema_valid".to_string(),
858 status: CheckStatus::Failed,
859 message: format!("Invalid JSON in schema file: {}", e),
860 details: None,
861 execution_time_ms: start_time.elapsed().as_millis() as u64,
862 },
863 }
864 }
865 Err(e) => CheckResult {
866 name: "schema_valid".to_string(),
867 status: CheckStatus::Failed,
868 message: format!("Failed to read schema file: {}", e),
869 details: None,
870 execution_time_ms: start_time.elapsed().as_millis() as u64,
871 },
872 };
873
874 if let Ok(metadata) = tokio::fs::metadata(schema_path).await
876 && let Ok(mtime) = metadata.modified()
877 {
878 self.schema_validation_cache
879 .insert(schema_path.to_path_buf(), (mtime, result.clone()));
880 }
881
882 result
883 }
884
885 async fn test_tool_execution(&self, config: &CliToolConfig) -> CheckResult {
887 let start_time = Instant::now();
888
889 let output = std::process::Command::new(&config.executable_path)
891 .arg("--help")
892 .output();
893
894 match output {
895 Ok(output) => {
896 if output.status.success() {
897 CheckResult {
898 name: "tool_executable".to_string(),
899 status: CheckStatus::Passed,
900 message: "Tool executed successfully with --help".to_string(),
901 details: None,
902 execution_time_ms: start_time.elapsed().as_millis() as u64,
903 }
904 } else {
905 let output = std::process::Command::new(&config.executable_path)
907 .arg("-h")
908 .output();
909
910 match output {
911 Ok(output) => {
912 if output.status.success() {
913 CheckResult {
914 name: "tool_executable".to_string(),
915 status: CheckStatus::Passed,
916 message: "Tool executed successfully with -h".to_string(),
917 details: None,
918 execution_time_ms: start_time.elapsed().as_millis() as u64,
919 }
920 } else {
921 CheckResult {
922 name: "tool_executable".to_string(),
923 status: CheckStatus::Warning,
924 message: "Tool executed but returned non-zero exit code"
925 .to_string(),
926 details: None,
927 execution_time_ms: start_time.elapsed().as_millis() as u64,
928 }
929 }
930 }
931 Err(e) => CheckResult {
932 name: "tool_executable".to_string(),
933 status: CheckStatus::Failed,
934 message: format!("Failed to execute tool: {}", e),
935 details: None,
936 execution_time_ms: start_time.elapsed().as_millis() as u64,
937 },
938 }
939 }
940 }
941 Err(e) => CheckResult {
942 name: "tool_executable".to_string(),
943 status: CheckStatus::Failed,
944 message: format!("Failed to execute tool: {}", e),
945 details: None,
946 execution_time_ms: start_time.elapsed().as_millis() as u64,
947 },
948 }
949 }
950
951 fn assess_security(&self, checks: &HashMap<String, CheckResult>) -> SecurityAssessment {
953 let mut warnings = vec![];
954 let blocked_content = vec![];
955 let mut security_level = SecurityLevel::Safe;
956
957 if let Some(scripts_check) = checks.get("scripts_valid")
959 && scripts_check.status == CheckStatus::Warning
960 && let Some(details) = &scripts_check.details
961 && let Some(issues) = details.as_array()
962 {
963 for issue in issues {
964 if let Some(issue_str) = issue.as_str()
965 && issue_str.contains("dangerous")
966 {
967 warnings.push(SecurityWarning {
968 warning_type: "dangerous_content".to_string(),
969 message: issue_str.to_string(),
970 severity: SecurityLevel::HighRisk,
971 suggestion: Some("Review script content for security issues".to_string()),
972 });
973 security_level = SecurityLevel::HighRisk;
974 }
975 }
976 }
977
978 let safe_to_execute = security_level != SecurityLevel::HighRisk;
979
980 SecurityAssessment {
981 security_level,
982 warnings,
983 blocked_content,
984 safe_to_execute,
985 }
986 }
987
988 fn generate_recommendations(
990 &self,
991 checks: &HashMap<String, CheckResult>,
992 security: &SecurityAssessment,
993 ) -> Vec<String> {
994 let mut recommendations = vec![];
995
996 for check in checks.values() {
998 match check.status {
999 CheckStatus::Warning => {
1000 recommendations.push(format!(
1001 "Address warning in {}: {}",
1002 check.name, check.message
1003 ));
1004 }
1005 CheckStatus::Failed => {
1006 recommendations.push(format!(
1007 "Fix failed check {}: {}",
1008 check.name, check.message
1009 ));
1010 }
1011 _ => {}
1012 }
1013 }
1014
1015 if security.security_level == SecurityLevel::HighRisk {
1017 recommendations
1018 .push("Review and fix security issues before using this skill".to_string());
1019 }
1020
1021 if let Some(loading_check) = checks.get("skill_file_valid")
1023 && loading_check.execution_time_ms > 1000
1024 {
1025 recommendations.push("Consider optimizing skill file parsing performance".to_string());
1026 }
1027
1028 recommendations
1029 }
1030
1031 fn determine_overall_status(
1033 &self,
1034 checks: &HashMap<String, CheckResult>,
1035 security: &SecurityAssessment,
1036 ) -> ValidationStatus {
1037 let has_failures = checks
1038 .values()
1039 .any(|check| check.status == CheckStatus::Failed);
1040 let has_warnings = checks
1041 .values()
1042 .any(|check| check.status == CheckStatus::Warning);
1043 let has_high_risk = security.security_level == SecurityLevel::HighRisk;
1044
1045 if has_failures || has_high_risk {
1046 ValidationStatus::Invalid
1047 } else if has_warnings {
1048 ValidationStatus::Warning
1049 } else {
1050 ValidationStatus::Valid
1051 }
1052 }
1053
1054 pub async fn validate_batch(
1056 &mut self,
1057 skill_paths: Vec<&Path>,
1058 ) -> Vec<Result<ValidationReport>> {
1059 let mut results = vec![];
1060
1061 for path in skill_paths {
1062 let result = self.validate_skill_directory(path).await;
1063 results.push(result);
1064 }
1065
1066 results
1067 }
1068}
1069
1070#[derive(Debug, Clone, Serialize, Deserialize)]
1072pub struct BatchValidationResult {
1073 pub total_skills: usize,
1075
1076 pub valid_skills: Vec<String>,
1078
1079 pub warning_skills: Vec<String>,
1081
1082 pub invalid_skills: Vec<String>,
1084
1085 pub average_validation_time_ms: u64,
1087
1088 pub reports: Vec<ValidationReport>,
1090}
1091
1092pub async fn validate_skill_batch(skill_paths: Vec<&Path>) -> Result<BatchValidationResult> {
1094 let mut validator = SkillValidator::new();
1095 let mut reports = vec![];
1096 let mut total_time = 0u64;
1097
1098 for path in skill_paths {
1099 match validator.validate_skill_directory(path).await {
1100 Ok(report) => {
1101 total_time += report.performance.total_time_ms;
1102 reports.push(report);
1103 }
1104 Err(e) => {
1105 let error_report = ValidationReport {
1107 status: ValidationStatus::Invalid,
1108 skill_name: path.to_string_lossy().to_string(),
1109 timestamp: chrono::Utc::now(),
1110 checks: HashMap::new(),
1111 performance: PerformanceMetrics::default(),
1112 security: SecurityAssessment::default(),
1113 recommendations: vec![format!("Validation failed: {}", e)],
1114 };
1115 reports.push(error_report);
1116 }
1117 }
1118 }
1119
1120 let valid_skills: Vec<String> = reports
1121 .iter()
1122 .filter(|r| r.status == ValidationStatus::Valid)
1123 .map(|r| r.skill_name.clone())
1124 .collect();
1125
1126 let warning_skills: Vec<String> = reports
1127 .iter()
1128 .filter(|r| r.status == ValidationStatus::Warning)
1129 .map(|r| r.skill_name.clone())
1130 .collect();
1131
1132 let invalid_skills: Vec<String> = reports
1133 .iter()
1134 .filter(|r| r.status == ValidationStatus::Invalid)
1135 .map(|r| r.skill_name.clone())
1136 .collect();
1137
1138 let average_time = if !reports.is_empty() {
1139 total_time / reports.len() as u64
1140 } else {
1141 0
1142 };
1143
1144 Ok(BatchValidationResult {
1145 total_skills: reports.len(),
1146 valid_skills,
1147 warning_skills,
1148 invalid_skills,
1149 average_validation_time_ms: average_time,
1150 reports,
1151 })
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157 use tempfile::TempDir;
1158
1159 #[test]
1160 fn test_validation_config_default() {
1161 let config = ValidationConfig::default();
1162 assert!(config.enable_security_checks);
1163 assert!(config.enable_performance_checks);
1164 assert_eq!(config.max_validation_time, 30);
1165 }
1166
1167 #[tokio::test]
1168 async fn test_validator_creation() {
1169 let _validator = SkillValidator::new();
1170 }
1172
1173 #[tokio::test]
1174 async fn test_invalid_skill_directory() {
1175 let mut validator = SkillValidator::new();
1176 let temp_dir = TempDir::new().unwrap();
1177 let non_existent = temp_dir.path().join("non_existent");
1178
1179 let result = validator.validate_skill_directory(&non_existent).await;
1180 assert!(result.is_ok());
1181
1182 let report = result.unwrap();
1183 assert_eq!(report.status, ValidationStatus::Invalid);
1184 }
1185
1186 #[tokio::test]
1187 async fn test_skill_validation_rejects_hooks() {
1188 let temp_dir = TempDir::new().unwrap();
1189 let skill_dir = temp_dir.path().join("hook-skill");
1190 std::fs::create_dir_all(&skill_dir).unwrap();
1191
1192 let skill_md = r#"---
1193name: hook-skill
1194description: Skill with hooks
1195hooks:
1196 pre_tool_use:
1197 - command: "echo pre"
1198---
1199# Hook Skill
1200"#;
1201 std::fs::write(skill_dir.join("SKILL.md"), skill_md).unwrap();
1202
1203 let mut validator = SkillValidator::new();
1204 let report = validator
1205 .validate_skill_directory(&skill_dir)
1206 .await
1207 .unwrap();
1208
1209 assert_eq!(report.status, ValidationStatus::Invalid);
1210 let check = report.checks.get("skill_file_valid").unwrap();
1211 assert_eq!(check.status, CheckStatus::Failed);
1212 assert!(check.message.contains("hooks"));
1213 }
1214
1215 #[tokio::test]
1216 async fn test_schema_validation_caching() {
1217 use std::fs::File;
1218 use std::io::Write;
1219
1220 let temp_dir = TempDir::new().unwrap();
1221 let schema_path = temp_dir.path().join("schema.json");
1222
1223 let sleep_fs = || std::thread::sleep(std::time::Duration::from_millis(50));
1225
1226 {
1228 let mut file = File::create(&schema_path).unwrap();
1229 write!(file, r#"{{"type": "string"}}"#).unwrap();
1230 }
1231 sleep_fs();
1232
1233 let mut validator = SkillValidator::new();
1234
1235 let result1 = validator.validate_json_schema(&schema_path).await;
1237 assert_eq!(result1.status, CheckStatus::Passed);
1238 assert_eq!(validator.schema_validation_cache.len(), 1);
1239
1240 let (cached_mtime, _) = validator.schema_validation_cache.get(&schema_path).unwrap();
1242 let cached_mtime = *cached_mtime;
1243
1244 let result2 = validator.validate_json_schema(&schema_path).await;
1246 assert_eq!(result2.status, CheckStatus::Passed);
1247 assert_eq!(
1249 validator
1250 .schema_validation_cache
1251 .get(&schema_path)
1252 .unwrap()
1253 .0,
1254 cached_mtime
1255 );
1256
1257 sleep_fs();
1259 {
1260 let mut file = File::create(&schema_path).unwrap();
1261 write!(file, r#"{{"type": "integer"}}"#).unwrap();
1262 }
1263 sleep_fs();
1264
1265 let result3 = validator.validate_json_schema(&schema_path).await;
1266 assert_eq!(result3.status, CheckStatus::Passed);
1267
1268 let (new_mtime, _) = validator.schema_validation_cache.get(&schema_path).unwrap();
1269 assert_ne!(
1270 *new_mtime, cached_mtime,
1271 "Cache should have updated with new mtime"
1272 );
1273 }
1274}