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 { config, schema_validation_cache: HashMap::new() }
245    }
246
247    /// Validate a traditional skill from directory
248    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        // Performance tracking initialized at end
252
253        info!("Validating skill directory: {}", skill_path.display());
254
255        // Check if directory exists
256        let check_result = self.check_directory_exists(skill_path).await;
257        checks.insert("directory_exists".to_string(), check_result);
258
259        // Validate SKILL.md file
260        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        // Validate scripts directory
271        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        // Validate resources
278        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        // Security assessment
284        let security = self.assess_security(&checks);
285
286        // Generate recommendations
287        let recommendations = self.generate_recommendations(&checks, &security);
288
289        // Determine overall status
290        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    /// Validate CLI tool configuration
309    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        // Check executable exists
316        let check_result = self.check_executable_exists(&config.executable_path).await;
317        checks.insert("executable_exists".to_string(), check_result);
318
319        // Check executable permissions
320        let check_result = self.check_executable_permissions(&config.executable_path).await;
321        checks.insert("executable_permissions".to_string(), check_result);
322
323        // Validate README if present
324        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        // Validate JSON schema if present
330        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        // Test tool execution (basic)
336        let check_result = self.test_tool_execution(config).await;
337        checks.insert("tool_executable".to_string(), check_result);
338
339        // Security assessment
340        let security = self.assess_security(&checks);
341
342        // Generate recommendations
343        let recommendations = self.generate_recommendations(&checks, &security);
344
345        // Determine overall status
346        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    /// Check if directory exists
365    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    /// Validate SKILL.md file
390    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                // Validate required fields
419                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    /// Validate scripts directory
474    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                // Check file size
507                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                // Check extension
517                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                // Security check
526                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    /// Validate resources
560    async fn validate_resources(&self, skill_path: &Path) -> HashMap<String, CheckResult> {
561        let mut results = HashMap::new();
562
563        // Check for common resource directories
564        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    /// Validate resource directory
576    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                // Check file size
610                if let Some(metadata) = entry.metadata().await.ok().filter(|m| m.len() > 10 * 1024 * 1024) {
611                    // 10MB limit for resources
612                    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    /// Check if executable exists
639    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    /// Check executable permissions
664    #[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        // Windows or metadata error - assume valid
705        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    /// Validate README file
715    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    /// Validate JSON schema
759    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        // Check cache
773        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            // Update execution time to reflect cache hit (near zero)
780            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                        // Validate that it's a proper JSON Schema by attempting to compile it
790                        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        // Update cache
828        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    /// Test basic tool execution
839    async fn test_tool_execution(&self, config: &CliToolConfig) -> CheckResult {
840        let start_time = Instant::now();
841
842        // Execute tool off the async executor — `Command::output()` blocks
843        // until the subprocess exits. See the `# Blocking` docs in
844        // `src/agent/runloop/git.rs` for the pattern.
845        let exec_path = config.executable_path.clone();
846
847        // Try --help first
848        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                // --help failed, try -h
861                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    /// Assess security based on checks
913    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        // Check for script security issues
919        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    /// Generate recommendations based on validation results
950    fn generate_recommendations(
951        &self,
952        checks: &HashMap<String, CheckResult>,
953        security: &SecurityAssessment,
954    ) -> Vec<String> {
955        let mut recommendations = vec![];
956
957        // General recommendations based on check results
958        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        // Security recommendations
971        if security.security_level == SecurityLevel::HighRisk {
972            recommendations.push("Review and fix security issues before using this skill".to_string());
973        }
974
975        // Performance recommendations
976        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    /// Determine overall validation status
986    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    /// Validate multiple skills in batch
1005    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/// Batch validation result
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019pub struct BatchValidationResult {
1020    /// Total skills validated
1021    pub total_skills: usize,
1022
1023    /// Valid skills
1024    pub valid_skills: Vec<String>,
1025
1026    /// Skills with warnings
1027    pub warning_skills: Vec<String>,
1028
1029    /// Invalid skills
1030    pub invalid_skills: Vec<String>,
1031
1032    /// Average validation time
1033    pub average_validation_time_ms: u64,
1034
1035    /// Validation reports
1036    pub reports: Vec<ValidationReport>,
1037}
1038
1039/// Validate a batch of skills and summarize results
1040pub 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                // Create error report
1053                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        // assert_eq!(validator.schema_cache.len(), 0); // Commented out since schema_cache is disabled
1118    }
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        // precise sleep to ensure file system time resolution
1168        let sleep_fs = || std::thread::sleep(std::time::Duration::from_millis(50));
1169
1170        // Create initial schema
1171        {
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        // 1. First validation - should cache
1180        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        // Capture mtime in cache
1185        let (cached_mtime, _) = validator.schema_validation_cache.get(&schema_path).unwrap();
1186        let cached_mtime = *cached_mtime;
1187
1188        // 2. Second validation - should hit cache (mtime same)
1189        let result2 = validator.validate_json_schema(&schema_path).await;
1190        assert_eq!(result2.status, CheckStatus::Passed);
1191        // Verify we still have the same cache entry
1192        assert_eq!(validator.schema_validation_cache.get(&schema_path).unwrap().0, cached_mtime);
1193
1194        // 3. Modify file - should invalidate cache
1195        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}