skilllite_agent/skills/
security.rs1use anyhow::Result;
4use std::path::Path;
5
6use skilllite_core::skill::metadata::SkillMetadata;
7use skilllite_sandbox::security::scanner::ScriptScanner;
8
9pub(super) fn compute_skill_hash(skill_dir: &Path, metadata: &SkillMetadata) -> String {
11 use sha2::{Digest, Sha256};
12 let mut hasher = Sha256::new();
13
14 let entry_path = if !metadata.entry_point.is_empty() {
16 skill_dir.join(&metadata.entry_point)
17 } else {
18 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 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
38pub(super) fn run_security_scan(skill_dir: &Path, metadata: &SkillMetadata) -> Option<String> {
41 let mut report_parts = Vec::new();
42
43 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 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#[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
130pub 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 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
158pub 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}