use super::{ToolRuntime, args::FILE_READ_MAX_BYTES, contract::metadata_key as meta};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
use cap_std::{
ambient_authority,
fs::{Dir as CapDir, OpenOptions as CapOpenOptions},
};
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 '{name}'; use one of the skills listed in the system prompt"
)
})?;
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_primary_skill_file(name, &skill.path)
}
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 '{name}'; use one of the skills listed in the system prompt"
)
})?;
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_primary_skill_file(skill_name: &str, candidate: &Path) -> anyhow::Result<LoadedSkillFile> {
let skill_dir = candidate
.parent()
.ok_or_else(|| anyhow::anyhow!("selected skill has no directory"))?;
let skill_parent = skill_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("selected skill directory has no parent"))?;
let skill_dir_name = skill_dir
.file_name()
.ok_or_else(|| anyhow::anyhow!("selected skill directory has no name"))?;
let parent = CapDir::open_ambient_dir(skill_parent, ambient_authority())?;
let skill_dir = parent.open_dir_nofollow(skill_dir_name).map_err(|error| {
anyhow::anyhow!(
"selected skill directory could not be opened without following symlinks: {error}"
)
})?;
let mut options = CapOpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
let file = skill_dir.open_with("SKILL.md", &options).map_err(|error| {
anyhow::anyhow!("skill target could not be opened without following symlinks: {error}")
})?;
if !file.metadata()?.is_file() {
anyhow::bail!("skill target must be a regular file");
}
let file = crate::prompt_file::read_prompt_text(file, candidate, FILE_READ_MAX_BYTES).map_err(
|error| {
let message = error.to_string();
if message.contains("exceeds") {
anyhow::anyhow!(
"skill target exceeds the skill read limit of {FILE_READ_MAX_BYTES} bytes"
)
} else if message.contains("UTF-8") {
anyhow::anyhow!("skill target must be UTF-8 text")
} else {
error
}
},
)?;
Ok(loaded_skill_file(skill_name, "skill", None, file.text))
}
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}"))?;
Ok(loaded_skill_file(skill_name, kind, reference, content))
}
fn loaded_skill_file(
skill_name: &str,
kind: &'static str,
reference: Option<&str>,
content: String,
) -> LoadedSkillFile {
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));
LoadedSkillFile {
kind,
reference,
content,
metadata,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
skills::{SkillDiscovery, load_skill_descriptor},
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_descriptor(&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_body_is_loaded_lazily_from_current_file() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "original body");
let path = temp.path().join("skills/test/SKILL.md");
fs::write(&path, "updated body").unwrap();
assert_eq!(
runtime.load_skill_markdown("test").unwrap().content,
"updated body"
);
fs::remove_file(path).unwrap();
assert!(runtime.load_skill_markdown("test").is_err());
}
#[test]
fn on_demand_skill_read_enforces_exact_byte_limit_and_utf8() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "seed");
let path = temp.path().join("skills/test/SKILL.md");
fs::write(&path, vec![b'x'; FILE_READ_MAX_BYTES as usize]).unwrap();
let exact = runtime.load_skill_markdown("test").unwrap();
assert_eq!(exact.content.len(), FILE_READ_MAX_BYTES as usize);
fs::write(&path, vec![b'x'; FILE_READ_MAX_BYTES as usize + 1]).unwrap();
let error = runtime.load_skill_markdown("test").unwrap_err().to_string();
assert!(error.contains("skill read limit"), "{error}");
fs::write(&path, [0xff]).unwrap();
let error = runtime.load_skill_markdown("test").unwrap_err().to_string();
assert!(error.contains("UTF-8"), "{error}");
}
#[test]
fn on_demand_skill_read_rejects_directory_replacement() {
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "seed");
let path = temp.path().join("skills/test/SKILL.md");
fs::remove_file(&path).unwrap();
fs::create_dir(&path).unwrap();
let error = runtime.load_skill_markdown("test").unwrap_err().to_string();
assert!(error.contains("regular file"), "{error}");
}
#[cfg(unix)]
#[test]
fn on_demand_skill_read_rejects_symlink_replacement() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "seed");
let path = temp.path().join("skills/test/SKILL.md");
let replacement = temp.path().join("replacement.md");
fs::write(&replacement, "replacement").unwrap();
fs::remove_file(&path).unwrap();
symlink(&replacement, &path).unwrap();
let error = runtime.load_skill_markdown("test").unwrap_err().to_string();
assert!(error.contains("symlink"), "{error}");
}
#[cfg(unix)]
#[test]
fn on_demand_skill_read_rejects_skill_directory_symlink_replacement() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let runtime = runtime_with_skill(&temp, "test", "seed");
let skill_dir = temp.path().join("skills/test");
let moved_dir = temp.path().join("skills/test-original");
fs::rename(&skill_dir, &moved_dir).unwrap();
symlink(&moved_dir, &skill_dir).unwrap();
let error = runtime.load_skill_markdown("test").unwrap_err().to_string();
assert!(error.contains("symlink"), "{error}");
}
#[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_descriptor(&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"));
}
}