Skip to main content

agent_first_data/
skill_validation.rs

1//! Agent Skills `SKILL.md` front-matter validation.
2//!
3//! This module implements the metadata constraints from the Agent Skills
4//! specification plus explicitly modeled, common client extensions. It parses
5//! only the YAML front matter; Markdown body content is intentionally left
6//! unrestricted.
7
8use noyalib::{ParserConfig, from_str_with_config};
9use serde::Deserialize;
10use std::collections::BTreeMap;
11use std::fmt::{Display, Formatter};
12
13#[cfg(feature = "skill-admin")]
14pub use crate::skill_admin::*;
15
16/// Validated Agent Skill metadata.
17#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
18#[serde(deny_unknown_fields)]
19pub struct SkillMetadata {
20    /// Skill name and directory name.
21    pub name: String,
22    /// What the skill does and when an agent should use it.
23    pub description: String,
24    /// License name or reference to a bundled license file.
25    pub license: Option<String>,
26    /// Optional environment requirements.
27    pub compatibility: Option<String>,
28    /// Extension metadata defined by clients or skill authors.
29    #[serde(default)]
30    pub metadata: BTreeMap<String, String>,
31    /// Experimental, space-separated pre-approved tool declarations.
32    #[serde(rename = "allowed-tools")]
33    pub allowed_tools: Option<String>,
34    /// Claude-compatible extension that prevents automatic model invocation.
35    #[serde(rename = "disable-model-invocation")]
36    pub disable_model_invocation: Option<bool>,
37    /// Claude-compatible extension that controls direct user invocation.
38    #[serde(rename = "user-invocable")]
39    pub user_invocable: Option<bool>,
40}
41
42/// A deterministic `SKILL.md` validation failure.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct SkillValidationError {
45    message: String,
46}
47
48impl SkillValidationError {
49    fn new(message: impl Into<String>) -> Self {
50        Self {
51            message: message.into(),
52        }
53    }
54
55    /// Returns the operator-facing failure detail.
56    #[must_use]
57    pub fn message(&self) -> &str {
58        &self.message
59    }
60}
61
62impl Display for SkillValidationError {
63    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
64        formatter.write_str(&self.message)
65    }
66}
67
68impl std::error::Error for SkillValidationError {}
69
70/// Validate one `SKILL.md` document and return its typed metadata.
71///
72/// The YAML parser uses its strict resource and duplicate-key policy. The
73/// metadata fields and limits follow the Agent Skills specification; unknown
74/// fields outside the documented client extensions are rejected.
75pub fn validate_skill(text: &str) -> Result<SkillMetadata, SkillValidationError> {
76    let frontmatter = extract_frontmatter(text)?;
77    let metadata = from_str_with_config::<SkillMetadata>(frontmatter, &ParserConfig::strict())
78        .map_err(|error| {
79            SkillValidationError::new(format!("invalid YAML front matter: {error}"))
80        })?;
81    validate_metadata(&metadata)?;
82    Ok(metadata)
83}
84
85/// Validate one `SKILL.md` document and require its name to match its directory.
86pub fn validate_skill_named(
87    text: &str,
88    expected_name: &str,
89) -> Result<SkillMetadata, SkillValidationError> {
90    let metadata = validate_skill(text)?;
91    if metadata.name != expected_name {
92        return Err(SkillValidationError::new(format!(
93            "skill name {:?} does not match directory name {expected_name:?}",
94            metadata.name
95        )));
96    }
97    Ok(metadata)
98}
99
100fn extract_frontmatter(text: &str) -> Result<&str, SkillValidationError> {
101    let opening_end = text
102        .find('\n')
103        .ok_or_else(|| SkillValidationError::new("missing YAML front matter"))?;
104    if text[..opening_end].trim_end_matches('\r') != "---" {
105        return Err(SkillValidationError::new(
106            "missing opening --- YAML front matter delimiter",
107        ));
108    }
109
110    let frontmatter_start = opening_end + 1;
111    let mut line_start = frontmatter_start;
112    while line_start <= text.len() {
113        let line_end = text[line_start..]
114            .find('\n')
115            .map_or(text.len(), |offset| line_start + offset);
116        if text[line_start..line_end].trim_end_matches('\r') == "---" {
117            return Ok(&text[frontmatter_start..line_start]);
118        }
119        if line_end == text.len() {
120            break;
121        }
122        line_start = line_end + 1;
123    }
124    Err(SkillValidationError::new(
125        "missing closing --- YAML front matter delimiter",
126    ))
127}
128
129fn validate_metadata(metadata: &SkillMetadata) -> Result<(), SkillValidationError> {
130    validate_name(&metadata.name)?;
131    validate_length("description", &metadata.description, 1, 1024)?;
132    if let Some(compatibility) = metadata.compatibility.as_deref() {
133        validate_length("compatibility", compatibility, 1, 500)?;
134    }
135    Ok(())
136}
137
138fn validate_name(name: &str) -> Result<(), SkillValidationError> {
139    let length = name.chars().count();
140    if !(1..=64).contains(&length) {
141        return Err(SkillValidationError::new(
142            "name must contain between 1 and 64 characters",
143        ));
144    }
145    if !name
146        .bytes()
147        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
148    {
149        return Err(SkillValidationError::new(
150            "name may contain only lowercase ASCII letters, digits, and hyphens",
151        ));
152    }
153    if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
154        return Err(SkillValidationError::new(
155            "name must not start or end with a hyphen or contain consecutive hyphens",
156        ));
157    }
158    Ok(())
159}
160
161fn validate_length(
162    field: &str,
163    value: &str,
164    minimum: usize,
165    maximum: usize,
166) -> Result<(), SkillValidationError> {
167    let length = value.chars().count();
168    if !(minimum..=maximum).contains(&length) {
169        return Err(SkillValidationError::new(format!(
170            "{field} must contain between {minimum} and {maximum} characters"
171        )));
172    }
173    Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    const VALID: &str = concat!(
181        "---\n",
182        "name: code-review\n",
183        "description: Reviews code. Use when checking a patch.\n",
184        "license: MIT\n",
185        "compatibility: Requires git\n",
186        "metadata:\n",
187        "  author: example-org\n",
188        "allowed-tools: Bash(git:*) Read\n",
189        "disable-model-invocation: false\n",
190        "user-invocable: true\n",
191        "---\n",
192        "# Code review\n",
193    );
194
195    #[test]
196    fn validates_spec_fields() {
197        let result = validate_skill_named(VALID, "code-review");
198        assert_eq!(
199            result.map(|metadata| metadata.name),
200            Ok("code-review".to_string())
201        );
202    }
203
204    #[test]
205    fn rejects_duplicate_or_unknown_fields() {
206        let duplicate = "---\nname: first\nname: second\ndescription: valid\n---\n";
207        assert!(validate_skill(duplicate).is_err());
208
209        let unknown = "---\nname: first\ndescription: valid\nversion: 1\n---\n";
210        assert!(validate_skill(unknown).is_err());
211    }
212
213    #[test]
214    fn rejects_invalid_names_and_directory_mismatches() {
215        let invalid = "---\nname: Bad--Name\ndescription: valid\n---\n";
216        assert!(validate_skill(invalid).is_err());
217        assert!(validate_skill_named(VALID, "other-name").is_err());
218    }
219
220    #[test]
221    fn supports_crlf_frontmatter() {
222        let input = "---\r\nname: code-review\r\ndescription: valid\r\n---\r\n# Body\r\n";
223        assert!(validate_skill(input).is_ok());
224    }
225}