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);
+ }
}