Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ follow [SemVer](https://semver.org/).
fallback, matching what the code reads

### Fixed
- Author-supplied field text can no longer desync the markdown reply
skeleton (confirmation-pass-2 needs-a-look, 2026-08-20 — the LOUD
sibling of the pass-2 silent-injection fix). A literal triple-backtick
fence inside a label, help text, or OPTION used to open a stray code
fence in the rendered form, so the trailing `answers` skeleton was no
longer cleanly delimited and paste-back failed loudly ("fenced code
block is not valid JSON"). Every author/host line rendered by
`form_to_markdown` and the `problems_to_markdown` re-ask is now defused
— runs of three+ backticks get a woven zero-width break so no ```
substring survives, while inline `` `code` `` (runs under three)
renders untouched. A fence-bearing value that reaches the JSON skeleton
itself (a default/recommended/suggested option carrying a fence) has
each backtick emitted as the JSON unicode escape `\u0060`, which
`json.loads` restores on ingestion, so the skeleton's own fence stays
intact and exact option matching is unchanged. The widget surface
HTML-escapes and was already immune
- **Directly-built D2 gate with a `default` can no longer pass
unanswered** (checkpoint-2 promoted item, 2026-08-20 — empirically
confirmed). The no-`default` rule for the two-way constructs (confirm,
Expand Down
16 changes: 11 additions & 5 deletions src/attune_forms/markdown_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
import re
from typing import Any

from attune_forms.markdown_surface import _field_lines, reply_skeleton
from attune_forms.markdown_surface import (
_defuse_fences,
_field_lines,
_skeleton_block,
reply_skeleton,
)
from attune_forms.models import (
ASSUMPTION_TEXT_SUFFIX,
FormQuestion,
Expand Down Expand Up @@ -475,15 +480,16 @@ def problems_to_markdown(form: FormSchema, problems: list[str]) -> str:
field = _field_lines(q)
field[0] = f"{idx}. {field[0]}"
lines += ["", *field]
# Defuse first: a re-asked field's text OR a quoted problem string may
# carry a fence, and either would desync the skeleton emitted below
# (same last-block-wins invariant form_to_markdown relies on).
lines = [_defuse_fences(line) for line in lines]
if offenders:
skeleton = reply_skeleton(form, offenders)
lines += [
"",
"Reply for just these fields — shorthand works (`field_id: value` "
"or `N: value`), or fill the `answers` skeleton below:",
"",
"```json",
json.dumps(skeleton, indent=2, ensure_ascii=False),
"```",
*_skeleton_block(reply_skeleton(form, offenders)),
]
return "\n".join(lines)
50 changes: 46 additions & 4 deletions src/attune_forms/markdown_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

import json
import re
from typing import Any

from attune_forms.models import (
Expand All @@ -35,6 +36,47 @@
)
from attune_forms.widget import WIDGET_RESPONSE_MARKER

#: Any run of three or more backticks — a markdown fence opener/closer.
_FENCE_RUN_RE = re.compile(r"`{3,}")

#: Woven between backticks to defuse a run; invisible in a terminal.
_FENCE_ZWSP = "​"


def _defuse_fences(text: str) -> str:
"""Break any run of three+ backticks in author/host-supplied text so
it can never open or close a markdown fence and desync the trailing
reply skeleton (confirmation pass 2, 2026-08-20).

A fence-bearing label, help text, or OPTION rendered verbatim used to
corrupt the delimiting of the ``answers`` skeleton
:func:`form_to_markdown` appends — the primary taught JSON-reply path
then failed to round-trip (``markdown_to_answers`` saw the wrong
fence boundaries). A zero-width space is woven between the backticks:
the run still reads as backticks in a terminal but no ``` substring
survives, so ``markdown_ingestion._FENCE_RE`` cannot mistake field
text for a fenced block. Runs shorter than three (real inline code,
`` `x` ``) are left untouched so legitimate inline backticks render.
"""
if "```" not in text:
return text
return _FENCE_RUN_RE.sub(lambda m: _FENCE_ZWSP.join(m.group()), text)


def _skeleton_block(skeleton: dict[str, Any]) -> list[str]:
"""The trailing ``answers`` skeleton as a fenced ``json`` block.

Backticks inside the payload are emitted as the JSON escape
``\\u0060`` — a fence-bearing author value that reaches the skeleton
(a default/recommended/suggested option carrying ```) would otherwise
close this block's own ``json`` fence early and break the paste-back.
Every backtick in the dump sits inside a JSON string, where
``\\u0060`` is valid and ``json.loads`` restores it, so exact option
matching on ingestion is unchanged (confirmation pass 2, 2026-08-20).
"""
payload = json.dumps(skeleton, indent=2, ensure_ascii=False).replace("`", "\\u0060")
return ["```json", payload, "```"]


def _option_lines(q: FormQuestion, *, badge_for: dict[str, str] | None = None) -> list[str]:
"""Bullet (or numbered) lines for a select-like question's options.
Expand Down Expand Up @@ -280,7 +322,9 @@ def form_to_markdown(form: FormSchema, message: str = "") -> str:
field = _field_lines(q)
field[0] = f"{idx}. {field[0]}"
lines += ["", *field]
skeleton = reply_skeleton(form)
# Defuse every author/host line before the machine skeleton: a fence
# in field text must not desync the ``answers`` block below it.
lines = [_defuse_fences(line) for line in lines]
lines += [
"",
"---",
Expand All @@ -291,8 +335,6 @@ def form_to_markdown(form: FormSchema, message: str = "") -> str:
"(`field_id.1: b`); an assumption row is `field_id.item_id: accept`, "
"`field_id.item_id: reject`, or `field_id.item_id: edit: <text>`:",
"",
"```json",
json.dumps(skeleton, indent=2, ensure_ascii=False),
"```",
*_skeleton_block(reply_skeleton(form)),
]
return "\n".join(lines)
96 changes: 96 additions & 0 deletions tests/test_markdown_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,99 @@ def test_non_edit_dict_ruling_not_laundered_into_edit(self) -> None:
assert answers["a"]["item"] == {"keep": True} # NOT rewritten to {"edit": ...}
with pytest.raises(FormValidationError, match="invalid ruling"):
collect_form_response(form, answers)

def test_fence_bearing_option_keeps_skeleton_parseable(self) -> None:
# LOUD variant of the field-text injection: a literal ``` fence
# inside an OPTION used to desync `_FENCE_RE`, so the trailing
# skeleton was no longer cleanly delimited and paste-back failed
# loudly ("fenced code block is not valid JSON"). The rendered
# fence is now defused so the round-trip stays clean.
form = form_from_dict(
{
"title": "Demo",
"fields": [
{
"id": "pick",
"type": "single_select",
"text": "Choose",
"options": ["ok\n```\nsneaky", "other"],
}
],
}
)
markdown = form_to_markdown(form)
answers, problems = markdown_to_answers(form, markdown)
assert problems == [] # skeleton still parses; no injected problems
assert answers == {} # nothing picked -> all placeholders unanswered
# A real pick typed as shorthand alongside the pasted form resolves.
picked, picked_problems = markdown_to_answers(form, markdown + "\npick: other")
assert picked == {"pick": "other"}
assert picked_problems == []

def test_fence_bearing_default_survives_in_skeleton(self) -> None:
# A fence-bearing value that reaches the JSON skeleton (a default
# option carrying ```) would close the skeleton's own ```json
# fence early; backticks emit as the \\u0060 escape, which
# `json.loads` restores, so the exact option round-trips.
form = form_from_dict(
{
"title": "Demo",
"fields": [
{
"id": "pick",
"type": "single_select",
"text": "Choose",
"options": ["a```b", "other"],
"default": "a```b",
}
],
}
)
markdown = form_to_markdown(form)
assert "```json" in markdown # the skeleton fence is intact
answers, problems = markdown_to_answers(form, markdown)
assert answers == {"pick": "a```b"} # raw value preserved for matching
assert problems == []
collect_form_response(form, answers) # validates against the option

def test_fence_in_reask_field_text_keeps_reask_skeleton_parseable(self) -> None:
# The re-ask path shares `_field_lines`; a fence in a re-asked
# field's text must not desync the re-ask skeleton either.
form = form_from_dict(
{
"title": "Demo",
"fields": [
{
"id": "pick",
"type": "single_select",
"text": "Choose ```\nfence",
"options": ["x", "y"],
},
{"id": "other", "type": "text_input", "text": "Other"},
],
}
)
reask = problems_to_markdown(form, ["'pick' is not a valid option"])
answers, problems = markdown_to_answers(form, reask)
assert answers == {}
assert problems == []

def test_inline_backticks_in_field_text_still_render(self) -> None:
# Defusing must only touch runs of three+ backticks; real inline
# code (single/double backticks) survives verbatim.
form = form_from_dict(
{
"title": "T",
"fields": [
{
"id": "q",
"type": "text_input",
"text": "Use `code` here",
"help_text": "like ``x`` too",
}
],
}
)
markdown = form_to_markdown(form)
assert "`code`" in markdown
assert "``x``" in markdown
Loading