use anyhow::Result;
use std::path::Path;
use skilllite_core::skill::metadata::SkillMetadata;
use skilllite_sandbox::security::scanner::ScriptScanner;
pub(super) fn compute_skill_hash(skill_dir: &Path, metadata: &SkillMetadata) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let entry_path = if !metadata.entry_point.is_empty() {
skill_dir.join(&metadata.entry_point)
} else {
let defaults = ["scripts/main.py", "main.py"];
defaults
.iter()
.map(|d| skill_dir.join(d))
.find(|p| p.exists())
.unwrap_or_else(|| skill_dir.join("SKILL.md"))
};
if let Ok(content) = skilllite_fs::read_bytes(&entry_path) {
hasher.update(&content);
}
if let Ok(skill_md) = skilllite_fs::read_bytes(&skill_dir.join("SKILL.md")) {
hasher.update(&skill_md);
}
hex::encode(hasher.finalize())[..16].to_string()
}
pub(super) fn run_security_scan(skill_dir: &Path, metadata: &SkillMetadata) -> Option<String> {
let mut report_parts = Vec::new();
let skill_md_path = skill_dir.join("SKILL.md");
if skill_md_path.exists() {
if let Ok(content) = skilllite_fs::read_file(&skill_md_path) {
let alerts =
skilllite_core::skill::skill_md_security::scan_skill_md_suspicious_patterns(
&content,
);
if !alerts.is_empty() {
report_parts.push(
"SKILL.md security alerts (supply chain / agent-driven social engineering):"
.to_string(),
);
for a in &alerts {
report_parts.push(format!(
" [{}] {}: {}",
a.severity.to_uppercase(),
a.pattern,
a.message
));
}
report_parts.push(String::new());
}
}
}
let entry_path = if !metadata.entry_point.is_empty() {
skill_dir.join(&metadata.entry_point)
} else {
let defaults = ["scripts/main.py", "main.py"];
match defaults
.iter()
.map(|d| skill_dir.join(d))
.find(|p| p.exists())
{
Some(p) => p,
None => {
return if report_parts.is_empty() {
None
} else {
Some(report_parts.join("\n"))
};
}
}
};
if entry_path.exists() {
let scanner = ScriptScanner::new();
match scanner.scan_file(&entry_path) {
Ok(result) => {
if !result.is_safe {
report_parts.push(
skilllite_sandbox::security::scanner::format_scan_result_compact(&result),
);
}
}
Err(e) => {
tracing::warn!("Security scan failed for {}: {}", entry_path.display(), e);
report_parts.push(format!(
"Script security scan failed: {}. Manual review required.",
e
));
}
}
}
if report_parts.is_empty() {
None
} else {
Some(report_parts.join("\n"))
}
}
#[derive(Debug, serde::Deserialize)]
pub struct LockFile {
pub compatibility_hash: String,
pub language: String,
pub resolved_packages: Vec<String>,
pub resolved_at: String,
pub resolver: String,
}
pub fn read_lock_file(skill_dir: &Path, compatibility: Option<&str>) -> Option<Vec<String>> {
let lock_path = skill_dir.join(".skilllite.lock");
if !lock_path.exists() {
return None;
}
let content = skilllite_fs::read_file(&lock_path).ok()?;
let lock: LockFile = serde_json::from_str(&content).ok()?;
let compat_str = compatibility.unwrap_or("");
let current_hash = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(compat_str.as_bytes());
hex::encode(hasher.finalize())
};
if lock.compatibility_hash != current_hash {
tracing::debug!("Lock file stale for {}: hash mismatch", skill_dir.display());
return None;
}
Some(lock.resolved_packages)
}
pub fn write_lock_file(
skill_dir: &Path,
compatibility: Option<&str>,
language: &str,
packages: &[String],
resolver: &str,
) -> Result<()> {
let compat_str = compatibility.unwrap_or("");
let compat_hash = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(compat_str.as_bytes());
hex::encode(hasher.finalize())
};
let mut sorted_packages = packages.to_vec();
sorted_packages.sort();
let lock = serde_json::json!({
"compatibility_hash": compat_hash,
"language": language,
"resolved_packages": sorted_packages,
"resolved_at": chrono::Utc::now().to_rfc3339(),
"resolver": resolver,
});
let lock_path = skill_dir.join(".skilllite.lock");
skilllite_fs::write_file(&lock_path, &(serde_json::to_string_pretty(&lock)? + "\n"))?;
Ok(())
}