Skip to content

Commit ba13ac8

Browse files
fix(skills): escape quotes and backslashes in YAML colon fixer, escape all 5 XML special chars
Fix two bugs in the AgentSkills plugin discovered during adversarial testing: 1. _fix_yaml_colons now escapes backslashes and double quotes before wrapping values in double quotes. Previously, values like 'description: Use when: user says "hello"' would produce invalid YAML because the quotes were not escaped. 2. XML generation now escapes all 5 XML special characters (&, <, >, ", ') instead of only the 3 covered by the default xml.sax.saxutils.escape (which omits " and ').
1 parent 2b81401 commit ba13ac8

4 files changed

Lines changed: 108 additions & 5 deletions

File tree

src/strands/vended_plugins/skills/agent_skills.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import logging
1111
from pathlib import Path
1212
from typing import TYPE_CHECKING, Any, TypeAlias
13-
from xml.sax.saxutils import escape
13+
from xml.sax.saxutils import escape as _xml_escape
1414

1515
from ...hooks.events import BeforeInvocationEvent
1616
from ...plugins import Plugin, hook
@@ -27,6 +27,24 @@
2727
_RESOURCE_DIRS = ("scripts", "references", "assets")
2828
_DEFAULT_MAX_RESOURCE_FILES = 20
2929

30+
_XML_EXTRA_ENTITIES = {'"': "&quot;", "'": "&apos;"}
31+
32+
33+
def _escape_xml(text: str) -> str:
34+
"""Escape all 5 XML special characters in text.
35+
36+
Extends the standard library's ``xml.sax.saxutils.escape`` to also
37+
escape ``"`` and ``'``, which are not covered by default.
38+
39+
Args:
40+
text: The text to escape.
41+
42+
Returns:
43+
The escaped text.
44+
"""
45+
return _xml_escape(text, _XML_EXTRA_ENTITIES)
46+
47+
3048
SkillSource: TypeAlias = str | Path | Skill
3149
"""A single skill source: path string, Path object, or Skill instance."""
3250

@@ -271,10 +289,10 @@ def _generate_skills_xml(self) -> str:
271289

272290
for skill in self._skills.values():
273291
lines.append("<skill>")
274-
lines.append(f"<name>{escape(skill.name)}</name>")
275-
lines.append(f"<description>{escape(skill.description)}</description>")
292+
lines.append(f"<name>{_escape_xml(skill.name)}</name>")
293+
lines.append(f"<description>{_escape_xml(skill.description)}</description>")
276294
if skill.path is not None:
277-
lines.append(f"<location>{escape(str(skill.path / 'SKILL.md'))}</location>")
295+
lines.append(f"<location>{_escape_xml(str(skill.path / 'SKILL.md'))}</location>")
278296
lines.append("</skill>")
279297

280298
lines.append("</available_skills>")

src/strands/vended_plugins/skills/skill.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ def _fix_yaml_colons(yaml_str: str) -> str:
104104
key, value = match.group(1), match.group(2)
105105
# If value contains a colon and isn't already quoted
106106
if ":" in value and not (value.startswith('"') or value.startswith("'")):
107-
line = f'{key}: "{value}"'
107+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
108+
line = f'{key}: "{escaped}"'
108109
lines.append(line)
109110
return "\n".join(lines)
110111

tests/strands/vended_plugins/skills/test_agent_skills.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,3 +689,43 @@ def test_skills_plugin_isinstance_check(self):
689689

690690
plugin = AgentSkills(skills=[])
691691
assert isinstance(plugin, Plugin)
692+
693+
694+
class TestSkillsXmlEscaping:
695+
"""Tests for XML escaping of all 5 special characters."""
696+
697+
def test_escapes_double_quotes(self):
698+
"""Test that double quotes in descriptions are escaped."""
699+
skill = _make_skill(description='Use "quotes" in text')
700+
plugin = AgentSkills(skills=[skill])
701+
xml = plugin._generate_skills_xml()
702+
703+
assert "&quot;" in xml
704+
assert '"quotes"' not in xml.split("<description>")[1].split("</description>")[0]
705+
706+
def test_escapes_single_quotes(self):
707+
"""Test that single quotes (apostrophes) in descriptions are escaped."""
708+
skill = _make_skill(description="It's a skill")
709+
plugin = AgentSkills(skills=[skill])
710+
xml = plugin._generate_skills_xml()
711+
712+
# Should escape ' to &apos; or &#x27; or similar
713+
desc_content = xml.split("<description>")[1].split("</description>")[0]
714+
assert "'" not in desc_content
715+
716+
def test_escapes_all_five_xml_special_chars(self):
717+
"""Test that all 5 XML special characters are escaped."""
718+
skill = _make_skill(
719+
name="test-skill",
720+
description="Has & < > \" ' chars",
721+
)
722+
plugin = AgentSkills(skills=[skill])
723+
xml = plugin._generate_skills_xml()
724+
725+
desc_content = xml.split("<description>")[1].split("</description>")[0]
726+
assert "&amp;" in desc_content
727+
assert "&lt;" in desc_content
728+
assert "&gt;" in desc_content
729+
assert "&quot;" in desc_content
730+
# ' should be escaped as &apos; or similar entity
731+
assert "'" not in desc_content

tests/strands/vended_plugins/skills/test_skill.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,3 +559,47 @@ def test_skill_classmethods_exist(self):
559559
assert callable(getattr(Skill, "from_file", None))
560560
assert callable(getattr(Skill, "from_content", None))
561561
assert callable(getattr(Skill, "from_directory", None))
562+
563+
564+
class TestFixYamlColonsEscaping:
565+
"""Tests for _fix_yaml_colons escaping edge cases (Bug fix)."""
566+
567+
def test_escapes_double_quotes_in_value(self):
568+
"""Test that double quotes in values are escaped before wrapping."""
569+
raw = 'description: Use when: user says "hello" and "goodbye"'
570+
fixed = _fix_yaml_colons(raw)
571+
# Must escape quotes: description: "Use when: user says \"hello\" and \"goodbye\""
572+
assert r"\"hello\"" in fixed
573+
assert r"\"goodbye\"" in fixed
574+
# Verify it produces valid YAML
575+
import yaml
576+
577+
result = yaml.safe_load(fixed)
578+
assert result["description"] == 'Use when: user says "hello" and "goodbye"'
579+
580+
def test_escapes_backslashes_in_value(self):
581+
r"""Test that backslashes in values are escaped before wrapping."""
582+
raw = r"description: Path C:\Users\test: with colons"
583+
fixed = _fix_yaml_colons(raw)
584+
# Must escape backslashes before wrapping
585+
import yaml
586+
587+
result = yaml.safe_load(fixed)
588+
assert r"C:\Users\test" in result["description"]
589+
590+
def test_escapes_mixed_quotes_and_backslashes(self):
591+
r"""Test that both quotes and backslashes are properly escaped."""
592+
raw = r'description: Run "cmd": C:\path\to: file'
593+
fixed = _fix_yaml_colons(raw)
594+
import yaml
595+
596+
result = yaml.safe_load(fixed)
597+
assert '"cmd"' in result["description"]
598+
assert "C:\\path\\to" in result["description"]
599+
600+
def test_frontmatter_with_quotes_in_colon_value(self):
601+
"""Test end-to-end: frontmatter with quotes and colons parses correctly."""
602+
content = '---\nname: my-skill\ndescription: Use when: user says "hello"\n---\nBody.'
603+
frontmatter, body = _parse_frontmatter(content)
604+
assert frontmatter["name"] == "my-skill"
605+
assert '"hello"' in frontmatter["description"]

0 commit comments

Comments
 (0)