Skip to main content

vtcode_core/skills/
validation.rs

1//! Skill Validation System
2//!
3//! Validates skill definitions, configurations, and executions to ensure:
4//! - Proper SKILL.md format and metadata
5//! - Valid JSON schemas for tool arguments
6//! - Executable scripts and tools
7//! - Security and safety checks
8//! - Performance and resource usage validation
9
10use 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/// Validation configuration
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ValidationConfig {
24    /// Enable security checks
25    pub enable_security_checks: bool,
26
27    /// Enable performance validation
28    pub enable_performance_checks: bool,
29
30    /// Maximum execution time for validation tests (seconds)
31    pub max_validation_time: u64,
32
33    /// Maximum script size (bytes)
34    pub max_script_size: usize,
35
36    /// Allowed script extensions
37    pub allowed_script_extensions: Vec<String>,
38
39    /// Blocked commands/patterns
40    pub blocked_commands: Vec<String>,
41
42    /// Required metadata fields
43    pub required_metadata_fields: Vec<String>,
44
45    /// Enable JSON schema validation
46    pub enable_schema_validation: bool,
47
48    /// Strict mode (fail on warnings)
49    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, // 1MB
59            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/// Validation result with detailed report
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ValidationReport {
87    /// Overall validation status
88    pub status: ValidationStatus,
89
90    /// Skill name
91    pub skill_name: String,
92
93    /// Validation timestamp
94    pub timestamp: chrono::DateTime<chrono::Utc>,
95
96    /// Individual check results
97    pub checks: HashMap<String, CheckResult>,
98
99    /// Performance metrics
100    pub performance: PerformanceMetrics,
101
102    /// Security assessment
103    pub security: SecurityAssessment,
104
105    /// Recommendations for improvement
106    pub recommendations: Vec<String>,
107}
108
109/// Validation status
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub enum ValidationStatus {
112    /// All checks passed
113    Valid,
114    /// Some warnings, but skill is usable
115    Warning,
116    /// Critical issues, skill should not be used
117    Invalid,
118}
119
120/// Individual check result
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CheckResult {
123    /// Check name
124    pub name: String,
125
126    /// Check status
127    pub status: CheckStatus,
128
129    /// Detailed message
130    pub message: String,
131
132    /// Additional details
133    pub details: Option<Value>,
134
135    /// Execution time
136    pub execution_time_ms: u64,
137}
138
139/// Check status
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141pub enum CheckStatus {
142    /// Check passed
143    Passed,
144    /// Check passed with warnings
145    Warning,
146    /// Check failed
147    Failed,
148    /// Check was skipped
149    Skipped,
150}
151
152/// Performance metrics
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct PerformanceMetrics {
155    /// Total validation time
156    pub total_time_ms: u64,
157
158    /// Skill loading time
159    pub loading_time_ms: u64,
160
161    /// Schema validation time
162    pub schema_validation_time_ms: u64,
163
164    /// Script validation time
165    pub script_validation_time_ms: u64,
166
167    /// Memory usage estimate (bytes)
168    pub memory_usage_bytes: usize,
169
170    /// Token usage estimate
171    pub token_usage_estimate: usize,
172}
173
174/// Security assessment
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct SecurityAssessment {
177    /// Overall security level
178    pub security_level: SecurityLevel,
179
180    /// Security warnings
181    pub warnings: Vec<SecurityWarning>,
182
183    /// Blocked content found
184    pub blocked_content: Vec<String>,
185
186    /// Safe to execute
187    pub safe_to_execute: bool,
188}
189
190/// Security level
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
192pub enum SecurityLevel {
193    /// No security concerns
194    #[default]
195    Safe,
196    /// Minor concerns, generally safe
197    LowRisk,
198    /// Moderate concerns, review recommended
199    MediumRisk,
200    /// High concerns, not recommended
201    HighRisk,
202}
203
204/// Security warning
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SecurityWarning {
207    /// Warning type
208    pub warning_type: String,
209
210    /// Warning message
211    pub message: String,
212
213    /// Severity level
214    pub severity: SecurityLevel,
215
216    /// Suggested remediation
217    pub suggestion: Option<String>,
218}
219
220/// Skill validator
221pub struct SkillValidator {
222    config: ValidationConfig,
223    // Note: Validator from jsonschema crate doesn't implement Clone,
224    // so we cache the validation result keyed by path and mtime instead.
225    schema_validation_cache: HashMap<PathBuf, (SystemTime, CheckResult)>,
226}
227
228impl SkillValidator {
229    /// Create new validator with default configuration
230    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    /// Create new validator with custom configuration
243    pub fn with_config(config: ValidationConfig) -> Self {
244        Self {
245            config,
246            schema_validation_cache: HashMap::new(),
247        }
248    }
249
250    /// Validate a traditional skill from directory
251    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        // Performance tracking initialized at end
258
259        info!("Validating skill directory: {}", skill_path.display());
260
261        // Check if directory exists
262        let check_result = self.check_directory_exists(skill_path).await;
263        checks.insert("directory_exists".to_string(), check_result);
264
265        // Validate SKILL.md file
266        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        // Validate scripts directory
281        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        // Validate resources
288        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        // Security assessment
294        let security = self.assess_security(&checks);
295
296        // Generate recommendations
297        let recommendations = self.generate_recommendations(&checks, &security);
298
299        // Determine overall status
300        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    /// Validate CLI tool configuration
319    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        // Check executable exists
326        let check_result = self.check_executable_exists(&config.executable_path).await;
327        checks.insert("executable_exists".to_string(), check_result);
328
329        // Check executable permissions
330        let check_result = self
331            .check_executable_permissions(&config.executable_path)
332            .await;
333        checks.insert("executable_permissions".to_string(), check_result);
334
335        // Validate README if present
336        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        // Validate JSON schema if present
342        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        // Test tool execution (basic)
348        let check_result = self.test_tool_execution(config).await;
349        checks.insert("tool_executable".to_string(), check_result);
350
351        // Security assessment
352        let security = self.assess_security(&checks);
353
354        // Generate recommendations
355        let recommendations = self.generate_recommendations(&checks, &security);
356
357        // Determine overall status
358        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    /// Check if directory exists
377    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    /// Validate SKILL.md file
402    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                // Validate required fields
434                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    /// Validate scripts directory
494    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                // Check file size
531                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                // Check extension
545                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                // Security check
555                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    /// Validate resources
590    async fn validate_resources(&self, skill_path: &Path) -> HashMap<String, CheckResult> {
591        let mut results = HashMap::new();
592
593        // Check for common resource directories
594        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    /// Validate resource directory
608    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                // Check file size
650                if let Some(metadata) = entry
651                    .metadata()
652                    .await
653                    .ok()
654                    .filter(|m| m.len() > 10 * 1024 * 1024)
655                {
656                    // 10MB limit for resources
657                    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    /// Check if executable exists
692    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    /// Check executable permissions
717    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        // Windows or metadata error - assume valid
751        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    /// Validate README file
761    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    /// Validate JSON schema
805    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        // Check cache
819        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            // Update execution time to reflect cache hit (near zero)
827            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                        // Validate that it's a proper JSON Schema by attempting to compile it
837                        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        // Update cache
875        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    /// Test basic tool execution
886    async fn test_tool_execution(&self, config: &CliToolConfig) -> CheckResult {
887        let start_time = Instant::now();
888
889        // Try to execute with --help or -h
890        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                    // Try -h
906                    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    /// Assess security based on checks
952    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        // Check for script security issues
958        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    /// Generate recommendations based on validation results
989    fn generate_recommendations(
990        &self,
991        checks: &HashMap<String, CheckResult>,
992        security: &SecurityAssessment,
993    ) -> Vec<String> {
994        let mut recommendations = vec![];
995
996        // General recommendations based on check results
997        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        // Security recommendations
1016        if security.security_level == SecurityLevel::HighRisk {
1017            recommendations
1018                .push("Review and fix security issues before using this skill".to_string());
1019        }
1020
1021        // Performance recommendations
1022        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    /// Determine overall validation status
1032    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    /// Validate multiple skills in batch
1055    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/// Batch validation result
1071#[derive(Debug, Clone, Serialize, Deserialize)]
1072pub struct BatchValidationResult {
1073    /// Total skills validated
1074    pub total_skills: usize,
1075
1076    /// Valid skills
1077    pub valid_skills: Vec<String>,
1078
1079    /// Skills with warnings
1080    pub warning_skills: Vec<String>,
1081
1082    /// Invalid skills
1083    pub invalid_skills: Vec<String>,
1084
1085    /// Average validation time
1086    pub average_validation_time_ms: u64,
1087
1088    /// Validation reports
1089    pub reports: Vec<ValidationReport>,
1090}
1091
1092/// Validate a batch of skills and summarize results
1093pub 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                // Create error report
1106                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        // assert_eq!(validator.schema_cache.len(), 0); // Commented out since schema_cache is disabled
1171    }
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        // precise sleep to ensure file system time resolution
1224        let sleep_fs = || std::thread::sleep(std::time::Duration::from_millis(50));
1225
1226        // Create initial schema
1227        {
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        // 1. First validation - should cache
1236        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        // Capture mtime in cache
1241        let (cached_mtime, _) = validator.schema_validation_cache.get(&schema_path).unwrap();
1242        let cached_mtime = *cached_mtime;
1243
1244        // 2. Second validation - should hit cache (mtime same)
1245        let result2 = validator.validate_json_schema(&schema_path).await;
1246        assert_eq!(result2.status, CheckStatus::Passed);
1247        // Verify we still have the same cache entry
1248        assert_eq!(
1249            validator
1250                .schema_validation_cache
1251                .get(&schema_path)
1252                .unwrap()
1253                .0,
1254            cached_mtime
1255        );
1256
1257        // 3. Modify file - should invalidate cache
1258        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}