feat(builder): safely interpret bounded native Template text

This commit is contained in:
Charles Yao
2026-10-12 16:11:10 -07:00
parent 4c2d7e9e3f
commit 4804bad018
7 changed files with 388 additions and 8 deletions
+30 -4
View File
@@ -518,14 +518,26 @@ def admitted_node_bindings(snapshot: RestrictedAdmissionSnapshot) -> tuple[Admit
from graphon.nodes.end.end_node import EndNode
from graphon.nodes.http_request.node import HttpRequestNode
from graphon.nodes.start.start_node import StartNode
from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode
from services.dify_builder.template_execution import validate_template_configuration, validate_template_source
admit_raw_execution_metadata(snapshot)
graph = snapshot.graph
canonical_digest(graph)
nodes, edges = graph.get("nodes"), graph.get("edges")
if not isinstance(nodes, list) or not 2 <= len(nodes) <= 128 or not isinstance(edges, list):
raise BuilderExecutionPolicyError("unsupported_graph")
audited = {"start": StartNode, "end": EndNode, "http-request": HttpRequestNode}
# Source limits precede the existing graph digest's UTF-8 allocation.
for node in nodes:
if isinstance(node, dict) and isinstance(node.get("data"), dict):
if node["data"].get("type") == "template-transform":
validate_template_source(node["data"].get("template"))
canonical_digest(graph)
audited = {
"start": StartNode,
"end": EndNode,
"http-request": HttpRequestNode,
"template-transform": TemplateTransformNode,
}
bindings = []
types: dict[str, str] = {}
raw_data: dict[str, dict[str, Any]] = {}
@@ -548,6 +560,8 @@ def admitted_node_bindings(snapshot: RestrictedAdmissionSnapshot) -> tuple[Admit
raise BuilderExecutionPolicyError("unsupported_node_implementation")
if node_type == "http-request":
validate_restricted_http_defaults(data)
elif node_type == "template-transform":
validate_template_configuration(data)
try:
cls.validate_node_data(data)
except (ValidationError, ValueError, TypeError):
@@ -604,17 +618,29 @@ def admitted_node_bindings(snapshot: RestrictedAdmissionSnapshot) -> tuple[Admit
raise BuilderExecutionPolicyError("unsupported_start_input")
start_outputs = {v.get("variable") for v in variables}
def selector_allowed(node_id: str, selector: Any) -> bool:
def selector_allowed(node_id: str, selector: Any, *, template_input: bool = False) -> bool:
if (
not isinstance(selector, (list, tuple))
or len(selector) != 2
or not isinstance(selector[0], str)
or not isinstance(selector[1], str)
or selector[0] not in upstream[node_id]
):
return False
return selector[1] in (start_outputs if types[selector[0]] == "start" else {"body", "status_code", "headers"})
outputs = {
"start": start_outputs,
"http-request": {"body"} if template_input else {"body", "status_code", "headers"},
"template-transform": {"output"},
}
return selector[1] in outputs[types[selector[0]]]
for node_id, data in raw_data.items():
if types[node_id] == "template-transform":
if any(
not selector_allowed(node_id, variable["value_selector"], template_input=True)
for variable in data["variables"]
):
raise BuilderExecutionPolicyError("unsupported_template_selector")
if types[node_id] == "http-request":
auth = data.get("authorization")
if (
+6 -1
View File
@@ -2,6 +2,7 @@
from core.dify_builder.contract import TestResultCard
from core.dify_builder.models import PublicationDecision, Run
from services.dify_builder.execution_messages import FALLBACK, execution_refusal_message
REVIEW_NOTE = "Other paths and goal acceptance were not verified."
SUCCESS_REPLY = "Execution succeeded; output needs review."
@@ -148,8 +149,12 @@ def result_card(run: Run) -> TestResultCard:
if is_execution_policy_blocker(run):
card.outcome = "execution_unknown"
card.failure_reason = (
"Execution safety requires review or revised test inputs; automatic repair and publication are blocked."
execution_refusal_message(run.execution_refusal.reason_code) if run.execution_refusal else FALLBACK
)
if run.execution_refusal is not None:
card.terminal_outputs = None
if not run.dify_run_id:
card.executed_node_ids = []
return card
if run.status == "succeeded" and has_output_blocker(run):
card.status = "failed"
+11 -1
View File
@@ -98,6 +98,7 @@ from graphon.nodes.start.start_node import StartNode
from graphon.nodes.tool.tool_node import ToolNode
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
from models.model import Conversation
from services.dify_builder.template_execution import MAX_OUTPUT_BYTES, BoundedJinja2TemplateRenderer
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
@@ -600,6 +601,11 @@ class DifyNodeFactory(NodeFactory):
"file_manager": files,
"file_reference_factory": files,
}
elif node_data.type == BuiltinNodeTypes.TEMPLATE_TRANSFORM:
kwargs = {
"jinja2_template_renderer": BoundedJinja2TemplateRenderer(),
"max_output_length": MAX_OUTPUT_BYTES,
}
return node_class(
node_id=node_id,
data=resolved_node_data.model_dump(mode="python", by_alias=True),
@@ -746,7 +752,11 @@ class DifyNodeFactory(NodeFactory):
if not isinstance(data, Mapping) or not isinstance(node_id, str):
raise BuilderExecutionPolicyError("invalid_node_configuration")
node_type, version = data.get("type"), data.get("version", "1")
if not isinstance(node_type, str) or node_type not in {"start", "end", "http-request"} or version != "1":
if (
not isinstance(node_type, str)
or node_type not in {"start", "end", "http-request", "template-transform"}
or version != "1"
):
raise BuilderExecutionPolicyError("unsupported_node_implementation")
if node_class is None:
node_class = self._resolve_node_class(node_type=node_type, node_version=version, node_data=data)
+18 -1
View File
@@ -27,8 +27,16 @@ from graphon.nodes.end.end_node import EndNode
from graphon.nodes.http_request.entities import HttpRequestNodeConfig, HttpRequestNodeData
from graphon.nodes.http_request.node import HttpRequestNode
from graphon.nodes.start.start_node import StartNode
from graphon.nodes.template_transform.entities import TemplateTransformNodeData
from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode
from services.dify_builder.template_execution import validate_template_configuration, validate_template_source
_AUDITED_CLASSES = {"start": StartNode, "end": EndNode, "http-request": HttpRequestNode}
_AUDITED_CLASSES = {
"start": StartNode,
"end": EndNode,
"http-request": HttpRequestNode,
"template-transform": TemplateTransformNode,
}
_CONTENT_TYPES = frozenset(
("application/json", "text/plain", "application/json; charset=utf-8", "text/plain; charset=utf-8")
)
@@ -58,6 +66,9 @@ def validate_restricted_raw_node(
or node_class.version() != "1"
):
raise BuilderExecutionPolicyError("unsupported_node_implementation")
if node_class is TemplateTransformNode:
# Refuse oversized drift before canonicalizing the raw binding.
validate_template_source(raw_node_data.get("template"))
binding = next((binding for binding in context.admitted_nodes if binding.node_id == node_id), None)
if (
binding is None
@@ -66,6 +77,8 @@ def validate_restricted_raw_node(
or binding.normalized_config_digest != canonical_digest({**raw_node_data, "version": "1"})
):
raise BuilderExecutionPolicyError("execution_binding_mismatch")
if node_class is TemplateTransformNode:
validate_template_configuration(raw_node_data)
def validate_restricted_node(
@@ -82,6 +95,10 @@ def validate_restricted_node(
or resolved_node_data.version != "1"
):
raise BuilderExecutionPolicyError("unsupported_node_implementation")
if node_class is TemplateTransformNode:
if not isinstance(resolved_node_data, TemplateTransformNodeData):
raise BuilderExecutionPolicyError("invalid_node_configuration")
validate_template_configuration(resolved_node_data.model_dump(mode="python"))
if node_class is HttpRequestNode:
if not isinstance(resolved_node_data, HttpRequestNodeData):
raise BuilderExecutionPolicyError("invalid_node_configuration")
+2 -1
View File
@@ -16,6 +16,7 @@ from collections.abc import Callable
from core.dify_builder import strings
from core.dify_builder.models import ConversationItem
from services.dify_builder.agent import llm
from services.dify_builder.execution_messages import STATIC_COPY as EXECUTION_COPY
from services.dify_builder.repair_gate import STATIC_COPY
logger = logging.getLogger(__name__)
@@ -41,7 +42,7 @@ _LANG_CODE_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$")
# concurrent duplicate translation of the same key is harmless and idempotent
# -- no lock needed.
_TRANSLATION_CACHE: dict[tuple[str, str], str] = {}
_PLAIN = strings.PLAIN | STATIC_COPY
_PLAIN = strings.PLAIN | STATIC_COPY | EXECUTION_COPY
class Localizer:
@@ -0,0 +1,28 @@
"""Finite sanitized execution-refusal copy; no graph/input/error interpolation."""
FALLBACK = "Execution safety requires review or revised test inputs; automatic repair and publication are blocked."
_BLOCKED = " Automatic repair and publication are blocked."
_MESSAGES = {
"unsupported_node_implementation": (
"This node is not supported by safe testing. Edit the workflow to use supported nodes; the test did not run."
+ _BLOCKED
),
"unsupported_template_syntax": (
"Template supports scalar text, if/elif/else, and trim. Edit unsupported syntax; the test did not run."
+ _BLOCKED
),
"unsupported_template_configuration": (
"Edit Template variables to use unique legal names and complete selectors; the test did not run." + _BLOCKED
),
"unsupported_template_selector": (
"Use Template inputs from Start scalars, earlier Template output or HTTP body; the test did not run." + _BLOCKED
),
"template_execution_limit": (
"Reduce Template size or selected text to fit safe testing limits; the test did not run." + _BLOCKED
),
}
STATIC_COPY = frozenset({FALLBACK, *_MESSAGES.values()})
def execution_refusal_message(reason_code: str) -> str:
return _MESSAGES.get(reason_code, FALLBACK)
@@ -0,0 +1,293 @@
"""Pure, finite text interpretation for Builder's existing native Template node.
Jinja supplies only its lexer and parser. No compilation, rendering, globals,
extensions or effectful collaborator is used. Admission inspects every branch;
the native adapter repeats inspection and bounds all selected values and output.
"""
import math
import re
from collections.abc import Iterator, Mapping
from typing import Any, NoReturn, override
from jinja2 import Environment, nodes
from core.dify_builder.execution_policy import BuilderExecutionPolicyError
from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderError
MAX_SOURCE_BYTES = 16384
MAX_TOKENS = 512
MAX_AST_NODES = 256
MAX_AST_DEPTH = 16
MAX_VARIABLES = 32
MAX_TEXT_BYTES = 65536
MAX_SELECTED_BYTES = 131072
MAX_OUTPUT_BYTES = 65536
MAX_VISITS = 512
MAX_INTEGER = 2**53 - 1
Scalar = str | bool | int | float | None
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}\Z", re.ASCII)
_FIELDS = {
nodes.Template: ("body",),
nodes.Output: ("nodes",),
nodes.TemplateData: ("data",),
nodes.If: ("test", "body", "elif_", "else_"),
nodes.Name: ("name", "ctx"),
nodes.Const: ("value",),
nodes.Filter: ("node", "name", "args", "kwargs", "dyn_args", "dyn_kwargs"),
nodes.And: ("left", "right"),
nodes.Or: ("left", "right"),
nodes.Not: ("node",),
}
def _refuse(reason: str = "unsupported_template_syntax") -> NoReturn:
raise BuilderExecutionPolicyError(reason)
def _text_bytes(value: object, limit: int) -> int:
"""Bound character count before allocating any UTF-8 representation."""
if type(value) is not str:
_refuse("unsupported_template_configuration")
if len(value) > limit:
_refuse("template_execution_limit")
try:
size = len(value.encode("utf-8"))
except UnicodeError:
_refuse("unsupported_template_configuration")
if size > limit:
_refuse("template_execution_limit")
return size
def validate_template_source(template: object) -> None:
"""Cheap preflight, including before an existing caller's graph digest."""
_text_bytes(template, MAX_SOURCE_BYTES)
def _names(values: object) -> frozenset[str]:
if not isinstance(values, (list, tuple)) or len(values) > MAX_VARIABLES:
_refuse("unsupported_template_configuration")
names: set[str] = set()
for name in values:
if type(name) is not str or not _NAME.fullmatch(name) or name in names:
_refuse("unsupported_template_configuration")
names.add(name)
return frozenset(names)
def _scalar(value: object) -> Scalar:
"""Exact built-ins only: no custom conversion/truthiness can execute."""
if value is None or type(value) is bool:
return value
if type(value) is str:
_text_bytes(value, MAX_TEXT_BYTES)
return value
if type(value) is int:
if not -MAX_INTEGER <= value <= MAX_INTEGER:
_refuse("template_execution_limit")
return value
if type(value) is float and math.isfinite(value):
return value
_refuse("unsupported_template_configuration")
def _scalar_text(value: Scalar) -> str:
# Values are checked before this conversion, including scalar constants.
return value if type(value) is str else str(value)
def _children(node: nodes.Node, declared: frozenset[str]) -> Iterator[nodes.Node]:
"""Validate every field, yielding only the reviewed structural children."""
kind = type(node)
if kind not in _FIELDS or node.fields != _FIELDS[kind]:
_refuse()
if kind is nodes.Name:
if node.ctx != "load" or node.name not in declared:
_refuse()
return
if kind is nodes.Const:
try:
_scalar(node.value)
except BuilderExecutionPolicyError:
_refuse()
return
if kind is nodes.TemplateData:
_text_bytes(node.data, MAX_SOURCE_BYTES)
return
if kind is nodes.Filter:
if node.name != "trim" or node.args or node.kwargs or node.dyn_args is not None or node.dyn_kwargs is not None:
_refuse()
if node.node is None:
_refuse()
yield node.node
return
for field in _FIELDS[kind]:
value = getattr(node, field)
if isinstance(value, nodes.Node):
yield value
elif type(value) is list:
for child in value:
if not isinstance(child, nodes.Node):
_refuse()
yield child
else:
_refuse()
def inspect_template(template: str, declared: frozenset[str]) -> nodes.Template:
"""Refuse before parsing on source/token bounds, then inspect iteratively."""
validate_template_source(template)
try:
environment = Environment()
for count, _token in enumerate(environment.lex(template), 1):
if count > MAX_TOKENS:
_refuse("template_execution_limit")
parsed = environment.parse(template)
pending: list[tuple[nodes.Node, int]] = [(parsed, 0)]
visited = 0
while pending:
node, depth = pending.pop()
visited += 1
if visited > MAX_AST_NODES or depth > MAX_AST_DEPTH:
_refuse("template_execution_limit")
for child in _children(node, declared):
# Bound the work stack as well as total visited nodes.
if visited + len(pending) >= MAX_AST_NODES:
_refuse("template_execution_limit")
pending.append((child, depth + 1))
return parsed
except BuilderExecutionPolicyError:
raise
except Exception:
# Parser/lexer/AST failures, including recursion/overflow, never echo source.
_refuse()
def validate_template_configuration(data: Mapping[str, Any]) -> None:
"""Raw names/selectors must be safe before permissive native DTO parsing."""
template, variables = data.get("template"), data.get("variables")
validate_template_source(template)
# A render failure must remain a native failure, not a default-value branch.
if data.get("error_strategy") not in (None, "") or data.get("default_value") not in (None, []):
_refuse("unsupported_template_configuration")
if data.get("default_values") not in (None, []):
_refuse("unsupported_template_configuration")
retry = data.get("retry_config")
if retry is not None and (
type(retry) is not dict
or set(retry) - {"retry_enabled", "max_retries", "retry_interval"}
or retry.get("retry_enabled", False) is not False
or any(
type(retry.get(key, 0)) is not int or not 0 <= retry.get(key, 0) <= MAX_INTEGER
for key in ("max_retries", "retry_interval")
)
):
_refuse("unsupported_template_configuration")
if type(variables) is not list or len(variables) > MAX_VARIABLES:
_refuse("unsupported_template_configuration")
names = []
for variable in variables:
if type(variable) is not dict or set(variable) != {"variable", "value_selector"}:
_refuse("unsupported_template_configuration")
selector = variable["value_selector"]
if (
not isinstance(selector, (list, tuple))
or len(selector) != 2
or any(type(part) is not str or not part for part in selector)
):
_refuse("unsupported_template_selector")
names.append(variable["variable"])
inspect_template(template, _names(names))
class _Evaluation:
"""One bounded invocation, with cumulative append and visit accounting."""
def __init__(self, values: dict[str, Scalar]) -> None:
self.values = values
self.visits = 0
self.output_bytes = 0
self.fragments: list[str] = []
def _visit(self) -> None:
if self.visits >= MAX_VISITS:
_refuse("template_execution_limit")
self.visits += 1
def expression(self, node: nodes.Node) -> Scalar:
self._visit()
kind = type(node)
if kind is nodes.Name:
return self.values[node.name]
if kind is nodes.Const:
return node.value
if kind is nodes.TemplateData:
return node.data
if kind is nodes.Filter:
return _scalar_text(self.expression(node.node)).strip()
if kind is nodes.Not:
return not self.expression(node.node)
if kind is nodes.And:
left = self.expression(node.left)
return self.expression(node.right) if left else left
if kind is nodes.Or:
left = self.expression(node.left)
return left or self.expression(node.right)
_refuse()
def statement(self, node: nodes.Node) -> None:
self._visit()
kind = type(node)
if kind is nodes.Template:
for child in node.body:
self.statement(child)
elif kind is nodes.Output:
for expression in node.nodes:
text = _scalar_text(self.expression(expression))
size = _text_bytes(text, MAX_OUTPUT_BYTES - self.output_bytes)
self.output_bytes += size
self.fragments.append(text)
elif kind is nodes.If:
branch = node.else_
if self.expression(node.test):
branch = node.body
else:
for alternative in node.elif_:
self._visit()
if self.expression(alternative.test):
branch = alternative.body
break
for child in branch:
self.statement(child)
else:
_refuse()
class BoundedJinja2TemplateRenderer(Jinja2TemplateRenderer):
"""Native nominal adapter; never delegates to ordinary Jinja rendering."""
@override
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
try:
if type(variables) is not dict or len(variables) > MAX_VARIABLES:
_refuse("unsupported_template_configuration")
declared = _names(tuple(variables))
values: dict[str, Scalar] = {}
selected_bytes = 0
for name, raw in variables.items():
value = _scalar(raw)
text = _scalar_text(value)
selected_bytes += _text_bytes(text, MAX_SELECTED_BYTES - selected_bytes)
values[name] = value
parsed = inspect_template(template, declared)
evaluation = _Evaluation(values)
evaluation.statement(parsed)
return "".join(evaluation.fragments)
except BuilderExecutionPolicyError as error:
if error.reason_code == "template_execution_limit":
raise TemplateRenderError("Template rendering exceeded safe testing size limits.") from None
raise TemplateRenderError("Template rendering refused unsupported syntax or scalar inputs.") from None
except Exception:
raise TemplateRenderError("Template rendering failed safely; review the template and inputs.") from None