diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index bfbb81b85b..c5d72306a0 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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. @@ -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): + prompt_yaml = yaml.safe_dump( + {"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize + ).strip() + 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 diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 56bed09eb2..a3968384f2 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -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.