diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 37d3cdf965..f532f885c1 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1059,6 +1059,10 @@ 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), and DEL. +_YAML_BLOCK_SCALAR_UNSAFE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]") + class YamlIntegration(IntegrationBase): """Concrete base for integrations that use YAML recipe format. @@ -1178,6 +1182,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..f46ba89143 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -201,6 +201,29 @@ 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) and DEL + in every scalar form; a literal block scalar emits them verbatim, + so the generated recipe failed to load. The renderer falls back to + an escaped double-quoted scalar for such bodies.""" + for ch in ("\x08", "\x0c", "\x1b", "\x7f"): + 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.