fix(skills): escape inlined skill XML content
This commit is contained in:
parent
8a4da141d6
commit
d9a94fc763
2 changed files with 119 additions and 17 deletions
|
|
@ -165,21 +165,26 @@ impl PromptSection for SkillsSection {
|
||||||
.join(&skill.name)
|
.join(&skill.name)
|
||||||
.join("SKILL.md")
|
.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, " <skill>");
|
let _ = writeln!(prompt, " <skill>");
|
||||||
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
|
let _ = writeln!(prompt, " <name>{escaped_name}</name>");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
prompt,
|
prompt,
|
||||||
" <description>{}</description>",
|
" <description>{escaped_description}</description>"
|
||||||
skill.description
|
|
||||||
);
|
);
|
||||||
let _ = writeln!(prompt, " <location>{}</location>", location.display());
|
let _ = writeln!(prompt, " <location>{escaped_location}</location>");
|
||||||
if !skill.tools.is_empty() {
|
if !skill.tools.is_empty() {
|
||||||
let _ = writeln!(prompt, " <tools>");
|
let _ = writeln!(prompt, " <tools>");
|
||||||
for tool in &skill.tools {
|
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!(
|
let _ = writeln!(
|
||||||
prompt,
|
prompt,
|
||||||
" <tool name=\"{}\" kind=\"{}\">{}</tool>",
|
" <tool name=\"{}\" kind=\"{}\">{}</tool>",
|
||||||
tool.name, tool.kind, tool.description
|
escaped_tool_name, escaped_tool_kind, escaped_tool_description
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _ = writeln!(prompt, " </tools>");
|
let _ = writeln!(prompt, " </tools>");
|
||||||
|
|
@ -187,9 +192,8 @@ impl PromptSection for SkillsSection {
|
||||||
if !skill.prompts.is_empty() {
|
if !skill.prompts.is_empty() {
|
||||||
let _ = writeln!(prompt, " <instructions>");
|
let _ = writeln!(prompt, " <instructions>");
|
||||||
for p in &skill.prompts {
|
for p in &skill.prompts {
|
||||||
prompt.push_str(" ");
|
let escaped_prompt = xml_escape(p);
|
||||||
prompt.push_str(p);
|
let _ = writeln!(prompt, " {escaped_prompt}");
|
||||||
prompt.push('\n');
|
|
||||||
}
|
}
|
||||||
let _ = writeln!(prompt, " </instructions>");
|
let _ = writeln!(prompt, " </instructions>");
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +204,14 @@ impl PromptSection for SkillsSection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn xml_escape(raw: &str) -> String {
|
||||||
|
raw.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
impl PromptSection for WorkspaceSection {
|
impl PromptSection for WorkspaceSection {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"workspace"
|
"workspace"
|
||||||
|
|
@ -391,4 +403,47 @@ mod tests {
|
||||||
assert!(payload.contains(" ("));
|
assert!(payload.contains(" ("));
|
||||||
assert!(payload.ends_with(')'));
|
assert!(payload.ends_with(')'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_builder_inlines_and_escapes_skills() {
|
||||||
|
let tools: Vec<Box<dyn Tool>> = vec![];
|
||||||
|
let skills = vec![crate::skills::Skill {
|
||||||
|
name: "code<review>&".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 <lint> & report".into(),
|
||||||
|
kind: "shell&exec".into(),
|
||||||
|
command: "cargo clippy".into(),
|
||||||
|
args: std::collections::HashMap::new(),
|
||||||
|
}],
|
||||||
|
prompts: vec!["Use <tool_call> 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("<available_skills>"));
|
||||||
|
assert!(prompt.contains("<name>code<review>&</name>"));
|
||||||
|
assert!(prompt.contains(
|
||||||
|
"<description>Review "unsafe" and 'risky' bits</description>"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
prompt.contains(
|
||||||
|
"<tool name=\"run"linter"\" kind=\"shell&exec\">Run <lint> & report</tool>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("Use <tool_call> and & keep output "safe""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1101,12 +1101,13 @@ pub fn build_system_prompt(
|
||||||
prompt.push_str("## Available Skills\n\n");
|
prompt.push_str("## Available Skills\n\n");
|
||||||
prompt.push_str("<available_skills>\n");
|
prompt.push_str("<available_skills>\n");
|
||||||
for skill in skills {
|
for skill in skills {
|
||||||
|
let escaped_name = xml_escape(&skill.name);
|
||||||
|
let escaped_description = xml_escape(&skill.description);
|
||||||
let _ = writeln!(prompt, " <skill>");
|
let _ = writeln!(prompt, " <skill>");
|
||||||
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
|
let _ = writeln!(prompt, " <name>{escaped_name}</name>");
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
prompt,
|
prompt,
|
||||||
" <description>{}</description>",
|
" <description>{escaped_description}</description>"
|
||||||
skill.description
|
|
||||||
);
|
);
|
||||||
let location = skill.location.clone().unwrap_or_else(|| {
|
let location = skill.location.clone().unwrap_or_else(|| {
|
||||||
workspace_dir
|
workspace_dir
|
||||||
|
|
@ -1114,14 +1115,18 @@ pub fn build_system_prompt(
|
||||||
.join(&skill.name)
|
.join(&skill.name)
|
||||||
.join("SKILL.md")
|
.join("SKILL.md")
|
||||||
});
|
});
|
||||||
let _ = writeln!(prompt, " <location>{}</location>", location.display());
|
let escaped_location = xml_escape(&location.display().to_string());
|
||||||
|
let _ = writeln!(prompt, " <location>{escaped_location}</location>");
|
||||||
if !skill.tools.is_empty() {
|
if !skill.tools.is_empty() {
|
||||||
let _ = writeln!(prompt, " <tools>");
|
let _ = writeln!(prompt, " <tools>");
|
||||||
for tool in &skill.tools {
|
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!(
|
let _ = writeln!(
|
||||||
prompt,
|
prompt,
|
||||||
" <tool name=\"{}\" kind=\"{}\">{}</tool>",
|
" <tool name=\"{}\" kind=\"{}\">{}</tool>",
|
||||||
tool.name, tool.kind, tool.description
|
escaped_tool_name, escaped_tool_kind, escaped_tool_description
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _ = writeln!(prompt, " </tools>");
|
let _ = writeln!(prompt, " </tools>");
|
||||||
|
|
@ -1129,9 +1134,8 @@ pub fn build_system_prompt(
|
||||||
if !skill.prompts.is_empty() {
|
if !skill.prompts.is_empty() {
|
||||||
let _ = writeln!(prompt, " <instructions>");
|
let _ = writeln!(prompt, " <instructions>");
|
||||||
for p in &skill.prompts {
|
for p in &skill.prompts {
|
||||||
prompt.push_str(" ");
|
let escaped_prompt = xml_escape(p);
|
||||||
prompt.push_str(p);
|
let _ = writeln!(prompt, " {escaped_prompt}");
|
||||||
prompt.push('\n');
|
|
||||||
}
|
}
|
||||||
let _ = writeln!(prompt, " </instructions>");
|
let _ = writeln!(prompt, " </instructions>");
|
||||||
}
|
}
|
||||||
|
|
@ -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");
|
prompt.push_str("- If a tool output contains credentials, they have already been redacted — do not mention them.\n\n");
|
||||||
|
|
||||||
if prompt.is_empty() {
|
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 {
|
} else {
|
||||||
prompt
|
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.
|
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
|
||||||
fn inject_workspace_file(
|
fn inject_workspace_file(
|
||||||
prompt: &mut String,
|
prompt: &mut String,
|
||||||
|
|
@ -3322,6 +3335,40 @@ mod tests {
|
||||||
assert!(prompt.contains("<tools>"), "should have tools block");
|
assert!(prompt.contains("<tools>"), "should have tools block");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_skills_escape_reserved_xml_chars() {
|
||||||
|
let ws = make_workspace();
|
||||||
|
let skills = vec![crate::skills::Skill {
|
||||||
|
name: "code<review>&".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 <lint> & report".into(),
|
||||||
|
kind: "shell&exec".into(),
|
||||||
|
command: "cargo clippy".into(),
|
||||||
|
args: std::collections::HashMap::new(),
|
||||||
|
}],
|
||||||
|
prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
|
||||||
|
location: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
|
||||||
|
|
||||||
|
assert!(prompt.contains("<name>code<review>&</name>"));
|
||||||
|
assert!(prompt.contains(
|
||||||
|
"<description>Review "unsafe" and 'risky' bits</description>"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
prompt.contains(
|
||||||
|
"<tool name=\"run"linter"\" kind=\"shell&exec\">Run <lint> & report</tool>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(prompt.contains("Use <tool_call> and & keep output "safe""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prompt_truncation() {
|
fn prompt_truncation() {
|
||||||
let ws = make_workspace();
|
let ws = make_workspace();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue