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. A
75/// description, and a compatibility note when present, must contain
76/// non-whitespace text; length limits count Unicode characters.
77pub fn validate_skill(text: &str) -> Result<SkillMetadata, SkillValidationError> {
78    let frontmatter = extract_frontmatter(text)?;
79    let metadata = from_str_with_config::<SkillMetadata>(frontmatter, &ParserConfig::strict())
80        .map_err(|error| {
81            SkillValidationError::new(format!("invalid YAML front matter: {error}"))
82        })?;
83    validate_metadata(&metadata)?;
84    Ok(metadata)
85}
86
87/// Validate one `SKILL.md` document and require its name to match its directory.
88pub fn validate_skill_named(
89    text: &str,
90    expected_name: &str,
91) -> Result<SkillMetadata, SkillValidationError> {
92    let metadata = validate_skill(text)?;
93    if metadata.name != expected_name {
94        return Err(SkillValidationError::new(format!(
95            "skill name {:?} does not match directory name {expected_name:?}",
96            metadata.name
97        )));
98    }
99    Ok(metadata)
100}
101
102fn extract_frontmatter(text: &str) -> Result<&str, SkillValidationError> {
103    let opening_end = text
104        .find('\n')
105        .ok_or_else(|| SkillValidationError::new("missing YAML front matter"))?;
106    if text[..opening_end].trim_end_matches('\r') != "---" {
107        return Err(SkillValidationError::new(
108            "missing opening --- YAML front matter delimiter",
109        ));
110    }
111
112    let frontmatter_start = opening_end + 1;
113    let mut line_start = frontmatter_start;
114    while line_start <= text.len() {
115        let line_end = text[line_start..]
116            .find('\n')
117            .map_or(text.len(), |offset| line_start + offset);
118        if text[line_start..line_end].trim_end_matches('\r') == "---" {
119            return Ok(&text[frontmatter_start..line_start]);
120        }
121        if line_end == text.len() {
122            break;
123        }
124        line_start = line_end + 1;
125    }
126    Err(SkillValidationError::new(
127        "missing closing --- YAML front matter delimiter",
128    ))
129}
130
131fn validate_metadata(metadata: &SkillMetadata) -> Result<(), SkillValidationError> {
132    validate_name(&metadata.name)?;
133    validate_length("description", &metadata.description, 1, 1024)?;
134    if let Some(compatibility) = metadata.compatibility.as_deref() {
135        validate_length("compatibility", compatibility, 1, 500)?;
136    }
137    Ok(())
138}
139
140fn validate_name(name: &str) -> Result<(), SkillValidationError> {
141    let length = name.chars().count();
142    if !(1..=64).contains(&length) {
143        return Err(SkillValidationError::new(
144            "name must contain between 1 and 64 characters",
145        ));
146    }
147    if !name
148        .bytes()
149        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
150    {
151        return Err(SkillValidationError::new(
152            "name may contain only lowercase ASCII letters, digits, and hyphens",
153        ));
154    }
155    if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
156        return Err(SkillValidationError::new(
157            "name must not start or end with a hyphen or contain consecutive hyphens",
158        ));
159    }
160    Ok(())
161}
162
163fn validate_length(
164    field: &str,
165    value: &str,
166    minimum: usize,
167    maximum: usize,
168) -> Result<(), SkillValidationError> {
169    if value.trim().is_empty() {
170        return Err(SkillValidationError::new(format!(
171            "{field} must contain non-whitespace text"
172        )));
173    }
174    let length = value.chars().count();
175    if !(minimum..=maximum).contains(&length) {
176        return Err(SkillValidationError::new(format!(
177            "{field} must contain between {minimum} and {maximum} characters"
178        )));
179    }
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    const VALID: &str = concat!(
188        "---\n",
189        "name: code-review\n",
190        "description: Reviews code. Use when checking a patch.\n",
191        "license: MIT\n",
192        "compatibility: Requires git\n",
193        "metadata:\n",
194        "  author: example-org\n",
195        "allowed-tools: Bash(git:*) Read\n",
196        "disable-model-invocation: false\n",
197        "user-invocable: true\n",
198        "---\n",
199        "# Code review\n",
200    );
201
202    #[test]
203    fn validates_spec_fields() {
204        let result = validate_skill_named(VALID, "code-review");
205        assert_eq!(
206            result.map(|metadata| metadata.name),
207            Ok("code-review".to_string())
208        );
209    }
210
211    #[test]
212    fn rejects_duplicate_or_unknown_fields() {
213        let duplicate = "---\nname: first\nname: second\ndescription: valid\n---\n";
214        assert!(validate_skill(duplicate).is_err());
215
216        let unknown = "---\nname: first\ndescription: valid\nversion: 1\n---\n";
217        assert!(validate_skill(unknown).is_err());
218    }
219
220    #[test]
221    fn rejects_invalid_names_and_directory_mismatches() {
222        let invalid = "---\nname: Bad--Name\ndescription: valid\n---\n";
223        assert!(validate_skill(invalid).is_err());
224        assert!(validate_skill_named(VALID, "other-name").is_err());
225    }
226
227    #[test]
228    fn supports_crlf_frontmatter() {
229        let input = "---\r\nname: code-review\r\ndescription: valid\r\n---\r\n# Body\r\n";
230        assert!(validate_skill(input).is_ok());
231    }
232
233    #[test]
234    fn rejects_blank_descriptions_and_present_but_blank_compatibility() {
235        for blank in ["", " ", "\n\t", "\u{3000}"] {
236            for field in ["description", "compatibility"] {
237                let mut metadata = validate_skill(VALID).unwrap();
238                if field == "description" {
239                    metadata.description = blank.to_owned();
240                } else {
241                    metadata.compatibility = Some(blank.to_owned());
242                }
243                let error = validate_metadata(&metadata).unwrap_err();
244                assert_eq!(
245                    error.message(),
246                    format!("{field} must contain non-whitespace text")
247                );
248            }
249        }
250    }
251
252    #[test]
253    fn metadata_limits_count_unicode_characters_without_rewriting_content() {
254        let description = format!(" {} ", "字".repeat(1022));
255        let input = format!("---\nname: code-review\ndescription: '{description}'\n---\n");
256        assert_eq!(validate_skill(&input).unwrap().description, description);
257        let too_long = format!(
258            "---\nname: code-review\ndescription: '{}'\n---\n",
259            "字".repeat(1025)
260        );
261        assert!(validate_skill(&too_long).is_err());
262    }
263}