diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 1da432759..f228c7efb 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -99,23 +99,35 @@ fn parse_frontmatter(content: &str) -> Option<(String, String)> { Some((name, description)) } +/// Escape XML special characters in user-controlled strings to preserve prompt structure. +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + /// Format skills as a system prompt section listing available skills. pub fn format_skills_prompt(skills: &[Skill]) -> String { if skills.is_empty() { return String::new(); } + + // Sort skills by name to ensure deterministic output + let mut sorted_skills = skills.to_vec(); + sorted_skills.sort_by(|a, b| a.name.cmp(&b.name)); + let mut out = String::from(concat!( "\n\n## Skills\n\n", "Before replying, scan the skills below. If one matches or may be relevant to the user's task, ", "use the `read` tool to load the full SKILL.md at the listed location and follow its instructions.\n\n", "\n", )); - for skill in skills { + for skill in sorted_skills { out.push_str(&format!( " \n {}\n {}\n {}\n \n", - skill.name, - skill.description, - skill.path.join("SKILL.md").display(), + xml_escape(&skill.name), + xml_escape(&skill.description), + xml_escape(&skill.path.join("SKILL.md").to_string_lossy()), )); } out.push_str("\n"); @@ -230,4 +242,37 @@ mod tests { assert!(prompt.contains("")); assert!(prompt.contains("Before replying, scan the skills below")); } + + #[test] + fn format_skills_prompt_escapes_xml_chars() { + let skills = vec![Skill { + name: "a & \"quotes\" injection test".to_string(), + path: PathBuf::from("/skills/test&cool"), + }]; + let prompt = format_skills_prompt(&skills); + assert!(prompt.contains("a<b")); + assert!(prompt.contains("Use <tag> & \"quotes\" </description> injection test")); + assert!(prompt.contains("/skills/test&cool/SKILL.md")); + } + + #[test] + fn format_skills_prompt_is_deterministic() { + let skills = vec![ + Skill { + name: "zoo".to_string(), + description: "Zoo skill".to_string(), + path: PathBuf::from("/skills/zoo"), + }, + Skill { + name: "apple".to_string(), + description: "Apple skill".to_string(), + path: PathBuf::from("/skills/apple"), + }, + ]; + let prompt = format_skills_prompt(&skills); + let apple_idx = prompt.find("apple").unwrap(); + let zoo_idx = prompt.find("zoo").unwrap(); + assert!(apple_idx < zoo_idx); + } }