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