Skip to main content

skilllite_agent/skills/
security.rs

1//! Security scanning and lock file management for skills.
2
3use anyhow::Result;
4use std::path::Path;
5
6use skilllite_core::skill::metadata::SkillMetadata;
7use skilllite_sandbox::security::scanner::ScriptScanner;
8
9/// Compute a hash of a skill's code for cache invalidation.
10pub(super) fn compute_skill_hash(skill_dir: &Path, metadata: &SkillMetadata) -> String {
11    use sha2::{Digest, Sha256};
12    let mut hasher = Sha256::new();
13
14    // Hash the entry point script content
15    let entry_path = if !metadata.entry_point.is_empty() {
16        skill_dir.join(&metadata.entry_point)
17    } else {
18        // Try common defaults
19        let defaults = ["scripts/main.py", "main.py"];
20        defaults
21            .iter()
22            .map(|d| skill_dir.join(d))
23            .find(|p| p.exists())
24            .unwrap_or_else(|| skill_dir.join("SKILL.md"))
25    };
26
27    if let Ok(content) = skilllite_fs::read_bytes(&entry_path) {
28        hasher.update(&content);
29    }
30    // Also include SKILL.md content
31    if let Ok(skill_md) = skilllite_fs::read_bytes(&skill_dir.join("SKILL.md")) {
32        hasher.update(&skill_md);
33    }
34
35    hex::encode(hasher.finalize())[..16].to_string()
36}
37
38/// Run security scan on a skill's entry point and SKILL.md.
39/// Returns formatted report string if any issues found, or None if scan is clean.
40pub(super) fn run_security_scan(skill_dir: &Path, metadata: &SkillMetadata) -> Option<String> {
41    let mut report_parts = Vec::new();
42
43    // 1. Scan SKILL.md for supply chain / agent-driven social engineering patterns
44    let skill_md_path = skill_dir.join("SKILL.md");
45    if skill_md_path.exists() {
46        if let Ok(content) = skilllite_fs::read_file(&skill_md_path) {
47            let alerts =
48                skilllite_core::skill::skill_md_security::scan_skill_md_suspicious_patterns(
49                    &content,
50                );
51            if !alerts.is_empty() {
52                report_parts.push(
53                    "SKILL.md security alerts (supply chain / agent-driven social engineering):"
54                        .to_string(),
55                );
56                for a in &alerts {
57                    report_parts.push(format!(
58                        "  [{}] {}: {}",
59                        a.severity.to_uppercase(),
60                        a.pattern,
61                        a.message
62                    ));
63                }
64                report_parts.push(String::new());
65            }
66        }
67    }
68
69    // 2. Scan entry point script
70    let entry_path = if !metadata.entry_point.is_empty() {
71        skill_dir.join(&metadata.entry_point)
72    } else {
73        let defaults = ["scripts/main.py", "main.py"];
74        match defaults
75            .iter()
76            .map(|d| skill_dir.join(d))
77            .find(|p| p.exists())
78        {
79            Some(p) => p,
80            None => {
81                return if report_parts.is_empty() {
82                    None
83                } else {
84                    Some(report_parts.join("\n"))
85                };
86            }
87        }
88    };
89
90    if entry_path.exists() {
91        let scanner = ScriptScanner::new();
92        match scanner.scan_file(&entry_path) {
93            Ok(result) => {
94                if !result.is_safe {
95                    report_parts.push(
96                        skilllite_sandbox::security::scanner::format_scan_result_compact(&result),
97                    );
98                }
99            }
100            Err(e) => {
101                tracing::warn!("Security scan failed for {}: {}", entry_path.display(), e);
102                report_parts.push(format!(
103                    "Script security scan failed: {}. Manual review required.",
104                    e
105                ));
106            }
107        }
108    }
109
110    if report_parts.is_empty() {
111        None
112    } else {
113        Some(report_parts.join("\n"))
114    }
115}
116
117// ─── Phase 2.5: .skilllite.lock dependency resolution ───────────────────────
118// Kept for future init_deps integration; metadata uses its own read_lock_file_packages.
119
120/// Lock file structure for cached dependency resolution.
121#[derive(Debug, serde::Deserialize)]
122pub struct LockFile {
123    pub compatibility_hash: String,
124    pub language: String,
125    pub resolved_packages: Vec<String>,
126    pub resolved_at: String,
127    pub resolver: String,
128}
129
130/// Read and validate a `.skilllite.lock` file for a skill.
131/// Returns the resolved packages if the lock is fresh, None if stale or missing.
132pub fn read_lock_file(skill_dir: &Path, compatibility: Option<&str>) -> Option<Vec<String>> {
133    let lock_path = skill_dir.join(".skilllite.lock");
134    if !lock_path.exists() {
135        return None;
136    }
137
138    let content = skilllite_fs::read_file(&lock_path).ok()?;
139    let lock: LockFile = serde_json::from_str(&content).ok()?;
140
141    // Check staleness via compatibility hash
142    let compat_str = compatibility.unwrap_or("");
143    let current_hash = {
144        use sha2::{Digest, Sha256};
145        let mut hasher = Sha256::new();
146        hasher.update(compat_str.as_bytes());
147        hex::encode(hasher.finalize())
148    };
149
150    if lock.compatibility_hash != current_hash {
151        tracing::debug!("Lock file stale for {}: hash mismatch", skill_dir.display());
152        return None;
153    }
154
155    Some(lock.resolved_packages)
156}
157
158/// Write a `.skilllite.lock` file for a skill.
159pub fn write_lock_file(
160    skill_dir: &Path,
161    compatibility: Option<&str>,
162    language: &str,
163    packages: &[String],
164    resolver: &str,
165) -> Result<()> {
166    let compat_str = compatibility.unwrap_or("");
167    let compat_hash = {
168        use sha2::{Digest, Sha256};
169        let mut hasher = Sha256::new();
170        hasher.update(compat_str.as_bytes());
171        hex::encode(hasher.finalize())
172    };
173
174    let mut sorted_packages = packages.to_vec();
175    sorted_packages.sort();
176
177    let lock = serde_json::json!({
178        "compatibility_hash": compat_hash,
179        "language": language,
180        "resolved_packages": sorted_packages,
181        "resolved_at": chrono::Utc::now().to_rfc3339(),
182        "resolver": resolver,
183    });
184
185    let lock_path = skill_dir.join(".skilllite.lock");
186    skilllite_fs::write_file(&lock_path, &(serde_json::to_string_pretty(&lock)? + "\n"))?;
187
188    Ok(())
189}