use super::{
ToolRuntime, args::FILE_READ_MAX_BYTES, contract::metadata_key as meta,
skill_provenance::digest_hex,
};
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,
&digest_hex(skill.path.as_os_str().as_encoded_bytes()),
)
}
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)?,
&digest_hex(skill.path.as_os_str().as_encoded_bytes()),
)
}
}
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,
descriptor_path_sha256: &str,
) -> 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,
descriptor_path_sha256,
))
}
fn skill_reference_io_error(error: std::io::Error, reference: Option<&str>) -> anyhow::Error {
match reference {
Some(reference) if error.kind() == std::io::ErrorKind::NotFound => {
anyhow::anyhow!("reference '{reference}' was not found in the selected skill directory")
}
_ => error.into(),
}
}
fn load_skill_file(
skill_name: &str,
kind: &'static str,
reference: Option<&str>,
canonical_skill_dir: &Path,
candidate: PathBuf,
descriptor_path_sha256: &str,
) -> anyhow::Result<LoadedSkillFile> {
let canonical_candidate = candidate
.canonicalize()
.map_err(|error| skill_reference_io_error(error, reference))?;
if !canonical_candidate.starts_with(canonical_skill_dir) {
anyhow::bail!("{kind} path escapes the selected skill directory");
}
let metadata = fs::symlink_metadata(&candidate)
.map_err(|error| skill_reference_io_error(error, reference))?;
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,
descriptor_path_sha256,
))
}
fn loaded_skill_file(
skill_name: &str,
kind: &'static str,
reference: Option<&str>,
content: String,
descriptor_path_sha256: &str,
) -> 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));
metadata.insert(
"_skill_provenance".to_string(),
json!({
"access_policy_version": 1,
"descriptor_path_sha256": descriptor_path_sha256,
"snapshot_sha256": digest_hex(content.as_bytes()),
"skill": skill_name,
"reference": reference,
}),
);
LoadedSkillFile {
kind,
reference,
content,
metadata,
}
}