Skip to content

Commit c8c3e33

Browse files
masciclaude
andcommitted
fix: only parse marked output as chat messages
Prompt.chat_messages() parsed every line of the rendered template as a possible ChatMessage, so template data that rendered to message JSON was returned as a privileged system, assistant or tool message. Each Prompt now carries a secret sentinel in its render context. The chat tag, the tool filter and the media filters mark everything they emit with it, and the parsers accept only marked lines and markers; anything else stays plain text. The sentinel is per instance rather than per render because the render cache keys on the context, so a sentinel that changed between renders would make cached text unparseable. text() strips it, so its output is unchanged and the marker cannot leak to whoever supplies the data. The same round trip through rendered text also affected CompletionExtension._body_to_messages(), where data could inject tool definitions as well as messages, and chat_message_from_text(), where data could inject content blocks such as remote image URLs through the no-chat-block fallback. Both are covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ae351d7 commit c8c3e33

22 files changed

Lines changed: 439 additions & 234 deletions

src/banks/env.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .config import config
88
from .filters import audio, cache_control, document, image, lemmatize, tool, video, xml
9+
from .utils import ensure_environment_sentinel
910

1011

1112
def _add_extensions(_env):
@@ -43,4 +44,8 @@ def _add_extensions(_env):
4344
env.filters["document"] = document
4445
env.filters["to_xml"] = xml
4546

47+
# Fallback for templates rendered straight off `env` instead of through a `Prompt`,
48+
# which would otherwise carry no sentinel and produce no parseable messages.
49+
ensure_environment_sentinel(env)
50+
4651
_add_extensions(env)

src/banks/extensions/chat.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from jinja2.ext import Extension
77

88
from banks.types import chat_message_from_text
9+
from banks.utils import ensure_environment_sentinel, sentinel_from_context
910

1011
SUPPORTED_TYPES = ("system", "user", "assistant")
1112

@@ -25,6 +26,10 @@ class ChatExtension(Extension):
2526
# a set of names that trigger the extension.
2627
tags = {"chat"} # noqa
2728

29+
def __init__(self, environment):
30+
super().__init__(environment)
31+
ensure_environment_sentinel(environment)
32+
2833
def parse(self, parser):
2934
# We get the line number of the first token for error reporting
3035
lineno = next(parser.stream).lineno
@@ -54,18 +59,22 @@ def parse(self, parser):
5459
msg = f"Unknown role type '{attr_value.value}', use one of ({types})"
5560
raise TemplateSyntaxError(msg, lineno)
5661

57-
# Pass the role name to the CallBlock node
58-
args: list[nodes.Expr] = [nodes.Const(attr_value.value)]
62+
# Pass the render context and the role name to the CallBlock node
63+
args: list[nodes.Expr] = [nodes.ContextReference(), nodes.Const(attr_value.value)]
5964

6065
# Message body
6166
body = parser.parse_statements(("name:endchat",), drop_needle=True)
6267

6368
# Build messages list
6469
return nodes.CallBlock(self.call_method("_store_chat_messages", args), [], [], body).set_lineno(lineno)
6570

66-
def _store_chat_messages(self, role, caller):
71+
def _store_chat_messages(self, context, role, caller):
6772
"""
6873
Helper callback.
6974
"""
70-
cm = chat_message_from_text(role=role, content=caller())
71-
return cm.model_dump_json(exclude_none=True) + "\n"
75+
sentinel = sentinel_from_context(context)
76+
cm = chat_message_from_text(role=role, content=caller(), sentinel=sentinel)
77+
# The sentinel marks this line as coming from a `chat` tag, so that template data
78+
# rendering to a JSON message can't pick its own role. `model_dump_json` escapes
79+
# newlines, so the content can't break out onto a line of its own either.
80+
return sentinel + cm.model_dump_json(exclude_none=True) + "\n"

src/banks/extensions/completion.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from banks.errors import InvalidPromptError, LLMError
1212
from banks.types import ChatMessage, Tool
13+
from banks.utils import ensure_environment_sentinel, sentinel_from_context
1314

1415
if TYPE_CHECKING:
1516
from litellm.types.utils import ChatCompletionMessageToolCall
@@ -44,6 +45,10 @@ class CompletionExtension(Extension):
4445
# Resolution never goes through importlib — only callables registered here are invocable.
4546
_callable_registry: ClassVar[dict[str, Callable[..., Any]]] = {}
4647

