Skip to content

fix: keep input marking through filters, tojson and join; undefined member access - #34

Merged
leehack merged 3 commits into
mainfrom
fix/input-marking-tojson-join-filter-undefined
Sep 26, 2026
Merged

leehack merged 3 commits into
mainfrom
fix/input-marking-tojson-join-filter-undefined

Conversation

@leehack

@leehack leehack commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #30
Closes #31
Closes #33

References: llama.cpp 7fe450e19 common/jinja, built standalone with a render hook, and Jinja2 3.1.6 as SandboxedEnvironment(trim_blocks, lstrip_blocks, loopcontrols), with and without autoescape. For #33, dinja follows llama.cpp. For #30 and #31, llama.cpp never HTML-escapes, so the escaping rules are dinja's own; the deviations are listed below.

What changed

  • filter blocks apply the filter to already-escaped input-marked text #30, filters on captured output: input text is no longer escaped when a block renders. execStatements joins output unescaped and Program.execute escapes once. So {% filter %}, and a filter on block set, macro or caller() output, sees the input as passed in, as {{ x | upper }} does. With x = JinjaString.user('<b>'):
    • {% filter upper %}{{ x }}{% endfilter %} is &lt;B&gt; (was &LT;B&GT;).
    • {% filter length %}… is 3 (was 9), as in llama.cpp.
    • Rendered output (block set, macro, caller(), {% filter %} body) is final, as in Jinja2 with autoescape. So safe on it, or on text made from it or from other safe text, escapes the input in it. | safe on a value passed to render, or returned by a function passed to it, still outputs it unescaped. {% filter safe %}{{ x }}{% endfilter %}, {{ c | safe }}, {{ m(x) | safe }}, {{ caller() | safe }} and {{ ns.v | safe }} give &lt;b&gt;, as in main and Jinja2 with autoescape.
  • tojson and join drop input marking, so JinjaString.user content is output unescaped #31, tojson and join: these keep the marking per part. Only input text is escaped, after JSON escaping; that covers values, nested values, dict keys and separators, with ensure_ascii and indent. The JSON's own quotes and the template's separators are not escaped.
    • {{ {"k": x} | tojson }} gives {"k": "&lt;b&gt;"} (was {"k": "<b>"}).
    • {{ [x, "<i>"] | join }} gives &lt;b&gt;<i> (was <b><i>).
    • tojson output that contains input is no longer is escaped.
  • Other marking leaks (found by the attack corpus): each character now keeps the marking of the text it came from (new internal lib/src/types/marking.dart). This covers:
    • string indexing, first, last, reverse, list and for over a string;
    • split and rsplit pieces, strip with characters, truncate, str();
    • ~, + and format with a list or dict, and string, upper, trim and the like on a list or dict;
    • replace, indent and format on partly-input text, and input dict keys in for.
  • Member access on undefined prints <function NAME> instead of undefined #33, member access on undefined: this now matches llama.cpp.
    • A subscript of undefined is undefined.
    • A dot member is a function only for the 28 names llama.cpp gives undefined. Such a function returns a typed empty value and prints nothing; any other member is undefined.
    • A number subscript of none throws.
    • Real templates that change, all as in llama.cpp: Devstral Small 2507 (below); Command R+ tool use renders an object parameter without additionalProperties as Dict[str, Union[]] (was Dict[str, Any]); QwQ-32B throws Call to non-function: Undefined for an assistant tool-call turn without content before the last turn.

#33 behavior

Template (y undefined, x = none) dinja before dinja after llama.cpp 7fe450e19 Jinja2 3.1.6
{{ y.text }} <function text> `` `` UndefinedError
{{ y[0]['text'] }} <function text> `` `` UndefinedError
{{ y.text is defined }} True False False UndefinedError
{{ y['text'] is defined }} True False False UndefinedError
{{ y.upper }} <function upper> `` `` UndefinedError
{{ y.length() }} `` 0 0 UndefinedError
{{ y.default(1) }} `` 1 1 UndefinedError
{{ y.get() }} `` throws throws (Callee is not a function) UndefinedError
{{ m.content.text }} (no content) <function text> `` `` UndefinedError
{{ x[0] }} `` throws Cannot access property with non-string: got Integer same ``
{{ x[0]['text'] }} <function text> throws, as above same UndefinedError
{{ x.text }} `` `` `` ``

How rendered output is tracked

Each render keeps its own record, dropped when it ends: the strings that are rendered output or were made from it or from safe text, and the values the caller supplied (input-marked strings and functions in the render values, and what those functions return). A supplied value is never marked, and nothing carries over to another render. Tracking runs only when the caller supplied input-marked text or a function, the only sources of input-marked text.

Cost (indicative only: the Mac is shared and loaded): rendering the 4785 real-template cases 5 times, main 826 ms, this branch 1103 ms without input and 1339 ms with input; 1249 ms without input but with a function in the values, where tracking runs. Of the 1103 ms, 7751c6e (output model and marking) already took about 985 ms.

Deviations from llama.cpp

llama.cpp's input marking only steers special-token parsing and it never escapes, so each of these is dinja's escaping contract:

  • tojson and join keep the marking; llama.cpp drops it (value.cpp:235-262, value.cpp:1017).
  • s[i], split/rsplit pieces, first/last, string iteration and the other operations listed above keep the marking; llama.cpp drops it.
  • replace and indent mark by origin. llama.cpp's mark_input_based_on marks the whole result as input when all of the source is. So x | replace('b', '<i>') is now &lt;<i>&gt; (was &lt;&lt;i&gt;&gt;), and x | indent('> ', true) no longer escapes > .
  • safe on rendered output escapes the input in it, as Jinja2 with autoescape does.
  • A block whose output mixes input with | safe input is escaped before a filter runs, so the filter sees escaped text: {% filter upper %}{{ x }}{{ x | safe }}{% endfilter %} gives &LT;B&GT;<B>, as in main and Jinja2 with autoescape.

