Skip to main content

vtcode_skills/
file_references.rs

1//! File reference validation for Agent Skills
2//!
3//! Validates file references in SKILL.md bodies to ensure they meet
4//! the Agent Skills specification requirements.
5
6use hashbrown::HashSet;
7use regex::Regex;
8use std::path::{Path, PathBuf};
9
10/// Validates that file references in skill instructions follow the Agent Skills spec
11///
12/// Requirements:
13/// - References must be relative paths from the skill root
14/// - Must be one level deep (no nested chains like `references/subdir/file.md`)
15/// - Must reference files in supported directories: scripts/, references/, assets/
16/// - Referenced files must exist
17pub struct FileReferenceValidator {
18    skill_root: PathBuf,
19}
20
21impl FileReferenceValidator {
22    /// Create a new validator for a skill at the given root path
23    pub fn new(skill_root: PathBuf) -> Self {
24        Self { skill_root }
25    }
26
27    /// Validate all file references in the instruction text
28    ///
29    /// Returns a list of validation errors (empty if valid)
30    pub(crate) fn validate_references(&self, instructions: &str) -> Vec<String> {
31        let mut errors = Vec::new();
32        let references = self.extract_references(instructions);
33
34        for reference in &references {
35            if let Err(e) = self.validate_reference(reference) {
36                errors.push(format!("Invalid reference '{reference}': {e}"));
37            }
38        }
39
40        errors.sort();
41        errors
42    }
43
44    /// Extract file references from instruction text
45    ///
46    /// Looks for patterns like:
47    /// - `[text](references/FILE.md)`
48    /// - `scripts/script.py`
49    /// - `assets/image.png`
50    fn extract_references(&self, instructions: &str) -> HashSet<String> {
51        let mut references = HashSet::new();
52        let Ok(md_link_regex) = Regex::new(r"\[.*?\]\((.*?)\)") else {
53            return references;
54        };
55        let Ok(plain_path_regex) = Regex::new(r#"\b(scripts|references|assets)/[^\s\),\]`\"<>]+"#) else {
56            return references;
57        };
58
59        let link_ranges = md_link_regex
60            .find_iter(instructions)
61            .map(|matched| matched.range())
62            .collect::<Vec<_>>();
63
64        // Extract markdown links
65        for cap in md_link_regex.captures_iter(instructions) {
66            if let Some(path_match) = cap.get(1) {
67                let path = path_match.as_str().trim().trim_matches(['<', '>']);
68                // External links and same-document anchors are not bundled
69                // file references. Do not validate them as local paths.
70                if path.starts_with('#')
71                    || path.starts_with("//")
72                    || path.contains("://")
73                    || path.starts_with("mailto:")
74                {
75                    continue;
76                }
77                let path = path.split('#').next().unwrap_or(path).trim_end_matches(['`', '.', ';', ':']);
78                references.insert(path.to_string());
79            }
80        }
81
82        // Extract plain paths
83        for cap in plain_path_regex.captures_iter(instructions) {
84            if let Some(path_match) = cap.get(0) {
85                if link_ranges.iter().any(|range| range.contains(&path_match.start())) {
86                    continue;
87                }
88                let token_start = instructions[..path_match.start()]
89                    .rfind(char::is_whitespace)
90                    .map_or(0, |offset| offset + 1);
91                if instructions[token_start..path_match.start()].contains("://") {
92                    continue;
93                }
94                references.insert(path_match.as_str().trim_end_matches(['.', ';', ':']).to_string());
95            }
96        }
97
98        references
99    }
100
101    /// Validate a single file reference
102    fn validate_reference(&self, reference: &str) -> Result<(), String> {
103        // Check if it's a valid path format
104        let path = Path::new(reference);
105
106        // Must be relative (no absolute paths)
107        if path.is_absolute() {
108            return Err("Absolute paths are not allowed".to_string());
109        }
110
111        // Must be within supported directories
112        let components: Vec<_> = path.components().collect();
113        if components.is_empty() {
114            return Err("Empty path".to_string());
115        }
116
117        // Check first component is a supported directory
118        if let Some(first_component) = components.first() {
119            let first_dir = first_component.as_os_str().to_string_lossy();
120            if !matches!(first_dir.as_ref(), "scripts" | "references" | "assets") {
121                return Err(format!(
122                    "Invalid directory '{first_dir}'. Must be 'scripts/', 'references/', or 'assets/'"
123                ));
124            }
125        }
126
127        // Check depth - must be one level deep (e.g., scripts/file.py, not scripts/subdir/file.py)
128        if components.len() > 2 {
129            return Err(format!(
130                "Path is too deep: '{reference}'. Per Agent Skills spec, references must be one level deep."
131            ));
132        }
133
134        // For paths with 2 components (dir + file), validate file exists
135        if components.len() == 2 {
136            let full_path = self.skill_root.join(path);
137            if !full_path.exists() {
138                return Err(format!("Referenced file does not exist: {full_path:?}"));
139            }
140        }
141
142        Ok(())
143    }
144
145    /// Get all valid references from a skill directory
146    pub fn list_valid_references(&self) -> Vec<PathBuf> {
147        let mut references = Vec::new();
148
149        for subdir in &["scripts", "references", "assets"] {
150            let dir = self.skill_root.join(subdir);
151            if dir.is_dir()
152                && let Ok(entries) = std::fs::read_dir(&dir)
153            {
154                for entry in entries.flatten() {
155                    let path = entry.path();
156                    if path.is_file() {
157                        references.push(path.strip_prefix(&self.skill_root).unwrap_or(&path).to_path_buf());
158                    }
159                }
160            }
161        }
162
163        references
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::fs;
171    use tempfile::TempDir;
172
173    #[test]
174    fn test_valid_file_references() {
175        let temp_dir = TempDir::new().unwrap();
176        let skill_root = temp_dir.path().to_path_buf();
177
178        // Create test files
179        fs::create_dir(skill_root.join("scripts")).unwrap();
180        fs::write(skill_root.join("scripts/helper.py"), "# test").unwrap();
181
182        let validator = FileReferenceValidator::new(skill_root);
183        let instructions = r#"
184            See [the reference](references/REFERENCE.md) for details.
185            Run the extraction script: scripts/helper.py
186        "#;
187
188        let errors = validator.validate_references(instructions);
189        // Should have an error for references/REFERENCE.md (doesn't exist)
190        assert_eq!(errors.len(), 1);
191        assert!(errors[0].contains("references/REFERENCE.md"));
192    }
193
194    #[test]
195    fn markdown_paths_are_normalized_and_external_links_ignored() {
196        let temp = TempDir::new().unwrap();
197        fs::create_dir(temp.path().join("scripts")).unwrap();
198        fs::write(temp.path().join("scripts/check.sh"), "exit 0").unwrap();
199        let validator = FileReferenceValidator::new(temp.path().to_path_buf());
200        let text = "Run `scripts/check.sh`. Then [check](scripts/check.sh#usage). See [upstream](https://github.com/astral-sh/hawk), [remote asset](https://example.com/assets/image.png), https://example.com/scripts/check.sh, [section](#usage), [mail](mailto:owner@example.com).";
201        assert!(validator.validate_references(text).is_empty());
202        let errors = validator.validate_references(
203            "Run `scripts/missing.sh`. See [missing](scripts/missing.sh). Run `scripts/other.sh`.",
204        );
205        assert_eq!(errors.len(), 2, "inline and linked copies must deduplicate");
206        assert!(errors[0].contains("'scripts/missing.sh'"));
207        assert!(errors[1].contains("'scripts/other.sh'"));
208        assert!(!errors.iter().any(|error| error.contains('`')));
209    }
210
211    #[test]
212    fn test_invalid_directory() {
213        let validator = FileReferenceValidator::new(PathBuf::from("/tmp"));
214        // Use a valid directory pattern but non-existent file
215        let errors = validator.validate_references("See `scripts/nonexistent.py`");
216        assert!(!errors.is_empty());
217        assert!(errors[0].contains("nonexistent.py"));
218    }
219
220    #[test]
221    fn test_deep_path_error() {
222        let validator = FileReferenceValidator::new(PathBuf::from("/tmp"));
223        let errors = validator.validate_references("See `scripts/subdir/deep.py`");
224        assert!(!errors.is_empty());
225        assert!(errors[0].contains("too deep"));
226    }
227
228    #[test]
229    fn test_list_valid_references() {
230        let temp_dir = TempDir::new().unwrap();
231        let skill_root = temp_dir.path().to_path_buf();
232
233        fs::create_dir(skill_root.join("scripts")).unwrap();
234        fs::create_dir(skill_root.join("references")).unwrap();
235        fs::write(skill_root.join("scripts/test.py"), "# test").unwrap();
236        fs::write(skill_root.join("references/ref.md"), "# ref").unwrap();
237
238        let validator = FileReferenceValidator::new(skill_root);
239        let refs = validator.list_valid_references();
240
241        assert_eq!(refs.len(), 2);
242        assert!(refs.iter().any(|p| p.to_string_lossy() == "scripts/test.py"));
243        assert!(refs.iter().any(|p| p.to_string_lossy() == "references/ref.md"));
244    }
245}