Files
dify/api/services/dify_builder/restricted_execution.py

351 lines
14 KiB
Python

"""Exact native capabilities for immutable Builder restricted executions.
These collaborators never wrap live transport or file owners. Durable evidence
is appended through the launch's shared recorder before any fixture is returned.
"""
import hashlib
import hmac
import json
import math
from collections.abc import Mapping
from typing import Any, Literal, NoReturn
from uuid import uuid4
from graphon.entities.base_node_data import BaseNodeData
from graphon.http import HttpResponse
from graphon.nodes.base.node import Node
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 runtime_contracts.builder.execution import (
BuilderExecutionContext,
BuilderExecutionObservation,
BuilderExecutionPolicyError,
BuilderExecutionRecorder,
canonical_digest,
)
from services.dify_builder.application.execution_policy import (
validate_restricted_http_defaults,
)
from services.dify_builder.revision import node_config_digest
from services.dify_builder.template_execution import validate_template_configuration, validate_template_source
_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")
)
_MAX_TEXT_BYTES = 65536
# Pure configuration; no deployment/client owner is consulted for fixtures.
RESTRICTED_HTTP_CONFIG = HttpRequestNodeConfig(
max_connect_timeout=10,
max_read_timeout=10,
max_write_timeout=10,
max_binary_size=0,
max_text_size=_MAX_TEXT_BYTES,
ssl_verify=True,
ssrf_default_max_retries=0,
)
def validate_restricted_raw_node(
*, context: BuilderExecutionContext, node_id: str, node_class: type[Node], raw_node_data: Mapping[str, Any]
) -> None:
"""Check raw implementation/config binding before concrete class validation."""
node_type = raw_node_data.get("type")
if (
not isinstance(node_type, str)
or node_class is not _AUDITED_CLASSES.get(node_type)
or raw_node_data.get("version", "1") != "1"
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
or binding.implementation != f"{node_class.__module__}.{node_class.__qualname__}"
or binding.node_version != "1"
or binding.normalized_config_digest != node_config_digest(raw_node_data)
):
raise BuilderExecutionPolicyError("execution_binding_mismatch")
if node_class is TemplateTransformNode:
validate_template_configuration(raw_node_data)
def validate_restricted_node(
*, context: BuilderExecutionContext, node_id: str, node_class: type[Node], resolved_node_data: BaseNodeData
) -> None:
"""Recheck typed text capabilities after raw binding and native validation."""
binding = next((binding for binding in context.admitted_nodes if binding.node_id == node_id), None)
if (
binding is None
or node_class is not _AUDITED_CLASSES.get(resolved_node_data.type)
or binding.implementation != f"{node_class.__module__}.{node_class.__qualname__}"
or binding.node_version != "1"
or node_class.version() != "1"
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")
data = resolved_node_data
if data.authorization.type != "no-auth" or data.authorization.config is not None:
raise BuilderExecutionPolicyError("unsupported_http_authorization")
if data.method.upper() not in {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}:
raise BuilderExecutionPolicyError("unsupported_http_method")
if data.body is not None and (
data.body.type not in {"none", "raw-text", "json"}
or any(item.type != "text" or item.file for item in data.body.data)
):
raise BuilderExecutionPolicyError("unsupported_http_body")
validate_restricted_http_defaults(data.model_dump(mode="python"))
class PolicyTransportError(Exception):
"""Sanitized denial handled by the native HTTP executor's request-error path."""
class PolicyMaxRetriesExceededError(Exception):
"""Distinct protocol type; restricted adapters never raise this URL-bearing path."""
def _text(value: Any) -> bool:
if type(value) is not str:
return False
try:
return len(value.encode("utf-8")) <= _MAX_TEXT_BYTES
except UnicodeError:
return False
def _json_value(value: Any, *, depth: int = 0) -> bool:
"""Exact finite JSON types; transport kwargs cannot smuggle Python objects."""
if depth > 32:
return False
if value is None or type(value) in (bool, int):
return True
if type(value) is float:
return math.isfinite(value)
if type(value) is str:
return _text(value)
if type(value) is list:
return all(_json_value(item, depth=depth + 1) for item in value)
if type(value) is dict:
return all(_text(key) and _json_value(item, depth=depth + 1) for key, item in value.items())
return False
def _valid_request(url: Any, kwargs: Mapping[str, Any]) -> bool:
if not _text(url) or set(kwargs) - {
"max_retries",
"data",
"files",
"json",
"content",
"headers",
"params",
"timeout",
"ssl_verify",
"follow_redirects",
}:
return False
retries = kwargs.get("max_retries", 0)
if type(retries) is not int or retries != 0:
return False
files = kwargs.get("files")
if files is not None and (type(files) is not list or files):
return False
if kwargs.get("data") is not None:
return False
content = kwargs.get("content")
if content is not None and not _text(content):
return False
headers = kwargs.get("headers")
if headers is not None and (
type(headers) is not dict
or len(headers) > 1
or any(
type(k) is not str or k.lower() != "content-type" or type(v) is not str or v.lower() not in _CONTENT_TYPES
for k, v in headers.items()
)
):
return False
params = kwargs.get("params")
if params is not None and (
type(params) is not list
or len(params) > 128
or any(type(pair) not in (list, tuple) or len(pair) != 2 or not all(_text(v) for v in pair) for pair in params)
):
return False
timeout = kwargs.get("timeout")
if timeout is not None and (
type(timeout) is not tuple
or len(timeout) != 3
or any(type(v) not in (int, float) or not 0 < v <= 300 or not math.isfinite(v) for v in timeout)
):
return False
if any(key in kwargs and type(kwargs[key]) is not bool for key in ("ssl_verify", "follow_redirects")):
return False
try:
if not _json_value(kwargs.get("json")):
return False
canonical_digest(dict(kwargs))
return len(json.dumps(kwargs, ensure_ascii=False, allow_nan=False).encode("utf-8")) <= _MAX_TEXT_BYTES
except (BuilderExecutionPolicyError, ValueError, TypeError, RecursionError):
return False
class RestrictedHttpClient:
"""Full Graphon HTTP protocol, bound to one admitted node and launch recorder."""
def __init__(
self,
*,
context: BuilderExecutionContext,
node_id: str,
recorder: BuilderExecutionRecorder,
request_hmac_key: bytes,
) -> None:
self._context = context
self._node_id = node_id
self._recorder = recorder
self._request_hmac_key = request_hmac_key
binding = next((binding for binding in context.admitted_nodes if binding.node_id == node_id), None)
if (
binding is None
or binding.implementation != "graphon.nodes.http_request.node.HttpRequestNode"
or binding.node_version != "1"
):
raise BuilderExecutionPolicyError("unsupported_node_implementation")
if type(request_hmac_key) is not bytes or len(request_hmac_key) != 32:
raise BuilderExecutionPolicyError("invalid_request_hmac_key")
self._implementation_version = f"{binding.implementation}:{binding.node_version}"
@property
def request_error(self) -> type[Exception]:
return PolicyTransportError
@property
def max_retries_exceeded_error(self) -> type[Exception]:
return PolicyMaxRetriesExceededError
def get(self, url: str, max_retries: int = 0, **kwargs: Any) -> HttpResponse:
return self._respond("GET", url, {"max_retries": max_retries, **kwargs})
def head(self, url: str, max_retries: int = 0, **kwargs: Any) -> HttpResponse:
return self._respond("HEAD", url, {"max_retries": max_retries, **kwargs})
def post(self, url: str, max_retries: int = 0, **kwargs: Any) -> HttpResponse:
return self._respond("POST", url, {"max_retries": max_retries, **kwargs})
def put(self, url: str, max_retries: int = 0, **kwargs: Any) -> HttpResponse:
return self._respond("PUT", url, {"max_retries": max_retries, **kwargs})
def delete(self, url: str, max_retries: int = 0, **kwargs: Any) -> HttpResponse:
return self._respond("DELETE", url, {"max_retries": max_retries, **kwargs})
def patch(self, url: str, max_retries: int = 0, **kwargs: Any) -> HttpResponse:
return self._respond("PATCH", url, {"max_retries": max_retries, **kwargs})
def _record(
self,
*,
kind: Literal["effect_blocked", "fixture_served"],
reason: str,
invocation_id: str,
request_hmac: str | None = None,
) -> None:
if not self._recorder.healthy:
raise BuilderExecutionPolicyError("execution_recorder_unhealthy")
self._recorder.record(
BuilderExecutionObservation(
observation_id=str(uuid4()),
invocation_id=invocation_id,
request_id=self._context.request_id,
node_id=self._node_id,
kind=kind,
implementation_version=self._implementation_version,
reason_code=reason,
fixture_digest=self._context.fixture_digest if kind == "fixture_served" else None,
request_hmac=request_hmac,
)
)
def deny_file(self) -> NoReturn:
self._record(kind="effect_blocked", reason="http_file_capability", invocation_id=str(uuid4()))
raise PolicyTransportError("execution_policy: http_file_capability")
def _respond(self, method: str, url: str, kwargs: Mapping[str, Any]) -> HttpResponse:
invocation_id = str(uuid4())
if method not in {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"} or not _valid_request(url, kwargs):
self._record(kind="effect_blocked", reason="http_request_arguments", invocation_id=invocation_id)
raise PolicyTransportError("execution_policy: http_request_arguments")
request = canonical_digest(
{
"method": method,
"url": url,
"kwargs": dict(kwargs),
"context_digest": self._context.context_digest,
"node_id": self._node_id,
"invocation_id": invocation_id,
}
)
request_hmac = hmac.new(self._request_hmac_key, request.encode("ascii"), hashlib.sha256).hexdigest()
fixture = next((fixture for fixture in self._context.http_fixtures if fixture.node_id == self._node_id), None)
if fixture is None:
self._record(
kind="effect_blocked",
reason="http_fixture_missing",
invocation_id=invocation_id,
request_hmac=request_hmac,
)
raise PolicyTransportError("execution_policy: http_fixture_missing")
self._record(
kind="fixture_served", reason="http_fixture_served", invocation_id=invocation_id, request_hmac=request_hmac
)
return HttpResponse(
status_code=fixture.status_code,
headers={"content-type": f"{fixture.content_type}; charset=utf-8"},
content=fixture.body.encode("utf-8"),
url=url,
)
class DeniedHttpFiles:
"""All native HTTP file collaborator protocols, with durable denials."""
def __init__(self, client: RestrictedHttpClient) -> None:
self._client = client
def download(self, f: Any, /) -> NoReturn:
self._client.deny_file()
def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> NoReturn:
self._client.deny_file()
def create_file_by_raw(self, *, file_binary: bytes, mimetype: str, filename: str | None = None) -> NoReturn:
self._client.deny_file()
def get_file_generator_by_tool_file_id(self, tool_file_id: str) -> NoReturn:
self._client.deny_file()
def __call__(self) -> NoReturn:
self._client.deny_file()