Skip to main content

symbi_runtime/skills/
loader.rs

1use schemapin::pinning::KeyPinStore;
2use schemapin::skill::{load_signature, parse_skill_name, verify_skill_offline};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use super::config::SkillsConfig;
7use super::scanner::{ScanResult, SkillScanner};
8
9/// Verification status of a skill's cryptographic signature.
10#[derive(Debug, Clone)]
11pub enum SignatureStatus {
12    Verified {
13        domain: String,
14        developer: Option<String>,
15    },
16    Pinned {
17        domain: String,
18        developer: Option<String>,
19    },
20    Unsigned,
21    Invalid {
22        reason: String,
23    },
24    Revoked {
25        reason: String,
26    },
27}
28
29impl std::fmt::Display for SignatureStatus {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            SignatureStatus::Verified { domain, .. } => write!(f, "Verified ({})", domain),
33            SignatureStatus::Pinned { domain, .. } => write!(f, "Pinned ({})", domain),
34            SignatureStatus::Unsigned => write!(f, "Unsigned"),
35            SignatureStatus::Invalid { reason } => write!(f, "Invalid: {}", reason),
36            SignatureStatus::Revoked { reason } => write!(f, "Revoked: {}", reason),
37        }
38    }
39}
40
41/// A skill that has been loaded and optionally verified.
42#[derive(Debug, Clone)]
43pub struct LoadedSkill {
44    pub name: String,
45    pub path: PathBuf,
46    pub signature_status: SignatureStatus,
47    pub content: String,
48    pub metadata: SkillMetadata,
49    pub scan_result: Option<ScanResult>,
50}
51
52/// Parsed frontmatter metadata from a SKILL.md file.
53#[derive(Debug, Clone)]
54pub struct SkillMetadata {
55    pub name: String,
56    pub description: Option<String>,
57    pub raw_frontmatter: HashMap<String, String>,
58}
59
60/// Errors that can occur during skill loading.
61#[derive(Debug, thiserror::Error)]
62pub enum SkillLoadError {
63    #[error("IO error: {0}")]
64    Io(#[from] std::io::Error),
65    #[error("SKILL.md not found in {0}")]
66    MissingSkillMd(PathBuf),
67    #[error("Signature error: {0}")]
68    Signature(String),
69}
70
71/// Loads skills from configured paths, verifies signatures, and runs content scanning.
72pub struct SkillLoader {
73    config: SkillsConfig,
74    pin_store: KeyPinStore,
75    scanner: Option<SkillScanner>,
76}
77
78impl SkillLoader {
79    /// Create a new skill loader with the given configuration.
80    pub fn new(config: SkillsConfig) -> Self {
81        let scanner = if config.scan_enabled {
82            let custom_rules = config
83                .custom_deny_patterns
84                .iter()
85                .map(|p| super::scanner::ScanRule::DenyContentPattern(p.clone()))
86                .collect();
87            Some(SkillScanner::with_custom_rules(custom_rules))
88        } else {
89            None
90        };
91
92        Self {
93            config,
94            pin_store: KeyPinStore::new(),
95            scanner,
96        }
97    }
98
99    /// Load all skills from configured paths.
100    pub fn load_all(&mut self) -> Vec<LoadedSkill> {
101        let mut skills = Vec::new();
102
103        for load_path in self.config.load_paths.clone() {
104            if !load_path.exists() || !load_path.is_dir() {
105                continue;
106            }
107
108            if let Ok(entries) = std::fs::read_dir(&load_path) {
109                for entry in entries.filter_map(Result::ok) {
110                    let path = entry.path();
111                    if !path.is_dir() {
112                        continue;
113                    }
114                    // Each subdirectory is a potential skill
115                    if path.join("SKILL.md").exists() {
116                        match self.load_skill(&path) {
117                            Ok(skill) => skills.push(skill),
118                            Err(e) => {
119                                tracing::warn!("Failed to load skill at {:?}: {}", path, e);
120                            }
121                        }
122                    }
123                }
124            }
125        }
126
127        skills
128    }
129
130    /// Load a single skill from a directory.
131    pub fn load_skill(&mut self, path: &Path) -> Result<LoadedSkill, SkillLoadError> {
132        let skill_md = path.join("SKILL.md");
133        if !skill_md.exists() {
134            return Err(SkillLoadError::MissingSkillMd(path.to_path_buf()));
135        }
136
137        let content = std::fs::read_to_string(&skill_md)?;
138        let name = parse_skill_name(path);
139        let metadata = parse_frontmatter(&content, &name);
140        let signature_status = self.verify_skill(path);
141
142        let scan_result = self.scanner.as_ref().map(|s| s.scan_skill(path));
143
144        Ok(LoadedSkill {
145            name,
146            path: path.to_path_buf(),
147            signature_status,
148            content,
149            metadata,
150            scan_result,
151        })
152    }
153
154    /// Verify the signature of a skill directory.
155    pub fn verify_skill(&mut self, path: &Path) -> SignatureStatus {
156        // Try to load the signature file
157        let sig = match load_signature(path) {
158            Ok(sig) => sig,
159            Err(_) => {
160                // Check if this path is allowed unsigned
161                if self.is_unsigned_allowed(path) {
162                    return SignatureStatus::Unsigned;
163                }
164                if self.config.require_signed {
165                    return SignatureStatus::Invalid {
166                        reason: "No signature file (.schemapin.sig) found".into(),
167                    };
168                }
169                return SignatureStatus::Unsigned;
170            }
171        };
172
173        let domain = &sig.domain;
174
175        // Build a discovery document from the signature for offline verification.
176        // In a full deployment, you'd resolve via DNS or trust bundle.
177        // For offline verification, we need a WellKnownResponse with the public key.
178        // Since we don't have a resolver here, we attempt offline verification
179        // using a minimal discovery document. In practice, the CLI verify command
180        // will accept a --domain flag and resolve properly.
181
182        // For TOFU: use verify_skill_offline with pin_store
183        let pin_store = if self.config.auto_pin {
184            Some(&mut self.pin_store)
185        } else {
186            None
187        };
188
189        let tool_id = sig.skill_name.clone();
190
191        // Without a resolver, we can still check if the signature file exists
192        // and if the skill has been previously pinned. Full verification
193        // requires a discovery document with the public key.
194        // Return Pinned status if we have a pinned key for this tool+domain.
195        if let Some(store) = &pin_store {
196            if store.get_tool(&tool_id, domain).is_some() {
197                return SignatureStatus::Pinned {
198                    domain: domain.clone(),
199                    developer: None,
200                };
201            }
202        }
203
204        // Without a discovery document, we can't fully verify.
205        // Mark as having a signature but unverified (needs resolver).
206        SignatureStatus::Invalid {
207            reason: format!(
208                "Signature found for domain '{}' but no discovery document available for offline verification",
209                domain
210            ),
211        }
212    }
213
214    /// Verify a skill with a provided discovery document (for CLI use).
215    pub fn verify_skill_with_discovery(
216        &mut self,
217        path: &Path,
218        discovery: &schemapin::types::discovery::WellKnownResponse,
219    ) -> SignatureStatus {
220        let sig = match load_signature(path) {
221            Ok(sig) => sig,
222            Err(_) => {
223                return SignatureStatus::Invalid {
224                    reason: "No signature file (.schemapin.sig) found".into(),
225                };
226            }
227        };
228
229        let tool_id = sig.skill_name.clone();
230
231        let pin_store = if self.config.auto_pin {
232            Some(&mut self.pin_store)
233        } else {
234            None
235        };
236
237        let result = verify_skill_offline(
238            path,
239            discovery,
240            Some(&sig),
241            None, // No revocation document for now
242            pin_store,
243            Some(&tool_id),
244        );
245
246        if result.valid {
247            let domain = result.domain.clone().unwrap_or_default();
248            let developer = result.developer_name.clone();
249
250            if let Some(ref pin_status) = result.key_pinning {
251                if pin_status.status == "first_use" {
252                    return SignatureStatus::Pinned { domain, developer };
253                }
254            }
255
256            SignatureStatus::Verified { domain, developer }
257        } else {
258            let reason = result
259                .error_message
260                .unwrap_or_else(|| "Verification failed".into());
261
262            if result
263                .error_code
264                .map(|c| c == schemapin::error::ErrorCode::KeyRevoked)
265                .unwrap_or(false)
266            {
267                SignatureStatus::Revoked { reason }
268            } else {
269                SignatureStatus::Invalid { reason }
270            }
271        }
272    }
273
274    /// Check if a path is exempted from signing requirements.
275    fn is_unsigned_allowed(&self, path: &Path) -> bool {
276        for allowed in &self.config.allow_unsigned_from {
277            if let (Ok(canonical_allowed), Ok(canonical_path)) =
278                (std::fs::canonicalize(allowed), std::fs::canonicalize(path))
279            {
280                if canonical_path.starts_with(&canonical_allowed) {
281                    return true;
282                }
283            }
284            // Fallback: simple prefix check for relative paths
285            if path.starts_with(allowed) {
286                return true;
287            }
288        }
289        false
290    }
291}
292
293/// Parse YAML frontmatter from a SKILL.md file.
294fn parse_frontmatter(content: &str, fallback_name: &str) -> SkillMetadata {
295    let mut raw_frontmatter = HashMap::new();
296    let mut name = fallback_name.to_string();
297    let mut description = None;
298
299    // Check for YAML frontmatter delimited by ---
300    if let Some(after_open) = content.strip_prefix("---") {
301        if let Some(end) = after_open.find("---") {
302            let fm_content = &after_open[..end];
303            for line in fm_content.lines() {
304                let line = line.trim();
305                if let Some(idx) = line.find(':') {
306                    let key = line[..idx].trim().to_string();
307                    let value = line[idx + 1..].trim().to_string();
308                    if key == "name" && !value.is_empty() {
309                        name = value.clone();
310                    }
311                    if key == "description" && !value.is_empty() {
312                        description = Some(value.clone());
313                    }
314                    raw_frontmatter.insert(key, value);
315                }
316            }
317        }
318    }
319
320    SkillMetadata {
321        name,
322        description,
323        raw_frontmatter,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn parse_frontmatter_with_name() {
333        let content = "---\nname: my-skill\ndescription: A test skill\n---\n# Content";
334        let meta = parse_frontmatter(content, "fallback");
335        assert_eq!(meta.name, "my-skill");
336        assert_eq!(meta.description.as_deref(), Some("A test skill"));
337    }
338
339    #[test]
340    fn parse_frontmatter_fallback() {
341        let content = "# No frontmatter here";
342        let meta = parse_frontmatter(content, "fallback");
343        assert_eq!(meta.name, "fallback");
344        assert!(meta.description.is_none());
345    }
346
347    #[test]
348    fn parse_frontmatter_empty() {
349        let content = "---\n---\n# Empty frontmatter";
350        let meta = parse_frontmatter(content, "fallback");
351        assert_eq!(meta.name, "fallback");
352    }
353
354    #[test]
355    fn load_skill_from_tempdir() {
356        let dir = tempfile::tempdir().unwrap();
357        let skill_dir = dir.path().join("test-skill");
358        std::fs::create_dir(&skill_dir).unwrap();
359        std::fs::write(
360            skill_dir.join("SKILL.md"),
361            "---\nname: test-skill\ndescription: A test\n---\n# Test Skill\nHello.",
362        )
363        .unwrap();
364
365        let config = SkillsConfig {
366            load_paths: vec![],
367            require_signed: false,
368            allow_unsigned_from: vec![dir.path().to_path_buf()],
369            auto_pin: false,
370            scan_enabled: true,
371            custom_deny_patterns: vec![],
372        };
373        let mut loader = SkillLoader::new(config);
374        let skill = loader.load_skill(&skill_dir).unwrap();
375        assert_eq!(skill.name, "test-skill");
376        assert!(matches!(skill.signature_status, SignatureStatus::Unsigned));
377        assert!(skill.scan_result.is_some());
378        assert!(skill.scan_result.unwrap().passed);
379    }
380
381    #[test]
382    fn load_skill_missing_skill_md() {
383        let dir = tempfile::tempdir().unwrap();
384        let skill_dir = dir.path().join("empty-skill");
385        std::fs::create_dir(&skill_dir).unwrap();
386
387        let config = SkillsConfig::default();
388        let mut loader = SkillLoader::new(config);
389        assert!(loader.load_skill(&skill_dir).is_err());
390    }
391
392    #[test]
393    fn load_all_from_empty_paths() {
394        let config = SkillsConfig {
395            load_paths: vec![PathBuf::from("/nonexistent/path")],
396            require_signed: false,
397            allow_unsigned_from: vec![],
398            auto_pin: false,
399            scan_enabled: false,
400            custom_deny_patterns: vec![],
401        };
402        let mut loader = SkillLoader::new(config);
403        let skills = loader.load_all();
404        assert!(skills.is_empty());
405    }
406
407    #[test]
408    fn load_all_discovers_skills() {
409        let dir = tempfile::tempdir().unwrap();
410        // Create two skill directories
411        for name in &["skill-a", "skill-b"] {
412            let skill_dir = dir.path().join(name);
413            std::fs::create_dir(&skill_dir).unwrap();
414            std::fs::write(
415                skill_dir.join("SKILL.md"),
416                format!("---\nname: {}\n---\n# {}", name, name),
417            )
418            .unwrap();
419        }
420
421        let config = SkillsConfig {
422            load_paths: vec![dir.path().to_path_buf()],
423            require_signed: false,
424            allow_unsigned_from: vec![dir.path().to_path_buf()],
425            auto_pin: false,
426            scan_enabled: false,
427            custom_deny_patterns: vec![],
428        };
429        let mut loader = SkillLoader::new(config);
430        let skills = loader.load_all();
431        assert_eq!(skills.len(), 2);
432    }
433
434    #[test]
435    fn unsigned_allowed_check() {
436        let dir = tempfile::tempdir().unwrap();
437        let allowed = dir.path().join("allowed");
438        std::fs::create_dir(&allowed).unwrap();
439        let skill_dir = allowed.join("my-skill");
440        std::fs::create_dir(&skill_dir).unwrap();
441
442        let config = SkillsConfig {
443            load_paths: vec![],
444            require_signed: true,
445            allow_unsigned_from: vec![allowed.clone()],
446            auto_pin: false,
447            scan_enabled: false,
448            custom_deny_patterns: vec![],
449        };
450        let loader = SkillLoader::new(config);
451        assert!(loader.is_unsigned_allowed(&skill_dir));
452    }
453}