48+
def __init__(self, environment):
49+
super().__init__(environment)
50+
ensure_environment_sentinel(environment)
51+
4752
@classmethod
4853
def register_callable(cls, name: str, func: Callable[..., Any]) -> None:
4954
cls._callable_registry[name] = func
@@ -72,8 +77,8 @@ def parse(self, parser):
7277
if attr_name.value not in SUPPORTED_KWARGS or attr_assign.value != "=":
7378
raise TemplateSyntaxError(error_msg, lineno)
7479

75-
# Pass the role name to the CallBlock node
76-
args: list[nodes.Expr] = [nodes.Const(attr_value.value)]
80+
# Pass the render context and the model name to the CallBlock node
81+
args: list[nodes.Expr] = [nodes.ContextReference(), nodes.Const(attr_value.value)]
7782

7883
# Message body
7984
body = parser.parse_statements(("name:endcompletion",), drop_needle=True)
@@ -105,7 +110,7 @@ def _get_tool_callable(self, tools: list[Tool], tool_call: "ChatCompletionMessag
105110
raise ValueError(msg)
106111
return self._callable_registry[name]
107112

108-
def _do_completion(self, model_name, caller):
113+
def _do_completion(self, context, model_name, caller):
109114
"""
110115
Helper callback.
111116
"""
@@ -115,7 +120,7 @@ def _do_completion(self, model_name, caller):
115120
except ImportError as e:
116121
raise ImportError(LITELLM_INSTALL_MSG) from e
117122

118-
messages, tools = self._body_to_messages(caller())
123+
messages, tools = self._body_to_messages(caller(), sentinel_from_context(context))
119124
message_dicts = [m.model_dump() for m in messages]
120125
tool_dicts = [t.model_dump(exclude={"import_path"}) for t in tools] or None
121126

@@ -145,7 +150,7 @@ def _do_completion(self, model_name, caller):
145150
choices = cast(list[Choices], response.choices)
146151
return choices[0].message.content
147152

148-
async def _do_completion_async(self, model_name, caller):
153+
async def _do_completion_async(self, context, model_name, caller):
149154
"""
150155
Helper callback.
151156
"""
@@ -155,7 +160,7 @@ async def _do_completion_async(self, model_name, caller):
155160
except ImportError as e:
156161
raise ImportError(LITELLM_INSTALL_MSG) from e
157162

158-
messages, tools = self._body_to_messages(caller())
163+
messages, tools = self._body_to_messages(caller(), sentinel_from_context(context))
159164
message_dicts = [m.model_dump() for m in messages]
160165
tool_dicts = [t.model_dump(exclude={"import_path"}) for t in tools] or None
161166

@@ -186,19 +191,27 @@ async def _do_completion_async(self, model_name, caller):
186191
choices = cast(list[Choices], response.choices)
187192
return choices[0].message.content
188193

189-
def _body_to_messages(self, body: str) -> tuple[list[ChatMessage], list[Tool]]:
190-
"""Converts each line in the body of a block into a chat message."""
194+
def _body_to_messages(self, body: str, sentinel: str) -> tuple[list[ChatMessage], list[Tool]]:
195+
"""Converts each line in the body of a block into a chat message.
196+
197+
Only lines marked with the render sentinel are parsed: they are the ones the `chat`
198+
tag and the `tool` filter produced. Unmarked lines are template data, which must not
199+
be able to declare its own message role or register a tool.
200+
"""
191201
body = body.strip()
192202
messages = []
193203
tools = []
194204
for line in body.split("\n"):
205+
if not line.startswith(sentinel):
206+
continue
207+
payload = line.removeprefix(sentinel)
195208
try:
196209
# Try to parse a chat message
197-
messages.append(ChatMessage.model_validate_json(line))
210+
messages.append(ChatMessage.model_validate_json(payload))
198211
except ValidationError: # pylint: disable=R0801
199212
try:
200213
# If not a chat message, try to parse a tool
201-
tools.append(Tool.model_validate_json(line))
214+
tools.append(Tool.model_validate_json(payload))
202215
except ValidationError:
203216
# Give up
204217
pass

src/banks/extensions/docs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ def chat(role: str): # pylint: disable=W0613
66
Text inside `chat` tags will be rendered as JSON strings representing chat messages. Calling `Prompt.chat_messages`
77
will return a list of `ChatMessage` instances.
88
9+
Only `chat` tags produce messages: template data that happens to render to message JSON is returned as plain
10+
text instead, so untrusted input cannot pick its own role.
11+
912
Example:
1013
```jinja
1114
{% chat role="system" %}

src/banks/filters/audio.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from urllib.parse import urlparse
1010

1111
import filetype # type: ignore[import-untyped]
12+
from jinja2 import pass_context
1213

13-
from banks.types import AudioFormat, ContentBlock, InputAudio, resolve_binary
14+
from banks.types import CONTENT_BLOCK_END, AudioFormat, ContentBlock, InputAudio, content_block_start, resolve_binary
15+
from banks.utils import sentinel_from_context
1416

1517
BASE64_AUDIO_REGEX = re.compile(r"audio\/.*;base64,.*")
1618

@@ -53,7 +55,8 @@ def _get_audio_format_from_bytes(data: bytes) -> AudioFormat:
5355
return "mp3"
5456

5557

56-
def audio(value: str | bytes) -> str:
58+
@pass_context
59+
def audio(context, value: str | bytes) -> str:
5760
"""Wrap the filtered value into a ContentBlock of type audio.
5861
5962
The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -75,4 +78,4 @@ def audio(value: str | bytes) -> str:
7578
else:
7679
input_audio = InputAudio.from_path(Path(value))
7780
block = ContentBlock.model_validate({"type": "audio", "input_audio": input_audio})
78-
return f"<content_block>{block.model_dump_json()}</content_block>"
81+
return f"{content_block_start(sentinel_from_context(context))}{block.model_dump_json()}{CONTENT_BLOCK_END}"

src/banks/filters/cache_control.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
# SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
22
#
33
# SPDX-License-Identifier: MIT
4-
from banks.types import ContentBlock
4+
from jinja2 import pass_context
55

6+
from banks.types import CONTENT_BLOCK_END, ContentBlock, content_block_start
7+
from banks.utils import sentinel_from_context
68

7-
def cache_control(value: str, cache_type: str = "ephemeral") -> str:
9+
10+
@pass_context
11+
def cache_control(context, value: str, cache_type: str = "ephemeral") -> str:
812
"""Wrap the filtered value into a ContentBlock with the proper cache_control field set.
913
1014
The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -17,8 +21,8 @@ def cache_control(value: str, cache_type: str = "ephemeral") -> str:
1721
```
1822
1923
Important:
20-
this filter marks the content to cache by surrounding it with `<content_block>` and
21-
`</content_block>`, so it's only useful when used within a `{% chat %}` block.
24+
this filter marks the content to cache by surrounding it with content block markers,
25+
so it's only useful when used within a `{% chat %}` block.
2226
"""
2327
block = ContentBlock.model_validate({"type": "text", "text": value, "cache_control": {"type": cache_type}})
24-
return f"<content_block>{block.model_dump_json()}</content_block>"
28+
return f"{content_block_start(sentinel_from_context(context))}{block.model_dump_json()}{CONTENT_BLOCK_END}"

src/banks/filters/document.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,17 @@
1010
from urllib.parse import urlparse
1111

1212
import filetype # type: ignore[import-untyped]
13-
14-
from banks.types import ContentBlock, DocumentFormat, InputDocument, resolve_binary
13+
from jinja2 import pass_context
14+
15+
from banks.types import (
16+
CONTENT_BLOCK_END,
17+
ContentBlock,
18+
DocumentFormat,
19+
InputDocument,
20+
content_block_start,
21+
resolve_binary,
22+
)
23+
from banks.utils import sentinel_from_context
1524

1625
BASE64_DOCUMENT_REGEX = re.compile(r"(text|application)\/.*;base64,.*")
1726

@@ -112,7 +121,8 @@ def _get_document_format_from_bytes(data: bytes) -> DocumentFormat:
112121
raise ValueError("Unsupported document format: " + kind.extension)
113122

114123

115-
def document(value: str | bytes) -> str:
124+
@pass_context
125+
def document(context, value: str | bytes) -> str:
116126
"""Wrap the filtered value into a ContentBlock of type document.
117127
118128
The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -134,4 +144,4 @@ def document(value: str | bytes) -> str:
134144
else:
135145
input_document = InputDocument.from_path(Path(value))
136146
block = ContentBlock.model_validate({"type": "document", "input_document": input_document})
137-
return f"<content_block>{block.model_dump_json()}</content_block>"
147+
return f"{content_block_start(sentinel_from_context(context))}{block.model_dump_json()}{CONTENT_BLOCK_END}"

src/banks/filters/image.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
from pathlib import Path
88
from urllib.parse import urlparse
99

10-
from banks.types import ContentBlock, ImageUrl
10+
from jinja2 import pass_context
11+
12+
from banks.types import CONTENT_BLOCK_END, ContentBlock, ImageUrl, content_block_start
13+
from banks.utils import sentinel_from_context
1114

1215
BASE64_PATH_REGEX = re.compile(r"image\/.*;base64,.*")
1316

@@ -24,7 +27,8 @@ def _is_url(string: str) -> bool:
2427
return True
2528

2629

27-
def image(value: str | bytes) -> str:
30+
@pass_context
31+
def image(context, value: str | bytes) -> str:
2832
"""Wrap the filtered value into a ContentBlock of type image.
2933
3034
The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -37,8 +41,8 @@ def image(value: str | bytes) -> str:
3741
```
3842
3943
Important:
40-
this filter marks the content to cache by surrounding it with `<content_block>` and
41-
`</content_block>`, so it's only useful when used within a `{% chat %}` block.
44+
this filter marks the content to cache by surrounding it with content block markers,
45+
so it's only useful when used within a `{% chat %}` block.
4246
"""
4347
if isinstance(value, bytes):
4448
image_url = ImageUrl.from_bytes(bytes_str=value)
@@ -48,4 +52,4 @@ def image(value: str | bytes) -> str:
4852
image_url = ImageUrl.from_path(Path(value))
4953

5054
block = ContentBlock.model_validate({"type": "image_url", "image_url": image_url})
51-
return f"<content_block>{block.model_dump_json()}</content_block>"
55+
return f"{content_block_start(sentinel_from_context(context))}{block.model_dump_json()}{CONTENT_BLOCK_END}"

src/banks/filters/tool.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
# SPDX-License-Identifier: MIT
44
from typing import Callable
55

6+
from jinja2 import pass_context
7+
68
from banks.types import Tool
9+
from banks.utils import sentinel_from_context
710

811

9-
def tool(function: Callable) -> str:
12+
@pass_context
13+
def tool(context, function: Callable) -> str:
1014
"""Inspect a Python callable and generates a JSON-schema ready for LLM function calling.
1115
1216
Important:
@@ -16,4 +20,4 @@ def tool(function: Callable) -> str:
1620

1721
t = Tool.from_callable(function)
1822
CompletionExtension.register_callable(function.__name__, function)
19-
return t.model_dump_json() + "\n"
23+
return sentinel_from_context(context) + t.model_dump_json() + "\n"

src/banks/filters/video.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
import filetype # type: ignore[import-untyped]
1212
from filetype.types.video import IsoBmff # type: ignore[import-untyped]
13+
from jinja2 import pass_context
1314

14-
from banks.types import ContentBlock, InputVideo, VideoFormat, resolve_binary
15+
from banks.types import CONTENT_BLOCK_END, ContentBlock, InputVideo, VideoFormat, content_block_start, resolve_binary
16+
from banks.utils import sentinel_from_context
1517

1618
BASE64_VIDEO_REGEX = re.compile(r"video\/.*;base64,.*")
1719

@@ -85,7 +87,8 @@ def _get_video_format_from_bytes(data: bytes) -> VideoFormat:
8587
return "mp4"
8688

8789

88-
def video(value: str | bytes) -> str:
90+
@pass_context
91+
def video(context, value: str | bytes) -> str:
8992
"""Wrap the filtered value into a ContentBlock of type video.
9093
9194
The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -107,4 +110,4 @@ def video(value: str | bytes) -> str:
107110
else:
108111
input_video = InputVideo.from_path(Path(value))
109112
block = ContentBlock.model_validate({"type": "video", "input_video": input_video})
110-
return f"<content_block>{block.model_dump_json()}</content_block>"
113+
return f"{content_block_start(sentinel_from_context(context))}{block.model_dump_json()}{CONTENT_BLOCK_END}"

0 commit comments

Comments
 (0)