agent_first_data/
skill_validation.rs1use 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#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
18#[serde(deny_unknown_fields)]
19pub struct SkillMetadata {
20 pub name: String,
22 pub description: String,
24 pub license: Option<String>,
26 pub compatibility: Option<String>,
28 #[serde(default)]
30 pub metadata: BTreeMap<String, String>,
31 #[serde(rename = "allowed-tools")]
33 pub allowed_tools: Option<String>,
34 #[serde(rename = "disable-model-invocation")]
36 pub disable_model_invocation: Option<bool>,
37 #[serde(rename = "user-invocable")]
39 pub user_invocable: Option<bool>,
40}
41
42#[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 #[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
70pub 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
87pub 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}