Deviation from Jinja2 with autoescape: filters on rendered output see the input unescaped (#30), so {% set c %}{{ x }}{% endset %}{{ c | upper | safe }} gives &lt;B&gt; where Jinja2 gives &LT;B&GT;. Apart from | safe on a value passed to render or returned by a function passed to it, no input comes out unescaped.

Verification

All of the following was run at this head.

  • Unmarked output identical to main (no JinjaString.user input): text, parts and safe flag are compared.

    • Real templates: 87 distinct templates from dinja fixtures, llamadart test/fixtures and tool/litert_lm_templates, and llama.cpp 7fe450e19 models/templates, with 11 conversations (tools, tool calls, tool results, reasoning, null and list content), 3 variants each, tools on and off: 4785 renders, 4775 identical. The 10 others are Devstral Small 2507 with assistant content: None: main printed <function text>, this branch throws Cannot access property with non-string: got Integer, as llama.cpp does. llamadart passes "" there since llamadart#715.
    • Randomized: 32,000 renders, all identical except 452 that access an undefined name (Member access on undefined prints <function NAME> instead of undefined #33). 330 now equal llama.cpp; 121 differ for reasons that predate this PR (llama.cpp raises on first/list/truncate of a string, on a function used as a string and on is escaped, and prints lists differently); 1 goes from an error to [] where llama.cpp errors on list of a string.
    • The independent audit's 10,465-case unmarked corpus: identical to main.
  • Attack corpus: 20,000 random templates over filters, tests, ~, loops, macros, caller, filter and set blocks, tojson/join/map, format and string methods, with nested structures and input dict keys, in 3 marking modes (60,000 renders), plus 36,000 renders with <T&'> in the template text.

    Detector main this branch
    raw </> from input 5413 12
    unescaped & from input 4848 0
    escaped twice / mangled 733 0
    template text escaped 62 0
    • The 12 remaining hits are engine text such as <function upper> split by a filter, not input.
    • Renders where this branch outputs </> from input and main does not: 112, all from x | safe or y | safe on a value, the intended opt-out that main escaped by mistake.
  • Real templates with marked input: main output raw user text in 395 renders across 49 templates; this branch in 0.

  • Tests: 86 new tests fail on main. test/src/input_escaping_test.dart checks 43 expressions in 20 wrappers, including safe on rendered output, for "input escaped exactly once" and "template text never escaped", and per-render tracking: input from a supplied function (direct, nested, or stored in a supplied map), input nested in the values, dict keys, int * str, supplied values and function results never marked, values made before a pop never marked, no state between renders (including const strings), and a fresh, restored state for a render inside a function. The llama.cpp and Jinja2 outputs cited in test comments were rerun against both references.

  • Mutations: 110 mutants: this PR's, both audits' (61 and 20), and new ones for per-render tracking. 108 killed on the VM; _retext dropping per-part marking changes output only where a case change changes the length, which the Dart VM never does, and the Chrome lane kills it. One is equivalent: forcing tracking on when the caller supplied no input and no function only costs time.

  • Gates: dart format 0 changed, dart analyze --fatal-infos clean, VM 1178 pass (11 skipped), Chrome JS and Wasm (test/src, test/llama_cross_test.dart) 1076 pass each (11 skipped), dart pub publish --dry-run 0 warnings, pana 160/160. README snippets and all three examples give the same output as main.

  • Consumer: llamadart main 1da81cb85 with dependency_overrides to this branch: test/unit/core/template and test/integration/core give 1034 pass and 74 skipped, the same as with dinja main. At db7b45881, before llamadart#721 changed that template, the typed content parts golden in jinja_analyzer_dinja_coupling_test.dart changes supports_string_content from false to true, which is what llama.cpp 7fe450e19 caps_get gives for it; llamadart updates the golden on adoption.

Independent audit

A fresh Claude subagent that took no part in the implementation ran three rounds. It audited 7751c6e (BLOCKING: safe on captured output, 9 untested fixes, format parts), then 12e9bf4 (BLOCKING: callable input bypassed tracking, global Expando state, 2 untested paths). The final round at 3d024b1 against main 9fadf00 found NO BLOCKERS:

  • All earlier blockers are fixed.
  • It found no other way for input to enter a render, and no case where rendered output and supplied values get mixed up.
  • Unmarked output is identical to main, both on 10,465 corpus cases and on 5,940 renders over 79 real templates. The only exceptions are the documented Member access on undefined prints <function NAME> instead of undefined #33 changes (Command R+, QwQ-32B, Devstral).
  • 26 of 29 mutants are killed. The 3 survivors are equivalent tracking-gate mutants that change only the running time.
  • Every changed claim held when executed.
  • The llamadart consumer at 1da81cb85 gives the same results with main and with this head.

Non-blocking findings:

  • A function passed to render that transforms captured output is trusted. up(c)|safe gives <I><B>, where Jinja2 AE gives <I>&LT;B&GT;. This falls under the documented exception, and the docs should say so in one line.
  • Unmarked renders cost about 46 µs more each (measured on a loaded machine). llamadart's capability probes pass functions, so they pay the tracking cost too. Re-measure on an unloaded host before the release.

@leehack
leehack marked this pull request as ready for review September 26, 2026 15:41
@leehack
leehack merged commit c470290 into main Sep 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant