Skip to main content

secunit_core/
skills.rs

1//! Bundled standard-library skills + the one resolver every skill
2//! reference goes through.
3//!
4//! A skill name resolves the same way everywhere it appears — a control's
5//! `skill:`, a runbook's `skill_args.extend:`, `validate`, `run prepare`,
6//! and `secunit skills show`: org-local `<root>/skills/<name>.md` first
7//! (the override), then the copy bundled into this binary. Bundled skills
8//! ship with the release, so an org needs no install step; it overrides a
9//! skill — spine or fragment — by dropping a same-named file under
10//! `skills/`.
11
12use std::path::{Path, PathBuf};
13
14use serde::Serialize;
15
16use crate::evidence::hasher::sha256_bytes;
17
18/// A skill embedded into the binary at compile time.
19pub struct BundledSkill {
20    pub name: &'static str,
21    pub body: &'static str,
22}
23
24macro_rules! bundled {
25    ($name:literal) => {
26        BundledSkill {
27            name: $name,
28            // Relative to this file (crates/secunit-core/src/skills.rs); the
29            // bundled `skills/` dir lives in the crate root so it ships inside
30            // the published crate tarball.
31            body: include_str!(concat!("../skills/", $name, ".md")),
32        }
33    };
34}
35
36/// The standard library. Generic, org-agnostic runbooks; org specifics
37/// flow in through control `skill_args` and `_config.yaml`, never through
38/// the skill text. Keep this list in sync with `skills/`.
39pub const BUNDLED: &[BundledSkill] = &[
40    bundled!("capture-sweep"),
41    bundled!("attestation-review"),
42    bundled!("policy-annual-review"),
43    bundled!("report"),
44    bundled!("bootstrap"),
45    bundled!("inventory-seed"),
46];
47
48/// Where a resolved skill came from.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum SkillSource {
52    /// An org-local file at `<root>/skills/<name>.md` (overrides bundled).
53    Local,
54    /// A copy compiled into this binary.
55    Bundled,
56}
57
58impl SkillSource {
59    pub fn as_str(self) -> &'static str {
60        match self {
61            SkillSource::Local => "local",
62            SkillSource::Bundled => "bundled",
63        }
64    }
65}
66
67/// A skill resolved by name, carrying the sha256 that pins it into a run
68/// manifest.
69#[derive(Debug, Clone)]
70pub struct ResolvedSkill {
71    pub name: String,
72    pub body: String,
73    pub source: SkillSource,
74    /// The file path for a `Local` skill; `None` when bundled.
75    pub path: Option<PathBuf>,
76    pub sha256: String,
77}
78
79/// Resolve a skill by name: org-local file first, then the bundled copy.
80/// `None` if neither has it.
81pub fn resolve(root: &Path, name: &str) -> Option<ResolvedSkill> {
82    let local = root.join("skills").join(format!("{name}.md"));
83    if local.is_file() {
84        // Read once: the sha is over the raw bytes (matching the manifest's
85        // artifact-hashing convention and any chain sealed before bundling),
86        // and the body is decoded from those same bytes. If the file can't be
87        // read, warn and fall through to bundled rather than returning a
88        // skill with a silently-empty body.
89        match std::fs::read(&local) {
90            Ok(bytes) => {
91                return Some(ResolvedSkill {
92                    name: name.to_string(),
93                    sha256: sha256_bytes(&bytes),
94                    body: String::from_utf8_lossy(&bytes).into_owned(),
95                    source: SkillSource::Local,
96                    path: Some(local),
97                });
98            }
99            Err(e) => {
100                tracing::warn!(
101                    path = %local.display(),
102                    error = %e,
103                    "skill `{name}` exists locally but could not be read; falling back to bundled"
104                );
105            }
106        }
107    }
108    BUNDLED
109        .iter()
110        .find(|s| s.name == name)
111        .map(|s| ResolvedSkill {
112            name: name.to_string(),
113            body: s.body.to_string(),
114            source: SkillSource::Bundled,
115            path: None,
116            sha256: sha256_bytes(s.body.as_bytes()),
117        })
118}
119
120/// True if `name` resolves to anything (org-local or bundled).
121pub fn exists(root: &Path, name: &str) -> bool {
122    root.join("skills").join(format!("{name}.md")).is_file()
123        || BUNDLED.iter().any(|s| s.name == name)
124}
125
126/// Pull the YAML frontmatter block out of a skill markdown body. Returns
127/// `None` if the body does not start with `---\n`.
128pub fn frontmatter(body: &str) -> Option<&str> {
129    let rest = body.strip_prefix("---\n")?;
130    let end = rest.find("\n---")?;
131    Some(&rest[..end])
132}
133
134/// The one-line `description:` from a skill's frontmatter, if present.
135pub fn description(body: &str) -> Option<String> {
136    let fm = frontmatter(body)?;
137    let parsed: serde_yaml::Value = serde_yaml::from_str(fm).ok()?;
138    parsed
139        .get("description")
140        .and_then(|v| v.as_str())
141        .map(str::to_string)
142}
143
144/// The `requires_features:` list from a skill's frontmatter, if present.
145pub fn requires_features(body: &str) -> Vec<String> {
146    let Some(fm) = frontmatter(body) else {
147        return Vec::new();
148    };
149    let Ok(parsed) = serde_yaml::from_str::<serde_yaml::Value>(fm) else {
150        return Vec::new();
151    };
152    parsed
153        .get("requires_features")
154        .and_then(|v| v.as_sequence())
155        .map(|seq| {
156            seq.iter()
157                .filter_map(|i| i.as_str().map(str::to_string))
158                .collect()
159        })
160        .unwrap_or_default()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn every_bundled_skill_has_matching_frontmatter_name() {
169        for s in BUNDLED {
170            let fm = frontmatter(s.body)
171                .unwrap_or_else(|| panic!("bundled skill {} lacks frontmatter", s.name));
172            let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
173            assert_eq!(
174                parsed.get("name").and_then(|v| v.as_str()),
175                Some(s.name),
176                "bundled skill `{}` frontmatter name must match its registry key",
177                s.name
178            );
179        }
180    }
181
182    #[test]
183    fn resolve_falls_back_to_bundled() {
184        // A fresh temp dir has no skills/, so resolution must hit the bundle.
185        let tmp = tempfile::tempdir().unwrap();
186        let r = resolve(tmp.path(), "capture-sweep").expect("capture-sweep is bundled");
187        assert_eq!(r.source, SkillSource::Bundled);
188        assert!(!r.sha256.is_empty());
189        assert!(r.body.contains("# Capture sweep"));
190    }
191
192    #[test]
193    fn resolve_local_overrides_bundled() {
194        // A local file shadows the bundled skill of the same name, and its
195        // sha is over the file's raw bytes.
196        let tmp = tempfile::tempdir().unwrap();
197        std::fs::create_dir(tmp.path().join("skills")).unwrap();
198        let body = "---\nname: capture-sweep\n---\n# local override\n";
199        std::fs::write(tmp.path().join("skills/capture-sweep.md"), body).unwrap();
200        let r = resolve(tmp.path(), "capture-sweep").expect("resolves locally");
201        assert_eq!(r.source, SkillSource::Local);
202        assert_eq!(r.body, body);
203        assert_eq!(r.sha256, sha256_bytes(body.as_bytes()));
204    }
205
206    #[test]
207    fn resolve_unknown_is_none() {
208        let tmp = tempfile::tempdir().unwrap();
209        assert!(resolve(tmp.path(), "no-such-skill").is_none());
210    }
211
212    #[test]
213    fn frontmatter_extract_basic() {
214        let body = "---\nname: x\nrequires_features: [a, b]\n---\n# body";
215        assert_eq!(requires_features(body), vec!["a", "b"]);
216    }
217}