Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,17 @@ def setup(
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------

# Characters a YAML literal block scalar cannot carry: C0 controls other
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
# treats it as a line break, which corrupts the block scalar's structure
# just the same, so all three are included.
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
)


class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.

Expand Down Expand Up @@ -1227,9 +1238,9 @@ def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]:
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.

Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
Produces a Goose-compatible recipe with a literal block scalar for
normal prompt content, or an escaped quoted scalar when control
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
"""
header = cls._build_yaml_header(title, description)

Expand All @@ -1240,6 +1251,23 @@ def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -
default_flow_style=False,
).strip()

# YAML forbids C0 control characters (except tab and newline) and
# DEL in every scalar form, and a bare CR acts as a line break
# inside a block scalar. A literal block scalar emits such bytes
# verbatim, producing a recipe the YAML parser rejects, so fall
# back to an escaped double-quoted scalar for those bodies.
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
Comment thread
marcelsafin marked this conversation as resolved.
prompt_yaml = yaml.safe_dump(
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
).strip()
Comment thread
marcelsafin marked this conversation as resolved.
lines = [
header_yaml,
prompt_yaml,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"

# Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose
Expand Down
30 changes: 30 additions & 0 deletions tests/integrations/test_integration_base_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,36 @@ def test_yaml_prompt_with_indented_first_line_stays_valid(self):
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body

def test_yaml_prompt_with_control_characters_stays_valid(self):
"""A body containing control characters must still produce parseable YAML.

YAML forbids C0 control characters (except tab and newline), DEL,
C1 controls, lone surrogates and U+FFFE/U+FFFF in every scalar form,
and YAML 1.1 treats NEL (U+0085), LS (U+2028) and PS (U+2029) as
line breaks that corrupt a literal block scalar's structure. The
renderer falls back to an escaped double-quoted scalar for such
bodies."""
for ch in (
"\x08", "\x0c", "\x1b", "\x7f",
"\x80", "\x84", "\x85", "\x86", "\x9f",
"\u2028", "\u2029",
"\ud800", "\udfff", "\ufffe", "\uffff",
):
body = f"before{ch}after\nsecond line"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body, f"char {ch!r} round-trip"

def test_yaml_prompt_with_bare_carriage_return_stays_valid(self):
"""A bare CR (not part of CRLF) must not break the generated YAML.

Inside a block scalar a lone \r acts as a line break, corrupting
the document structure."""
body = "line1\rstill line1\nline2"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body

def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.

Expand Down
Loading