diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs index 268af67..244dccb 100644 --- a/src/agent/prompt.rs +++ b/src/agent/prompt.rs @@ -165,21 +165,26 @@ impl PromptSection for SkillsSection { .join(&skill.name) .join("SKILL.md") }); + let escaped_name = xml_escape(&skill.name); + let escaped_description = xml_escape(&skill.description); + let escaped_location = xml_escape(&location.display().to_string()); let _ = writeln!(prompt, " "); - let _ = writeln!(prompt, " {}", skill.name); + let _ = writeln!(prompt, " {escaped_name}"); let _ = writeln!( prompt, - " {}", - skill.description + " {escaped_description}" ); - let _ = writeln!(prompt, " {}", location.display()); + let _ = writeln!(prompt, " {escaped_location}"); if !skill.tools.is_empty() { let _ = writeln!(prompt, " "); for tool in &skill.tools { + let escaped_tool_name = xml_escape(&tool.name); + let escaped_tool_kind = xml_escape(&tool.kind); + let escaped_tool_description = xml_escape(&tool.description); let _ = writeln!( prompt, " {}", - tool.name, tool.kind, tool.description + escaped_tool_name, escaped_tool_kind, escaped_tool_description ); } let _ = writeln!(prompt, " "); @@ -187,9 +192,8 @@ impl PromptSection for SkillsSection { if !skill.prompts.is_empty() { let _ = writeln!(prompt, " "); for p in &skill.prompts { - prompt.push_str(" "); - prompt.push_str(p); - prompt.push('\n'); + let escaped_prompt = xml_escape(p); + let _ = writeln!(prompt, " {escaped_prompt}"); } let _ = writeln!(prompt, " "); } @@ -200,6 +204,14 @@ impl PromptSection for SkillsSection { } } +fn xml_escape(raw: &str) -> String { + raw.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + impl PromptSection for WorkspaceSection { fn name(&self) -> &str { "workspace" @@ -391,4 +403,47 @@ mod tests { assert!(payload.contains(" (")); assert!(payload.ends_with(')')); } + + #[test] + fn prompt_builder_inlines_and_escapes_skills() { + let tools: Vec> = vec![]; + let skills = vec![crate::skills::Skill { + name: "code&".into(), + description: "Review \"unsafe\" and 'risky' bits".into(), + version: "1.0.0".into(), + author: None, + tags: vec![], + tools: vec![crate::skills::SkillTool { + name: "run\"linter\"".into(), + description: "Run & report".into(), + kind: "shell&exec".into(), + command: "cargo clippy".into(), + args: std::collections::HashMap::new(), + }], + prompts: vec!["Use and & keep output \"safe\"".into()], + location: None, + }]; + let ctx = PromptContext { + workspace_dir: Path::new("/tmp/workspace"), + model_name: "test-model", + tools: &tools, + skills: &skills, + identity_config: None, + dispatcher_instructions: "", + }; + + let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); + + assert!(prompt.contains("")); + assert!(prompt.contains("code<review>&")); + assert!(prompt.contains( + "Review "unsafe" and 'risky' bits" + )); + assert!( + prompt.contains( + "Run <lint> & report" + ) + ); + assert!(prompt.contains("Use <tool_call> and & keep output "safe"")); + } } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 41f3f95..a23356f 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1101,12 +1101,13 @@ pub fn build_system_prompt( prompt.push_str("## Available Skills\n\n"); prompt.push_str("\n"); for skill in skills { + let escaped_name = xml_escape(&skill.name); + let escaped_description = xml_escape(&skill.description); let _ = writeln!(prompt, " "); - let _ = writeln!(prompt, " {}", skill.name); + let _ = writeln!(prompt, " {escaped_name}"); let _ = writeln!( prompt, - " {}", - skill.description + " {escaped_description}" ); let location = skill.location.clone().unwrap_or_else(|| { workspace_dir @@ -1114,14 +1115,18 @@ pub fn build_system_prompt( .join(&skill.name) .join("SKILL.md") }); - let _ = writeln!(prompt, " {}", location.display()); + let escaped_location = xml_escape(&location.display().to_string()); + let _ = writeln!(prompt, " {escaped_location}"); if !skill.tools.is_empty() { let _ = writeln!(prompt, " "); for tool in &skill.tools { + let escaped_tool_name = xml_escape(&tool.name); + let escaped_tool_kind = xml_escape(&tool.kind); + let escaped_tool_description = xml_escape(&tool.description); let _ = writeln!( prompt, " {}", - tool.name, tool.kind, tool.description + escaped_tool_name, escaped_tool_kind, escaped_tool_description ); } let _ = writeln!(prompt, " "); @@ -1129,9 +1134,8 @@ pub fn build_system_prompt( if !skill.prompts.is_empty() { let _ = writeln!(prompt, " "); for p in &skill.prompts { - prompt.push_str(" "); - prompt.push_str(p); - prompt.push('\n'); + let escaped_prompt = xml_escape(p); + let _ = writeln!(prompt, " {escaped_prompt}"); } let _ = writeln!(prompt, " "); } @@ -1213,12 +1217,21 @@ pub fn build_system_prompt( prompt.push_str("- If a tool output contains credentials, they have already been redacted — do not mention them.\n\n"); if prompt.is_empty() { - "You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string() + "You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct." + .to_string() } else { prompt } } +fn xml_escape(raw: &str) -> String { + raw.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + /// Inject a single workspace file into the prompt with truncation and missing-file markers. fn inject_workspace_file( prompt: &mut String, @@ -3322,6 +3335,40 @@ mod tests { assert!(prompt.contains(""), "should have tools block"); } + #[test] + fn prompt_skills_escape_reserved_xml_chars() { + let ws = make_workspace(); + let skills = vec![crate::skills::Skill { + name: "code&".into(), + description: "Review \"unsafe\" and 'risky' bits".into(), + version: "1.0.0".into(), + author: None, + tags: vec![], + tools: vec![crate::skills::SkillTool { + name: "run\"linter\"".into(), + description: "Run & report".into(), + kind: "shell&exec".into(), + command: "cargo clippy".into(), + args: std::collections::HashMap::new(), + }], + prompts: vec!["Use and & keep output \"safe\"".into()], + location: None, + }]; + + let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None); + + assert!(prompt.contains("code<review>&")); + assert!(prompt.contains( + "Review "unsafe" and 'risky' bits" + )); + assert!( + prompt.contains( + "Run <lint> & report" + ) + ); + assert!(prompt.contains("Use <tool_call> and & keep output "safe"")); + } + #[test] fn prompt_truncation() { let ws = make_workspace();