From b6ef3492076de676dd83de74a129043be248d772 Mon Sep 17 00:00:00 2001 From: shaun-agent Date: Mon, 1 Jun 2026 08:33:49 +0000 Subject: [PATCH 1/4] fix(openab-agent): escape XML special chars in skills prompt Skill names and descriptions come from user-controlled SKILL.md frontmatter. Characters like <, >, & would break the XML structure and confuse LLM parsing of skill boundaries. Add xml_escape() helper applied to name and description fields. Path is left unescaped (filesystem paths don't contain XML specials in practice). --- openab-agent/src/skills.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 1da432759..f02ac200f 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -99,6 +99,11 @@ 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() { @@ -113,8 +118,8 @@ pub fn format_skills_prompt(skills: &[Skill]) -> String { for skill in skills { out.push_str(&format!( " \n {}\n {}\n {}\n \n", - skill.name, - skill.description, + xml_escape(&skill.name), + xml_escape(&skill.description), skill.path.join("SKILL.md").display(), )); } @@ -230,4 +235,16 @@ 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\"".to_string(), + path: PathBuf::from("/skills/test"), + }]; + let prompt = format_skills_prompt(&skills); + assert!(prompt.contains("a<b")); + assert!(prompt.contains("Use <tag> & \"quotes\"")); + } } From 7c570feb440e91fbb1b7edba0a9aae008fdb8470 Mon Sep 17 00:00:00 2001 From: Zoe Date: Mon, 1 Jun 2026 08:35:09 +0000 Subject: [PATCH 2/4] fix(skills): ensure deterministic ordering of skills prompt --- openab-agent/src/skills.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index f02ac200f..31155178e 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -109,13 +109,18 @@ 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", xml_escape(&skill.name), @@ -247,4 +252,24 @@ mod tests { assert!(prompt.contains("a<b")); assert!(prompt.contains("Use <tag> & \"quotes\"")); } + + #[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); + } } From b023179c7194d2f04218db99e7c6097bab273822 Mon Sep 17 00:00:00 2001 From: Zoe Date: Mon, 1 Jun 2026 08:36:11 +0000 Subject: [PATCH 3/4] style(skills): format xml_escape to comply with rustfmt --- openab-agent/src/skills.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 31155178e..83b296ccc 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -101,7 +101,9 @@ fn parse_frontmatter(content: &str) -> Option<(String, String)> { /// Escape XML special characters in user-controlled strings to preserve prompt structure. fn xml_escape(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">") + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") } /// Format skills as a system prompt section listing available skills. From 3fbb5bd6a714567dd22c3e4ccf0e68c01bc26cc2 Mon Sep 17 00:00:00 2001 From: Zoe Date: Mon, 1 Jun 2026 08:43:57 +0000 Subject: [PATCH 4/4] fix(skills): escape location path and add tag-injection regression tests --- openab-agent/src/skills.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 83b296ccc..f228c7efb 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -127,7 +127,7 @@ pub fn format_skills_prompt(skills: &[Skill]) -> String { " \n {}\n {}\n {}\n \n", xml_escape(&skill.name), xml_escape(&skill.description), - skill.path.join("SKILL.md").display(), + xml_escape(&skill.path.join("SKILL.md").to_string_lossy()), )); } out.push_str("\n"); @@ -247,12 +247,13 @@ mod tests { fn format_skills_prompt_escapes_xml_chars() { let skills = vec![Skill { name: "a & \"quotes\"".to_string(), - path: PathBuf::from("/skills/test"), + description: "Use & \"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\"")); + assert!(prompt.contains("Use <tag> & \"quotes\" </description> injection test")); + assert!(prompt.contains("/skills/test&cool/SKILL.md")); } #[test]