Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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=float("inf")
).strip()
Comment on lines +1191 to +1193

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to sys.maxsize in ff0afae. For reference, PyYAML 6.0.3 accepts float("inf") (the emitter only compares against best_width) and produces byte-identical output, but an int is the documented type and avoids the question entirely.

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
23 changes: 23 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,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.

Expand Down
Loading