fix: keep input marking through filters, tojson and join; undefined member access - #34
Merged
Merged
Conversation
leehack
marked this pull request as ready for review
September 26, 2026 15:41
This was referenced Sep 26, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #30
Closes #31
Closes #33
References: llama.cpp 7fe450e19
common/jinja, built standalone with a render hook, and Jinja2 3.1.6 asSandboxedEnvironment(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
execStatementsjoins output unescaped andProgram.executeescapes once. So{% filter %}, and a filter on blockset, macro orcaller()output, sees the input as passed in, as{{ x | upper }}does. Withx = JinjaString.user('<b>'):{% filter upper %}{{ x }}{% endfilter %}is<B>(was<B>).{% filter length %}…is3(was9), as in llama.cpp.set, macro,caller(),{% filter %}body) is final, as in Jinja2 with autoescape. Sosafeon it, or on text made from it or from other safe text, escapes the input in it.| safeon a value passed torender, 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<b>, as in main and Jinja2 with autoescape.tojsonandjoin: these keep the marking per part. Only input text is escaped, after JSON escaping; that covers values, nested values, dict keys and separators, withensure_asciiandindent. The JSON's own quotes and the template's separators are not escaped.{{ {"k": x} | tojson }}gives{"k": "<b>"}(was{"k": "<b>"}).{{ [x, "<i>"] | join }}gives<b><i>(was<b><i>).tojsonoutput that contains input is no longeris escaped.lib/src/types/marking.dart). This covers:first,last,reverse,listandforover a string;splitandrsplitpieces,stripwith characters,truncate,str();~,+andformatwith a list or dict, andstring,upper,trimand the like on a list or dict;replace,indentandformaton partly-input text, and input dict keys infor.objectparameter withoutadditionalPropertiesasDict[str, Union[]](wasDict[str, Any]); QwQ-32B throwsCall to non-function: Undefinedfor an assistant tool-call turn withoutcontentbefore the last turn.#33 behavior
yundefined,x = none){{ y.text }}<function text>{{ y[0]['text'] }}<function text>{{ y.text is defined }}TrueFalseFalse{{ y['text'] is defined }}TrueFalseFalse{{ y.upper }}<function upper>{{ y.length() }}00{{ y.default(1) }}11{{ y.get() }}Callee is not a function){{ m.content.text }}(nocontent)<function text>{{ x[0] }}Cannot access property with non-string: got Integer{{ x[0]['text'] }}<function text>{{ 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
rendervalues, 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:
tojsonandjoinkeep the marking; llama.cpp drops it (value.cpp:235-262,value.cpp:1017).s[i],split/rsplitpieces,first/last, string iteration and the other operations listed above keep the marking; llama.cpp drops it.replaceandindentmark by origin. llama.cpp'smark_input_based_onmarks the whole result as input when all of the source is. Sox | replace('b', '<i>')is now<<i>>(was<<i>>), andx | indent('> ', true)no longer escapes>.safeon rendered output escapes the input in it, as Jinja2 with autoescape does.| safeinput is escaped before a filter runs, so the filter sees escaped text:{% filter upper %}{{ x }}{{ x | safe }}{% endfilter %}gives<B><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<B>where Jinja2 gives<B>. Apart from| safeon a value passed torenderor 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.userinput): text, parts and safe flag are compared.test/fixturesandtool/litert_lm_templates, and llama.cpp 7fe450e19models/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 assistantcontent: None: main printed<function text>, this branch throwsCannot access property with non-string: got Integer, as llama.cpp does. llamadart passes""there since llamadart#715.first/list/truncateof a string, on a function used as a string and onis escaped, and prints lists differently); 1 goes from an error to[]where llama.cpp errors onlistof a string.Attack corpus: 20,000 random templates over filters, tests,
~, loops, macros,caller, filter and set blocks,tojson/join/map,formatand 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.</>from input&from input<function upper>split by a filter, not input.</>from input and main does not: 112, all fromx | safeory | safeon 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.dartchecks 43 expressions in 20 wrappers, includingsafeon 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 apopnever marked, no state between renders (includingconststrings), 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;
_retextdropping 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 format0 changed,dart analyze --fatal-infosclean, 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-run0 warnings, pana 160/160. README snippets and all three examples give the same output as main.Consumer: llamadart main 1da81cb85 with
dependency_overridesto this branch:test/unit/core/templateandtest/integration/coregive 1034 pass and 74 skipped, the same as with dinja main. At db7b45881, before llamadart#721 changed that template, thetyped content partsgolden injinja_analyzer_dinja_coupling_test.dartchangessupports_string_contentfrom false to true, which is what llama.cpp 7fe450e19caps_getgives 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:
safeon captured output, 9 untested fixes,formatparts), then 12e9bf4 (BLOCKING: callable input bypassed tracking, global Expando state, 2 untested paths). The final round at 3d024b1 against main 9fadf00 found NO BLOCKERS:Non-blocking findings:
renderthat transforms captured output is trusted.up(c)|safegives<I><B>, where Jinja2 AE gives<I><B>. This falls under the documented exception, and the docs should say so in one line.