use super::{ToolRuntime, args::FILE_READ_MAX_BYTES, contract::metadata_key as meta};
use serde_json::{Map, Value, json};
use std::{
fs,
path::{Component, Path, PathBuf},
};
#[derive(Debug)]
pub(super) struct LoadedSkillFile {
pub(super) kind: &'static str,
pub(super) reference: Option<String>,
pub(super) content: String,
pub(super) metadata: Map<String, Value>,
}
impl ToolRuntime {
pub(super) fn load_skill_markdown(&self, name: &str) -> anyhow::Result<LoadedSkillFile> {
let skill = self.skills.get(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown skill '{}'; use one of the skills listed in the system prompt",
name
)
})?;
let skill_dir = skill
.path
.parent()
.ok_or_else(|| anyhow::anyhow!("selected skill has no directory"))?;
let canonical_skill_dir = canonical_skill_dir(skill_dir)?;
if skill.path.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") {
anyhow::bail!("selected skill file must be named SKILL.md");
}
load_skill_file(
name,
"skill",
None,
&canonical_skill_dir,
skill.path.clone(),
)
}
pub(super) fn load_skill_reference(
&self,
name: &str,
reference: &str,
) -> anyhow::Result<LoadedSkillFile> {
let skill = self.skills.get(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown skill '{}'; use one of the skills listed in the system prompt",
name
)
})?;
let skill_dir = skill
.path
.parent()
.ok_or_else(|| anyhow::anyhow!("selected skill has no directory"))?;
let canonical_skill_dir = canonical_skill_dir(skill_dir)?;
load_skill_file(
name,
"reference",
Some(reference),
&canonical_skill_dir,
resolve_reference_path(skill_dir, reference)?,
)
}
}
fn canonical_skill_dir(skill_dir: &Path) -> anyhow::Result<PathBuf> {
let metadata = fs::symlink_metadata(skill_dir)?;
if metadata.file_type().is_symlink() {
anyhow::bail!("selected skill directory must not be a symlink");
}
if !metadata.is_dir() {
anyhow::bail!("selected skill directory is not a directory");
}
Ok(skill_dir.canonicalize()?)
}
fn resolve_reference_path(skill_dir: &Path, reference: &str) -> anyhow::Result<PathBuf> {
if reference.trim().is_empty() {
anyhow::bail!("reference path must not be empty");
}
let reference_path = PathBuf::from(reference);
if reference_path.is_absolute() {
anyhow::bail!("reference path must be relative to the selected skill directory");
}
for component in reference_path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir => anyhow::bail!("reference path must not contain '..'"),
Component::RootDir => anyhow::bail!("reference path must not contain a root component"),
Component::Prefix(_) => anyhow::bail!("reference path must not contain a path prefix"),
}
}
Ok(skill_dir.join(reference_path))
}
fn load_skill_file(
skill_name: &str,
kind: &'static str,
reference: Option<&str>,
canonical_skill_dir: &Path,
candidate: PathBuf,
) -> anyhow::Result<LoadedSkillFile> {
let canonical_candidate = match candidate.canonicalize() {
Ok(path) => path,
Err(error) if reference.is_some() && error.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!(
"reference '{}' was not found in the selected skill directory",
reference.unwrap()
)
}
Err(error) => return Err(error.into()),
};
if !canonical_candidate.starts_with(canonical_skill_dir) {
anyhow::bail!("{kind} path escapes the selected skill directory");
}
let metadata = match fs::symlink_metadata(&candidate) {
Ok(metadata) => metadata,
Err(error) if reference.is_some() && error.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!(
"reference '{}' was not found in the selected skill directory",
reference.unwrap()
)
}
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() {
anyhow::bail!("{kind} target must not be a symlink");
}
if metadata.is_dir() {
anyhow::bail!("{kind} target must be a regular file, not a directory");
}
if !metadata.is_file() {
anyhow::bail!("{kind} target must be a regular file");
}
if metadata.len() > FILE_READ_MAX_BYTES {
anyhow::bail!(
"{kind} target is {} bytes; skill read limit is {FILE_READ_MAX_BYTES} bytes",
metadata.len()
);
}
let content = fs::read_to_string(&canonical_candidate)
.map_err(|error| anyhow::anyhow!("{kind} target must be UTF-8 text: {error}"))?;
let bytes = content.len();
let reference = reference.map(str::to_string);
let mut metadata = Map::new();
metadata.insert(meta::SKILL.to_string(), json!(skill_name));
metadata.insert(meta::KIND.to_string(), json!(kind));
metadata.insert(meta::REFERENCE.to_string(), json!(reference.clone()));
metadata.insert(meta::BYTES.to_string(), json!(bytes));
metadata.insert(meta::BYTE_LIMIT.to_string(), json!(FILE_READ_MAX_BYTES));
Ok(LoadedSkillFile {
kind,
reference,
content,
metadata,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
skills::{SkillDiscovery, load_skill},
tools::ToolRuntime,
};
use std::collections::{BTreeMap, BTreeSet};
use tempfile::TempDir;
fn runtime_with_skill(temp: &TempDir, name: &str, content: &str) -> ToolRuntime {
let skill_dir = temp.path().join("skills").join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
let skill = load_skill(&skill_dir.join("SKILL.md")).unwrap();
let mut skills = BTreeMap::new();
skills.insert(name.to_string(), skill);
ToolRuntime::new(temp.path())
.unwrap()
.with_skills(&SkillDiscovery {
skills,
diagnostics: Vec::new(),
})
}
#[test]
fn skill_helper_loads_markdown_and_reference() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "# Skill\n");
let reference = temp.path().join("skills/test/references/example.md");
fs::create_dir_all(reference.parent().unwrap()).unwrap();
fs::write(&reference, "reference body").unwrap();
let skill = runtime.load_skill_markdown("test").unwrap();
assert_eq!(skill.content, "# Skill\n");
assert_eq!(skill.kind, "skill");
assert_eq!(skill.metadata["skill"], "test");
let reference = runtime
.load_skill_reference("test", "references/example.md")
.unwrap();
assert_eq!(reference.content, "reference body");
assert_eq!(reference.kind, "reference");
assert_eq!(
reference.reference.as_deref(),
Some("references/example.md")
);
}
#[test]
fn skill_unknown_name_rejects_without_path_fallback() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("unknown")).unwrap();
fs::write(temp.path().join("unknown/SKILL.md"), "secret").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let error = runtime
.load_skill_markdown("unknown")
.unwrap_err()
.to_string();
assert!(error.contains("unknown skill 'unknown'"));
assert!(!error.contains("secret"));
}
#[test]
fn skill_runtime_receives_only_filtered_enabled_skills() {
let temp = TempDir::new().unwrap();
let mut discovered = SkillDiscovery::default();
for name in ["enabled", "disabled"] {
let skill_dir = temp.path().join("skills").join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), format!("# {name}\n")).unwrap();
discovered.skills.insert(
name.to_string(),
load_skill(&skill_dir.join("SKILL.md")).unwrap(),
);
}
let enabled = crate::skills::filter_enabled_skills(
&discovered,
&BTreeSet::from(["disabled".to_string()]),
);
let runtime = ToolRuntime::new(temp.path()).unwrap().with_skills(&enabled);
assert!(runtime.load_skill_markdown("enabled").is_ok());
let error = runtime
.load_skill_markdown("disabled")
.unwrap_err()
.to_string();
assert!(error.contains("unknown skill 'disabled'"));
}
#[test]
fn skill_helper_rejects_missing_traversal_absolute_and_directory_references() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "# Skill\n");
fs::create_dir_all(temp.path().join("skills/test/dir")).unwrap();
fs::create_dir_all(temp.path().join("skills/other")).unwrap();
fs::write(temp.path().join("skills/other/SKILL.md"), "other").unwrap();
for (reference, expected) in [
("missing.md", "was not found"),
("../other/SKILL.md", "must not contain '..'"),
(
temp.path().join("outside.txt").to_str().unwrap(),
"must be relative",
),
("/rooted", "must be relative"),
("dir", "not a directory"),
] {
let error = runtime
.load_skill_reference("test", reference)
.unwrap_err()
.to_string();
assert!(
error.contains(expected),
"expected {expected:?} for {reference:?}, got {error}"
);
}
}
#[test]
fn skill_helper_rejects_oversized_and_non_utf8_targets() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "# Skill\n");
let skill_dir = temp.path().join("skills/test");
fs::write(
skill_dir.join("big.txt"),
vec![b'x'; FILE_READ_MAX_BYTES as usize + 1],
)
.unwrap();
let error = runtime
.load_skill_reference("test", "big.txt")
.unwrap_err()
.to_string();
assert!(error.contains("skill read limit"));
fs::write(skill_dir.join("bad.bin"), [0xff]).unwrap();
let error = runtime
.load_skill_reference("test", "bad.bin")
.unwrap_err()
.to_string();
assert!(error.contains("UTF-8"));
}
#[cfg(unix)]
#[test]
fn skill_helper_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("outside.md"), "outside").unwrap();
let runtime = runtime_with_skill(&temp, "test", "# Skill\n");
let skill_dir = temp.path().join("skills/test");
symlink(outside.path().join("outside.md"), skill_dir.join("link.md")).unwrap();
let error = runtime
.load_skill_reference("test", "link.md")
.unwrap_err()
.to_string();
assert!(error.contains("escapes the selected skill directory"));
}
#[test]
fn cloned_runtime_preserves_skills_and_debug_redacts_records() {
let temp = TempDir::new().unwrap();
let content = "# Secret Skill Path Test\n";
let runtime = runtime_with_skill(&temp, "test", content);
let cloned = runtime
.clone_for_cwd_with_subagent_depth(temp.path(), 1)
.unwrap();
let result = cloned.load_skill_markdown("test").unwrap();
assert_eq!(result.content, content);
let debug = format!("{runtime:?}");
assert!(debug.contains("skills_count"));
assert!(!debug.contains("Secret Skill Path Test"));
assert!(!debug.contains("SKILL.md"));
}
}