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(¤t).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 if let Some(skills_root) = skills_root_for(root)
300 && canonical_target.starts_with(&skills_root)
301 {
302 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 if is_cross_skill_reference(stripped) {
329 return;
331 }
332 report.findings.push(format!(
333 "{rel}: markdown link points to a missing file ({normalized})."
334 ));
335 }
336 }
337}
338
339fn is_cross_skill_reference(target: &str) -> bool {
345 let path = Path::new(target);
346
347 if path
349 .components()
350 .any(|component| component == Component::ParentDir)
351 {
352 return true;
353 }
354
355 let stripped = target.strip_prefix("./").unwrap_or(target);
359
360 !stripped.contains('/') && !stripped.contains('\\') && has_markdown_suffix(stripped)
363}
364
365fn 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 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 if target.starts_with("~/") {
521 return true;
522 }
523
524 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 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 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 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 assert!(report.is_clean(), "{:#?}", report.findings);
736 }
737
738 #[test]
739 fn audit_allows_missing_cross_skill_reference_with_bare_filename() {
740 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 assert!(report.is_clean(), "{:#?}", report.findings);
753 }
754
755 #[test]
756 fn audit_allows_missing_cross_skill_reference_with_dot_slash() {
757 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 assert!(report.is_clean(), "{:#?}", report.findings);
770 }
771
772 #[test]
773 fn audit_rejects_missing_local_markdown_file() {
774 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 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 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 assert!(report.is_clean(), "{:#?}", report.findings);
818 }
819
820 #[test]
821 fn is_cross_skill_reference_detection() {
822 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}