Skip to main content

zeroclaw_runtime/skills/
audit.rs

1use anyhow::{Context, Result, bail};
2use regex::Regex;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5use std::sync::OnceLock;
6
7use super::constants::{SKILL_DEPRECATED_MANIFESTS, SKILL_MANIFEST_FILENAME};
8
9const MAX_TEXT_FILE_BYTES: u64 = 512 * 1024;
10
11#[derive(Debug, Clone, Copy, Default)]
12pub struct SkillAuditOptions {
13    pub allow_scripts: bool,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct SkillAuditReport {
18    pub files_scanned: usize,
19    pub findings: Vec<String>,
20}
21
22impl SkillAuditReport {
23    pub fn is_clean(&self) -> bool {
24        self.findings.is_empty()
25    }
26
27    pub fn summary(&self) -> String {
28        self.findings.join("; ")
29    }
30}
31
32pub fn audit_skill_directory(skill_dir: &Path) -> Result<SkillAuditReport> {
33    audit_skill_directory_with_options(skill_dir, SkillAuditOptions::default())
34}
35
36pub fn audit_skill_directory_with_options(
37    skill_dir: &Path,
38    options: SkillAuditOptions,
39) -> Result<SkillAuditReport> {
40    if !skill_dir.exists() {
41        bail!(
42            "Skill source does not exist: {}",
43            skill_dir.display().to_string()
44        );
45    }
46    if !skill_dir.is_dir() {
47        bail!(
48            "Skill source must be a directory: {}",
49            skill_dir.display().to_string()
50        );
51    }
52
53    let canonical_root = skill_dir
54        .canonicalize()
55        .with_context(|| format!("failed to canonicalize {}", skill_dir.display().to_string()))?;
56    let mut report = SkillAuditReport::default();
57
58    let has_canonical = canonical_root.join(SKILL_MANIFEST_FILENAME).is_file();
59    let has_deprecated = SKILL_DEPRECATED_MANIFESTS
60        .iter()
61        .any(|name| canonical_root.join(name).is_file());
62    if !has_canonical && !has_deprecated {
63        report.findings.push(format!(
64            "Skill root must include {SKILL_MANIFEST_FILENAME} (canonical) or one of {SKILL_DEPRECATED_MANIFESTS:?} (deprecated) for deterministic auditing.",
65        ));
66    }
67
68    for path in collect_paths_depth_first(&canonical_root)? {
69        report.files_scanned += 1;
70        audit_path(&canonical_root, &path, &mut report, options)?;
71    }
72
73    Ok(report)
74}
75
76pub fn audit_open_skill_markdown(path: &Path, repo_root: &Path) -> Result<SkillAuditReport> {
77    if !path.exists() {
78        bail!(
79            "Open-skill markdown not found: {}",
80            path.display().to_string()
81        );
82    }
83    let canonical_repo = repo_root
84        .canonicalize()
85        .with_context(|| format!("failed to canonicalize {}", repo_root.display().to_string()))?;
86    let canonical_path = path
87        .canonicalize()
88        .with_context(|| format!("failed to canonicalize {}", path.display().to_string()))?;
89    if !canonical_path.starts_with(&canonical_repo) {
90        bail!(
91            "Open-skill markdown escapes repository root: {}",
92            path.display()
93        );
94    }
95
96    let mut report = SkillAuditReport {
97        files_scanned: 1,
98        findings: Vec::new(),
99    };
100    audit_markdown_file(&canonical_repo, &canonical_path, &mut report)?;
101    Ok(report)
102}
103
104fn collect_paths_depth_first(root: &Path) -> Result<Vec<PathBuf>> {
105    let mut stack = vec![root.to_path_buf()];
106    let mut out = Vec::new();
107
108    while let Some(current) = stack.pop() {
109        out.push(current.clone());
110
111        if !current.is_dir() {
112            continue;
113        }
114
115        let mut children = Vec::new();
116        for entry in fs::read_dir(&current).with_context(|| {
117            format!("failed to read directory {}", current.display().to_string())
118        })? {
119            let entry = entry?;
120            children.push(entry.path());
121        }
122
123        children.sort();
124        for child in children.into_iter().rev() {
125            stack.push(child);
126        }
127    }
128
129    Ok(out)
130}
131
132fn audit_path(
133    root: &Path,
134    path: &Path,
135    report: &mut SkillAuditReport,
136    options: SkillAuditOptions,
137) -> Result<()> {
138    let metadata = fs::symlink_metadata(path)
139        .with_context(|| format!("failed to read metadata for {}", path.display().to_string()))?;
140    let rel = relative_display(root, path);
141
142    if metadata.file_type().is_symlink() {
143        report.findings.push(format!(
144            "{rel}: symlinks are not allowed in installed skills."
145        ));
146        return Ok(());
147    }
148
149    if metadata.is_dir() {
150        return Ok(());
151    }
152
153    if !options.allow_scripts && is_unsupported_script_file(path) {
154        report.findings.push(format!(
155            "{rel}: script-like files are blocked by skill security policy."
156        ));
157    }
158
159    if metadata.len() > MAX_TEXT_FILE_BYTES && (is_markdown_file(path) || is_toml_file(path)) {
160        report.findings.push(format!(
161            "{rel}: file is too large for static audit (>{MAX_TEXT_FILE_BYTES} bytes)."
162        ));
163        return Ok(());
164    }
165
166    if is_markdown_file(path) {
167        audit_markdown_file(root, path, report)?;
168    } else if is_toml_file(path) {
169        audit_manifest_file(root, path, report)?;
170    }
171
172    Ok(())
173}
174
175fn audit_markdown_file(root: &Path, path: &Path, report: &mut SkillAuditReport) -> Result<()> {
176    let content = fs::read_to_string(path).with_context(|| {
177        format!(
178            "failed to read markdown file {}",
179            path.display().to_string()
180        )
181    })?;
182
183    for raw_target in extract_markdown_links(&content) {
184        audit_markdown_link_target(root, path, &raw_target, report);
185    }
186
187    Ok(())
188}
189
190fn audit_manifest_file(root: &Path, path: &Path, report: &mut SkillAuditReport) -> Result<()> {
191    let content = fs::read_to_string(path).with_context(|| {
192        format!(
193            "failed to read TOML manifest {}",
194            path.display().to_string()
195        )
196    })?;
197    let rel = relative_display(root, path);
198    let parsed: toml::Value = match toml::from_str(&content) {
199        Ok(value) => value,
200        Err(err) => {
201            report
202                .findings
203                .push(format!("{rel}: invalid TOML manifest ({err})."));
204            return Ok(());
205        }
206    };
207
208    if let Some(tools) = parsed.get("tools").and_then(toml::Value::as_array) {
209        for (idx, tool) in tools.iter().enumerate() {
210            let command = tool.get("command").and_then(toml::Value::as_str);
211            let kind = tool
212                .get("kind")
213                .and_then(toml::Value::as_str)
214                .unwrap_or("unknown");
215
216            if command.is_none() {
217                report
218                    .findings
219                    .push(format!("{rel}: tools[{idx}] is missing a command field."));
220            }
221
222            if (kind.eq_ignore_ascii_case("script") || kind.eq_ignore_ascii_case("shell"))
223                && command.is_some_and(|value| value.trim().is_empty())
224            {
225                report
226                    .findings
227                    .push(format!("{rel}: tools[{idx}] has an empty {kind} command."));
228            }
229        }
230    }
231
232    Ok(())
233}
234
235fn audit_markdown_link_target(
236    root: &Path,
237    source: &Path,
238    raw: &str,
239    report: &mut SkillAuditReport,
240) {
241    let normalized = normalize_markdown_target(raw);
242    if normalized.is_empty() || normalized.starts_with('#') {
243        return;
244    }
245
246    let rel = relative_display(root, source);
247
248    if let Some(scheme) = url_scheme(normalized) {
249        if matches!(scheme, "http" | "https" | "mailto") {
250            if has_markdown_suffix(normalized) {
251                report.findings.push(format!(
252                    "{rel}: remote markdown links are blocked by skill security audit ({normalized})."
253                ));
254            }
255            return;
256        }
257
258        report.findings.push(format!(
259            "{rel}: unsupported URL scheme in markdown link ({normalized})."
260        ));
261        return;
262    }
263
264    let stripped = strip_query_and_fragment(normalized);
265    if stripped.is_empty() {
266        return;
267    }
268
269    if looks_like_absolute_path(stripped) {
270        report.findings.push(format!(
271            "{rel}: absolute markdown link paths are not allowed ({normalized})."
272        ));
273        return;
274    }
275
276    if has_script_suffix(stripped) {
277        report.findings.push(format!(
278            "{rel}: markdown links to script files are blocked ({normalized})."
279        ));
280    }
281
282    if !has_markdown_suffix(stripped) {
283        return;
284    }
285
286    let Some(base_dir) = source.parent() else {
287        report.findings.push(format!(
288            "{rel}: failed to resolve parent directory for markdown link ({normalized})."
289        ));
290        return;
291    };
292    let linked_path = base_dir.join(stripped);
293
294    match linked_path.canonicalize() {
295        Ok(canonical_target) => {
296            if !canonical_target.starts_with(root) {
297                // Allow cross-skill markdown references that stay within the
298                // overall skills directory (e.g., ~/.zeroclaw/workspace/skills).
299                if let Some(skills_root) = skills_root_for(root)
300                    && canonical_target.starts_with(&skills_root)
301                {
302                    // The link resolves to another installed skill under the same
303                    // trusted skills root, so it is considered safe.
304                    if !canonical_target.is_file() {
305                        report.findings.push(format!(
306                            "{rel}: markdown link must point to a file ({normalized})."
307                        ));
308                    }
309                    return;
310                }
311
312                report.findings.push(format!(
313                    "{rel}: markdown link escapes skill root ({normalized})."
314                ));
315                return;
316            }
317            if !canonical_target.is_file() {
318                report.findings.push(format!(
319                    "{rel}: markdown link must point to a file ({normalized})."
320                ));
321            }
322        }
323        Err(_) => {
324            // Check if this is a cross-skill reference (links outside current skill directory)
325            // Cross-skill references are allowed to point to missing files since the referenced
326            // skill may not be installed. This is common in open-skills where skills reference
327            // each other but not all skills are necessarily present.
328            if is_cross_skill_reference(stripped) {
329                // Allow missing cross-skill references - this is valid for open-skills
330                return;
331            }
332            report.findings.push(format!(
333                "{rel}: markdown link points to a missing file ({normalized})."
334            ));
335        }
336    }
337}
338
339/// Check if a link target appears to be a cross-skill reference.
340/// Cross-skill references can take several forms:
341/// 1. Parent directory traversal: `../other-skill/SKILL.md`
342/// 2. Bare skill filename: `other-skill.md` (reference to another skill's markdown)
343/// 3. Explicit relative path: `./other-skill.md`
344fn is_cross_skill_reference(target: &str) -> bool {
345    let path = Path::new(target);
346
347    // Case 1: Uses parent directory traversal (..)
348    if path
349        .components()
350        .any(|component| component == Component::ParentDir)
351    {
352        return true;
353    }
354
355    // Case 2 & 3: Bare filename or ./filename that looks like a skill reference
356    // A skill reference is typically a bare markdown filename like "skill-name.md"
357    // without any directory separators (or just "./" prefix)
358    let stripped = target.strip_prefix("./").unwrap_or(target);
359
360    // If it's just a filename (no path separators) with .md extension,
361    // it's likely a cross-skill reference
362    !stripped.contains('/') && !stripped.contains('\\') && has_markdown_suffix(stripped)
363}
364
365/// Best-effort detection of the shared skills directory root for an installed skill.
366/// This looks for the nearest ancestor directory named "skills" and treats it as
367/// the logical root for sibling skill references.
368fn skills_root_for(root: &Path) -> Option<PathBuf> {
369    let mut current = root;
370    loop {
371        if current.file_name().is_some_and(|name| name == "skills") {
372            return Some(current.to_path_buf());
373        }
374        current = current.parent()?;
375    }
376}
377
378fn relative_display(root: &Path, path: &Path) -> String {
379    if let Ok(rel) = path.strip_prefix(root) {
380        if rel.as_os_str().is_empty() {
381            return ".".to_string();
382        }
383        return rel.display().to_string();
384    }
385    path.display().to_string()
386}
387
388fn is_markdown_file(path: &Path) -> bool {
389    path.extension()
390        .and_then(|ext| ext.to_str())
391        .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown"))
392}
393
394fn is_toml_file(path: &Path) -> bool {
395    path.extension()
396        .and_then(|ext| ext.to_str())
397        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
398}
399
400fn is_unsupported_script_file(path: &Path) -> bool {
401    has_script_suffix(path.to_string_lossy().as_ref()) || has_shell_shebang(path)
402}
403
404fn has_script_suffix(raw: &str) -> bool {
405    let lowered = raw.to_ascii_lowercase();
406    let script_suffixes = [
407        ".sh", ".bash", ".zsh", ".ksh", ".fish", ".ps1", ".bat", ".cmd",
408    ];
409    script_suffixes
410        .iter()
411        .any(|suffix| lowered.ends_with(suffix))
412}
413
414fn has_shell_shebang(path: &Path) -> bool {
415    let Ok(content) = fs::read(path) else {
416        return false;
417    };
418    let prefix = &content[..content.len().min(128)];
419    let shebang_line = String::from_utf8_lossy(prefix)
420        .lines()
421        .next()
422        .unwrap_or_default()
423        .trim()
424        .to_ascii_lowercase();
425    let Some(interpreter) = shebang_interpreter(&shebang_line) else {
426        return false;
427    };
428
429    matches!(
430        interpreter,
431        "sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell"
432    )
433}
434
435fn shebang_interpreter(line: &str) -> Option<&str> {
436    let shebang = line.strip_prefix("#!")?.trim();
437    if shebang.is_empty() {
438        return None;
439    }
440
441    let mut parts = shebang.split_whitespace();
442    let first = parts.next()?;
443    let first_basename = Path::new(first).file_name()?.to_str()?;
444
445    if first_basename == "env" {
446        for part in parts {
447            if part.starts_with('-') {
448                continue;
449            }
450            return Path::new(part).file_name()?.to_str();
451        }
452        return None;
453    }
454
455    Some(first_basename)
456}
457
458fn extract_markdown_links(content: &str) -> Vec<String> {
459    static MARKDOWN_LINK_RE: OnceLock<Regex> = OnceLock::new();
460    let regex = MARKDOWN_LINK_RE.get_or_init(|| {
461        Regex::new(r#"\[[^\]]*\]\(([^)]+)\)"#).expect("markdown link regex must compile")
462    });
463
464    regex
465        .captures_iter(content)
466        .filter_map(|capture| capture.get(1))
467        .map(|target| target.as_str().trim().to_string())
468        .collect()
469}
470
471fn normalize_markdown_target(raw_target: &str) -> &str {
472    let trimmed = raw_target.trim();
473    let trimmed = trimmed.strip_prefix('<').unwrap_or(trimmed);
474    let trimmed = trimmed.strip_suffix('>').unwrap_or(trimmed);
475    trimmed.split_whitespace().next().unwrap_or_default()
476}
477
478fn strip_query_and_fragment(input: &str) -> &str {
479    let mut end = input.len();
480    if let Some(idx) = input.find('#') {
481        end = end.min(idx);
482    }
483    if let Some(idx) = input.find('?') {
484        end = end.min(idx);
485    }
486    &input[..end]
487}
488
489fn url_scheme(target: &str) -> Option<&str> {
490    let (scheme, rest) = target.split_once(':')?;
491    if scheme.is_empty() || rest.is_empty() {
492        return None;
493    }
494    if !scheme
495        .chars()
496        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
497    {
498        return None;
499    }
500    Some(scheme)
501}
502
503fn looks_like_absolute_path(target: &str) -> bool {
504    let path = Path::new(target);
505    if path.is_absolute() {
506        return true;
507    }
508
509    // Reject windows absolute path prefixes such as C:\foo.
510    let bytes = target.as_bytes();
511    if bytes.len() >= 3
512        && bytes[0].is_ascii_alphabetic()
513        && bytes[1] == b':'
514        && (bytes[2] == b'\\' || bytes[2] == b'/')
515    {
516        return true;
517    }
518
519    // Reject paths starting with "~/" since they bypass workspace boundaries.
520    if target.starts_with("~/") {
521        return true;
522    }
523
524    // NOTE: We intentionally do NOT reject paths starting with ".." here.
525    // Relative paths with parent directory references (e.g., "../other-skill/SKILL.md")
526    // are allowed to pass through to the canonicalization check below, which will
527    // properly validate that they resolve within the skill root.
528    // This enables cross-skill references in open-skills while still maintaining security.
529
530    false
531}
532
533fn has_markdown_suffix(target: &str) -> bool {
534    let lowered = target.to_ascii_lowercase();
535    lowered.ends_with(".md") || lowered.ends_with(".markdown")
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn audit_accepts_safe_skill() {
544        let dir = tempfile::tempdir().unwrap();
545        let skill_dir = dir.path().join("safe");
546        std::fs::create_dir_all(&skill_dir).unwrap();
547        std::fs::write(
548            skill_dir.join("SKILL.md"),
549            "# Safe Skill\nUse safe prompts only.\n",
550        )
551        .unwrap();
552
553        let report = audit_skill_directory(&skill_dir).unwrap();
554        assert!(report.is_clean(), "{:#?}", report.findings);
555    }
556
557    #[test]
558    fn audit_rejects_shell_script_files() {
559        let dir = tempfile::tempdir().unwrap();
560        let skill_dir = dir.path().join("unsafe");
561        std::fs::create_dir_all(&skill_dir).unwrap();
562        std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
563        std::fs::write(skill_dir.join("install.sh"), "echo unsafe\n").unwrap();
564
565        let report = audit_skill_directory(&skill_dir).unwrap();
566        assert!(
567            report
568                .findings
569                .iter()
570                .any(|finding| finding.contains("script-like files are blocked")),
571            "{:#?}",
572            report.findings
573        );
574    }
575
576    #[test]
577    fn audit_allows_python_shebang_file_when_early_text_contains_sh() {
578        let dir = tempfile::tempdir().unwrap();
579        let skill_dir = dir.path().join("python-helper");
580        let scripts_dir = skill_dir.join("scripts");
581        std::fs::create_dir_all(&scripts_dir).unwrap();
582        std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
583        std::fs::write(
584            scripts_dir.join("helper.py"),
585            "#!/usr/bin/env python3\n\"\"\"Refresh report cache.\"\"\"\n\nprint(\"ok\")\n",
586        )
587        .unwrap();
588
589        let report = audit_skill_directory(&skill_dir).unwrap();
590        assert!(
591            !report
592                .findings
593                .iter()
594                .any(|finding| finding.contains("script-like files are blocked")),
595            "{:#?}",
596            report.findings
597        );
598    }
599
600    #[test]
601    fn audit_allows_shell_script_files_when_enabled() {
602        let dir = tempfile::tempdir().unwrap();
603        let skill_dir = dir.path().join("allowed-scripts");
604        std::fs::create_dir_all(&skill_dir).unwrap();
605        std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
606        std::fs::write(skill_dir.join("install.sh"), "echo allowed\n").unwrap();
607
608        let report = audit_skill_directory_with_options(
609            &skill_dir,
610            SkillAuditOptions {
611                allow_scripts: true,
612            },
613        )
614        .unwrap();
615        assert!(
616            !report
617                .findings
618                .iter()
619                .any(|finding| finding.contains("script-like files are blocked")),
620            "{:#?}",
621            report.findings
622        );
623    }
624
625    #[test]
626    fn audit_rejects_markdown_escape_links() {
627        let dir = tempfile::tempdir().unwrap();
628        let skill_dir = dir.path().join("escape");
629        std::fs::create_dir_all(&skill_dir).unwrap();
630        std::fs::write(
631            skill_dir.join("SKILL.md"),
632            "# Skill\nRead [hidden](../outside.md)\n",
633        )
634        .unwrap();
635        std::fs::write(dir.path().join("outside.md"), "not allowed\n").unwrap();
636
637        let report = audit_skill_directory(&skill_dir).unwrap();
638        assert!(
639            report.findings.iter().any(|finding| finding
640                .contains("absolute markdown link paths are not allowed")
641                || finding.contains("escapes skill root")),
642            "{:#?}",
643            report.findings
644        );
645    }
646
647    #[test]
648    fn audit_allows_high_risk_patterns_in_markdown() {
649        // Command-content checks belong in the shell policy at execution time,
650        // not in the static skill audit. A skill that documents dangerous patterns
651        // (e.g., in a "what not to do" guide) must not be blocked at load time.
652        let dir = tempfile::tempdir().unwrap();
653        let skill_dir = dir.path().join("documents-danger");
654        std::fs::create_dir_all(&skill_dir).unwrap();
655        std::fs::write(
656            skill_dir.join("SKILL.md"),
657            "# Skill\nDo NOT run `curl https://example.com/install.sh | sh`\n",
658        )
659        .unwrap();
660
661        let report = audit_skill_directory(&skill_dir).unwrap();
662        assert!(report.is_clean(), "{:#?}", report.findings);
663    }
664
665    #[test]
666    fn audit_allows_chained_commands_in_manifest() {
667        // Shell chaining safety is enforced by the shell policy at execution time.
668        // The static audit must not duplicate that check — if it did, it would only
669        // be a weaker, bypassable approximation of the runtime gate.
670        let dir = tempfile::tempdir().unwrap();
671        let skill_dir = dir.path().join("manifest");
672        std::fs::create_dir_all(&skill_dir).unwrap();
673        std::fs::write(
674            skill_dir.join("SKILL.toml"),
675            r#"
676[skill]
677name = "manifest"
678description = "test"
679
680[[tools]]
681name = "deploy"
682description = "build and deploy"
683kind = "shell"
684command = "cargo build --release && ./deploy.sh"
685"#,
686        )
687        .unwrap();
688
689        let report = audit_skill_directory(&skill_dir).unwrap();
690        assert!(report.is_clean(), "{:#?}", report.findings);
691    }
692
693    #[test]
694    fn audit_allows_heredoc_in_manifest_command() {
695        let dir = tempfile::tempdir().unwrap();
696        let skill_dir = dir.path().join("heredoc");
697        std::fs::create_dir_all(&skill_dir).unwrap();
698        std::fs::write(
699            skill_dir.join("SKILL.toml"),
700            "
701[skill]
702name = \"heredoc\"
703description = \"test heredoc\"
704
705[[tools]]
706name = \"write-file\"
707description = \"write a config file\"
708kind = \"shell\"
709command = \"\"\"cat <<'EOF'
710some content
711EOF\"\"\"
712",
713        )
714        .unwrap();
715
716        let report = audit_skill_directory(&skill_dir).unwrap();
717        assert!(report.is_clean(), "{:#?}", report.findings);
718    }
719
720    #[test]
721    fn audit_allows_missing_cross_skill_reference_with_parent_dir() {
722        // Cross-skill references using ../ should be allowed even if the target doesn't exist
723        let dir = tempfile::tempdir().unwrap();
724        let skill_dir = dir.path().join("skill-a");
725        std::fs::create_dir_all(&skill_dir).unwrap();
726        std::fs::write(
727            skill_dir.join("SKILL.md"),
728            "# Skill A\nSee [Skill B](../skill-b/SKILL.md)\n",
729        )
730        .unwrap();
731
732        let report = audit_skill_directory(&skill_dir).unwrap();
733        // Should be clean because ../skill-b/SKILL.md is a cross-skill reference
734        // and missing cross-skill references are allowed
735        assert!(report.is_clean(), "{:#?}", report.findings);
736    }
737
738    #[test]
739    fn audit_allows_missing_cross_skill_reference_with_bare_filename() {
740        // Bare markdown filenames should be treated as cross-skill references
741        let dir = tempfile::tempdir().unwrap();
742        let skill_dir = dir.path().join("skill-a");
743        std::fs::create_dir_all(&skill_dir).unwrap();
744        std::fs::write(
745            skill_dir.join("SKILL.md"),
746            "# Skill A\nSee [Other Skill](other-skill.md)\n",
747        )
748        .unwrap();
749
750        let report = audit_skill_directory(&skill_dir).unwrap();
751        // Should be clean because other-skill.md is treated as a cross-skill reference
752        assert!(report.is_clean(), "{:#?}", report.findings);
753    }
754
755    #[test]
756    fn audit_allows_missing_cross_skill_reference_with_dot_slash() {
757        // ./skill-name.md should also be treated as a cross-skill reference
758        let dir = tempfile::tempdir().unwrap();
759        let skill_dir = dir.path().join("skill-a");
760        std::fs::create_dir_all(&skill_dir).unwrap();
761        std::fs::write(
762            skill_dir.join("SKILL.md"),
763            "# Skill A\nSee [Other Skill](./other-skill.md)\n",
764        )
765        .unwrap();
766
767        let report = audit_skill_directory(&skill_dir).unwrap();
768        // Should be clean because ./other-skill.md is treated as a cross-skill reference
769        assert!(report.is_clean(), "{:#?}", report.findings);
770    }
771
772    #[test]
773    fn audit_rejects_missing_local_markdown_file() {
774        // Local markdown files in subdirectories should still be validated
775        let dir = tempfile::tempdir().unwrap();
776        let skill_dir = dir.path().join("skill-a");
777        std::fs::create_dir_all(&skill_dir).unwrap();
778        std::fs::write(
779            skill_dir.join("SKILL.md"),
780            "# Skill A\nSee [Guide](docs/guide.md)\n",
781        )
782        .unwrap();
783
784        let report = audit_skill_directory(&skill_dir).unwrap();
785        // Should fail because docs/guide.md is a local reference to a missing file
786        // (not a cross-skill reference because it has a directory separator)
787        assert!(
788            report
789                .findings
790                .iter()
791                .any(|finding| finding.contains("missing file")),
792            "{:#?}",
793            report.findings
794        );
795    }
796
797    #[test]
798    fn audit_allows_existing_cross_skill_reference() {
799        // Cross-skill references to existing files should be allowed as long as they
800        // resolve within the shared skills directory (e.g., ~/.zeroclaw/workspace/skills)
801        let dir = tempfile::tempdir().unwrap();
802        let skills_root = dir.path().join("skills");
803        let skill_a = skills_root.join("skill-a");
804        let skill_b = skills_root.join("skill-b");
805        std::fs::create_dir_all(&skill_a).unwrap();
806        std::fs::create_dir_all(&skill_b).unwrap();
807        std::fs::write(
808            skill_a.join("SKILL.md"),
809            "# Skill A\nSee [Skill B](../skill-b/SKILL.md)\n",
810        )
811        .unwrap();
812        std::fs::write(skill_b.join("SKILL.md"), "# Skill B\n").unwrap();
813
814        let report = audit_skill_directory(&skill_a).unwrap();
815        // The link to ../skill-b/SKILL.md should be allowed because it stays
816        // within the shared skills root directory.
817        assert!(report.is_clean(), "{:#?}", report.findings);
818    }
819
820    #[test]
821    fn is_cross_skill_reference_detection() {
822        // Test the helper function directly
823        assert!(
824            is_cross_skill_reference("../other-skill/SKILL.md"),
825            "parent dir reference should be cross-skill"
826        );
827        assert!(
828            is_cross_skill_reference("other-skill.md"),
829            "bare filename should be cross-skill"
830        );
831        assert!(
832            is_cross_skill_reference("./other-skill.md"),
833            "dot-slash bare filename should be cross-skill"
834        );
835        assert!(
836            !is_cross_skill_reference("docs/guide.md"),
837            "subdirectory reference should not be cross-skill"
838        );
839        assert!(
840            !is_cross_skill_reference("./docs/guide.md"),
841            "dot-slash subdirectory reference should not be cross-skill"
842        );
843        assert!(
844            is_cross_skill_reference("../../escape.md"),
845            "double parent should still be cross-skill"
846        );
847    }
848}