Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- `requires-secrets` SKILL.md frontmatter field renamed to `x-requires-secrets` to follow JSON Schema vendor extension convention and avoid future spec collisions — **breaking change**: update skill frontmatter to use `x-requires-secrets`; the old `requires-secrets` form is still parsed with a deprecation warning (#688)
- `allowed-tools` SKILL.md field now uses space-separated values per agentskills.io spec (was comma-separated) — **breaking change** for skills using comma-delimited allowed-tools (#686)
- Skill resource files (references, scripts, assets) are no longer eagerly injected into the system prompt on skill activation; only filenames are listed as available resources — **breaking change** for skills relying on auto-injected reference content (#687)

### Added
- `load_skill_resource(skill_dir, relative_path)` public function in `zeph-skills::resource` for on-demand loading of skill resource files with path traversal protection (#687)
- Nested `metadata:` block support in SKILL.md frontmatter: indented key-value pairs under `metadata:` are parsed as structured metadata (#686)
- Field length validation in SKILL.md loader: `description` capped at 1024 characters, `compatibility` capped at 500 characters (#686)
- Warning log in `load_skill_body()` when body exceeds 20,000 bytes (~5000 tokens) per spec recommendation (#686)
Expand Down
2 changes: 1 addition & 1 deletion crates/zeph-core/src/agent/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,7 +795,7 @@ impl<C: Channel> Agent<C> {
.collect();

let trust_map = self.build_skill_trust_map().await;
let skills_prompt = format_skills_prompt(&active_skills, std::env::consts::OS, &trust_map);
let skills_prompt = format_skills_prompt(&active_skills, &trust_map);
let catalog_prompt = format_skills_catalog(&remaining_skills);
self.skill_state
.last_skills_prompt
Expand Down
4 changes: 2 additions & 2 deletions crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ impl<C: Channel> Agent<C> {
.filter_map(|m| registry.get_skill(&m.name).ok())
.collect();
let empty_trust = HashMap::new();
let skills_prompt = format_skills_prompt(&all_skills, std::env::consts::OS, &empty_trust);
let skills_prompt = format_skills_prompt(&all_skills, &empty_trust);
let system_prompt = build_system_prompt(&skills_prompt, None, None, false);
tracing::debug!(len = system_prompt.len(), "initial system prompt built");
tracing::trace!(prompt = %system_prompt, "full system prompt");
Expand Down Expand Up @@ -702,7 +702,7 @@ impl<C: Channel> Agent<C> {
.filter_map(|m| self.skill_state.registry.get_skill(&m.name).ok())
.collect();
let trust_map = self.build_skill_trust_map().await;
let skills_prompt = format_skills_prompt(&all_skills, std::env::consts::OS, &trust_map);
let skills_prompt = format_skills_prompt(&all_skills, &trust_map);
self.skill_state
.last_skills_prompt
.clone_from(&skills_prompt);
Expand Down
2 changes: 1 addition & 1 deletion crates/zeph-skills/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub mod matcher;
pub mod prompt;
pub mod qdrant_matcher;
pub mod registry;
pub(crate) mod resource;
pub mod resource;
pub mod trust;
pub mod watcher;

Expand Down
149 changes: 82 additions & 67 deletions crates/zeph-skills/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ use crate::loader::Skill;
use crate::resource::discover_resources;
use crate::trust::TrustLevel;

const OS_NAMES: &[&str] = &["linux", "macos", "windows"];

// XML tag patterns (lowercase) that could break prompt structure if injected verbatim.
// Matching is case-insensitive; the replacement is always the canonical escaped form.
const SANITIZE_PATTERNS: &[(&str, &str)] = &[
Expand Down Expand Up @@ -50,19 +48,9 @@ pub fn sanitize_skill_body(body: &str) -> String {
out
}

fn should_include_reference(filename: &str, os_family: &str) -> bool {
let stem = filename.strip_suffix(".md").unwrap_or(filename);
if OS_NAMES.contains(&stem) {
stem == os_family
} else {
true
}
}

#[must_use]
pub fn format_skills_prompt<S: std::hash::BuildHasher>(
skills: &[Skill],
os_family: &str,
trust_levels: &HashMap<String, TrustLevel, S>,
) -> String {
if skills.is_empty() {
Expand Down Expand Up @@ -95,19 +83,32 @@ pub fn format_skills_prompt<S: std::hash::BuildHasher>(
);

let resources = discover_resources(&skill.meta.skill_dir);
for ref_path in &resources.references {
let Some(filename) = ref_path.file_name().and_then(|f| f.to_str()) else {
continue;
};
if !should_include_reference(filename, os_family) {
continue;
}
if let Ok(content) = std::fs::read_to_string(ref_path) {
let _ = write!(
out,
"\n<reference name=\"{filename}\">\n{content}\n</reference>",
);
}

let ref_names: Vec<&str> = resources
.references
.iter()
.filter_map(|p| p.file_name()?.to_str())
.collect();
if !ref_names.is_empty() {
let _ = write!(out, "\nAvailable references: {}", ref_names.join(", "));
}

let script_names: Vec<&str> = resources
.scripts
.iter()
.filter_map(|p| p.file_name()?.to_str())
.collect();
if !script_names.is_empty() {
let _ = write!(out, "\nAvailable scripts: {}", script_names.join(", "));
}

let asset_names: Vec<&str> = resources
.assets
.iter()
.filter_map(|p| p.file_name()?.to_str())
.collect();
if !asset_names.is_empty() {
let _ = write!(out, "\nAvailable assets: {}", asset_names.join(", "));
}

out.push_str("\n </instructions>\n </skill>\n");
Expand Down Expand Up @@ -187,14 +188,14 @@ mod tests {
#[test]
fn empty_skills_returns_empty_string() {
let empty: &[Skill] = &[];
assert_eq!(format_skills_prompt(empty, "linux", &HashMap::new()), "");
assert_eq!(format_skills_prompt(empty, &HashMap::new()), "");
}

#[test]
fn single_skill_format() {
let skills = vec![make_skill("test", "A test.", "# Hello\nworld")];

let output = format_skills_prompt(&skills, "linux", &HashMap::new());
let output = format_skills_prompt(&skills, &HashMap::new());
assert!(output.starts_with("<available_skills>"));
assert!(output.ends_with("</available_skills>"));
assert!(output.contains("<skill name=\"test\">"));
Expand All @@ -209,39 +210,62 @@ mod tests {
make_skill("b", "desc b", "body b"),
];

let output = format_skills_prompt(&skills, "linux", &HashMap::new());
let output = format_skills_prompt(&skills, &HashMap::new());
assert!(output.contains("<skill name=\"a\">"));
assert!(output.contains("<skill name=\"b\">"));
}

#[test]
fn should_include_os_matching_reference() {
assert!(should_include_reference("linux.md", "linux"));
assert!(!should_include_reference("linux.md", "macos"));
assert!(!should_include_reference("linux.md", "windows"));
fn references_listed_not_injected() {
let dir = tempfile::tempdir().unwrap();
let refs = dir.path().join("references");
std::fs::create_dir(&refs).unwrap();
std::fs::write(refs.join("api-guide.md"), "# API Guide content").unwrap();
std::fs::write(refs.join("common.md"), "# Common docs content").unwrap();

assert!(should_include_reference("macos.md", "macos"));
assert!(!should_include_reference("macos.md", "linux"));
let skills = vec![make_skill_with_dir(
"test",
"desc",
"body",
dir.path().to_path_buf(),
)];

assert!(should_include_reference("windows.md", "windows"));
assert!(!should_include_reference("windows.md", "linux"));
let output = format_skills_prompt(&skills, &HashMap::new());
// filenames listed
assert!(output.contains("Available references:"));
assert!(output.contains("api-guide.md"));
assert!(output.contains("common.md"));
// content NOT injected
assert!(!output.contains("# API Guide content"));
assert!(!output.contains("# Common docs content"));
assert!(!output.contains("<reference"));
}

#[test]
fn should_include_generic_reference() {
assert!(should_include_reference("api.md", "linux"));
assert!(should_include_reference("api.md", "macos"));
assert!(should_include_reference("usage.md", "windows"));
fn scripts_listed_not_injected() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
std::fs::create_dir(&scripts).unwrap();
std::fs::write(scripts.join("extract.py"), "print('hi')").unwrap();

let skills = vec![make_skill_with_dir(
"test",
"desc",
"body",
dir.path().to_path_buf(),
)];

let output = format_skills_prompt(&skills, &HashMap::new());
assert!(output.contains("Available scripts: extract.py"));
assert!(!output.contains("print('hi')"));
}

#[test]
fn references_injected_for_matching_os() {
fn assets_listed_not_injected() {
let dir = tempfile::tempdir().unwrap();
let refs = dir.path().join("references");
std::fs::create_dir(&refs).unwrap();
std::fs::write(refs.join("linux.md"), "# Linux commands").unwrap();
std::fs::write(refs.join("macos.md"), "# macOS commands").unwrap();
std::fs::write(refs.join("common.md"), "# Common docs").unwrap();
let assets = dir.path().join("assets");
std::fs::create_dir(&assets).unwrap();
std::fs::write(assets.join("logo.png"), &[0u8; 4]).unwrap();

let skills = vec![make_skill_with_dir(
"test",
Expand All @@ -250,16 +274,12 @@ mod tests {
dir.path().to_path_buf(),
)];

let output = format_skills_prompt(&skills, "linux", &HashMap::new());
assert!(output.contains("# Linux commands"));
assert!(!output.contains("# macOS commands"));
assert!(output.contains("# Common docs"));
assert!(output.contains("<reference name=\"linux.md\">"));
assert!(output.contains("<reference name=\"common.md\">"));
let output = format_skills_prompt(&skills, &HashMap::new());
assert!(output.contains("Available assets: logo.png"));
}

#[test]
fn no_references_dir_produces_body_only() {
fn no_resources_dir_produces_body_only() {
let dir = tempfile::tempdir().unwrap();
let skills = vec![make_skill_with_dir(
"test",
Expand All @@ -268,17 +288,19 @@ mod tests {
dir.path().to_path_buf(),
)];

let output = format_skills_prompt(&skills, "macos", &HashMap::new());
let output = format_skills_prompt(&skills, &HashMap::new());
assert!(output.contains("skill body"));
assert!(!output.contains("<reference"));
assert!(!output.contains("Available references"));
assert!(!output.contains("Available scripts"));
assert!(!output.contains("Available assets"));
}

#[test]
fn quarantined_skill_gets_wrapped() {
let skills = vec![make_skill("untrusted", "desc", "do stuff")];
let mut trust = HashMap::new();
trust.insert("untrusted".into(), TrustLevel::Quarantined);
let output = format_skills_prompt(&skills, "linux", &trust);
let output = format_skills_prompt(&skills, &trust);
assert!(output.contains("[QUARANTINED SKILL: untrusted]"));
assert!(output.contains("restricted tool access"));
}
Expand All @@ -288,14 +310,13 @@ mod tests {
let skills = vec![make_skill("safe", "desc", "do stuff")];
let mut trust = HashMap::new();
trust.insert("safe".into(), TrustLevel::Trusted);
let output = format_skills_prompt(&skills, "linux", &trust);
let output = format_skills_prompt(&skills, &trust);
assert!(!output.contains("QUARANTINED"));
assert!(output.contains("do stuff"));
}

#[test]
fn sanitize_case_insensitive() {
// Mixed-case variants must be escaped
let body = "Close </Skill> and </INSTRUCTIONS> and </Available_Skills>.";
let sanitized = sanitize_skill_body(body);
assert!(!sanitized.contains("</Skill>"));
Expand Down Expand Up @@ -332,10 +353,7 @@ mod tests {
let skills = vec![make_skill("safe", "desc", body)];
let mut trust = HashMap::new();
trust.insert("safe".into(), TrustLevel::Trusted);
let output = format_skills_prompt(&skills, "linux", &trust);
// Trusted skills are injected verbatim
assert!(output.contains("</skill>") || output.contains("&lt;/skill&gt;"));
// Specifically, it must NOT have been sanitized (verbatim pass-through)
let output = format_skills_prompt(&skills, &trust);
assert!(output.contains("Some </skill> content."));
}

Expand All @@ -345,8 +363,7 @@ mod tests {
let skills = vec![make_skill("ver", "desc", body)];
let mut trust = HashMap::new();
trust.insert("ver".into(), TrustLevel::Verified);
let output = format_skills_prompt(&skills, "linux", &trust);
// Escaped tag must appear; raw body text must not appear verbatim
let output = format_skills_prompt(&skills, &trust);
assert!(output.contains("&lt;/skill&gt;"));
assert!(!output.contains("Inject </skill> here."));
}
Expand All @@ -357,12 +374,10 @@ mod tests {
let skills = vec![make_skill("evil", "desc", body)];
let mut trust = HashMap::new();
trust.insert("evil".into(), TrustLevel::Quarantined);
let output = format_skills_prompt(&skills, "linux", &trust);
let output = format_skills_prompt(&skills, &trust);
assert!(output.contains("[QUARANTINED SKILL: evil]"));
// The injected XML tags must be escaped; the structural ones remain
assert!(output.contains("&lt;/instructions&gt;"));
assert!(output.contains("&lt;/skill&gt;"));
// Raw injected body should not appear verbatim
assert!(!output.contains("Inject </instructions>"));
}

Expand Down
23 changes: 11 additions & 12 deletions crates/zeph-skills/src/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,15 @@ pub(crate) fn discover_resources(skill_dir: &Path) -> SkillResources {
resources
}

/// Load a resource file content with path traversal protection.
/// Load a skill resource file as a UTF-8 string with path traversal protection.
///
/// # Errors
///
/// Returns an error if the path escapes the skill directory or the file cannot be read.
#[cfg(test)]
fn load_resource(
pub fn load_skill_resource(
skill_dir: &Path,
relative_path: &str,
) -> Result<Vec<u8>, crate::error::SkillError> {
) -> Result<String, crate::error::SkillError> {
use crate::error::SkillError;
let canonical_base = skill_dir.canonicalize().map_err(|e| {
SkillError::Other(format!(
Expand All @@ -66,7 +65,7 @@ fn load_resource(
)));
}

std::fs::read(&canonical_target).map_err(|e| {
std::fs::read_to_string(&canonical_target).map_err(|e| {
SkillError::Other(format!(
"failed to read resource {}: {e}",
canonical_target.display()
Expand Down Expand Up @@ -109,30 +108,30 @@ mod tests {
}

#[test]
fn load_resource_valid() {
fn load_skill_resource_valid() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
std::fs::create_dir(&scripts).unwrap();
std::fs::write(scripts.join("run.sh"), "echo hello").unwrap();

let content = load_resource(dir.path(), "scripts/run.sh").unwrap();
assert_eq!(content, b"echo hello");
let content = load_skill_resource(dir.path(), "scripts/run.sh").unwrap();
assert_eq!(content, "echo hello");
}

#[test]
fn load_resource_path_traversal() {
fn load_skill_resource_path_traversal() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
std::fs::write(dir.path().join("scripts/ok.sh"), "ok").unwrap();

let err = load_resource(dir.path(), "../../../etc/passwd").unwrap_err();
let err = load_skill_resource(dir.path(), "../../../etc/passwd").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("path traversal") || msg.contains("canonicalize"));
}

#[test]
fn load_resource_not_found() {
fn load_skill_resource_not_found() {
let dir = tempfile::tempdir().unwrap();
assert!(load_resource(dir.path(), "nonexistent.txt").is_err());
assert!(load_skill_resource(dir.path(), "nonexistent.txt").is_err());
}
}
Loading