mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
862 lines
38 KiB
Python
862 lines
38 KiB
Python
"""Opt-in concrete HTTP metadata, binding and disclosure for one opaque literal call.
|
|
|
|
The qualification is server policy, never an assertion of remote read-only behavior.
|
|
Exact request data lives only in existing encrypted input/journal owners. Native
|
|
Graphon request trace persistence still requires a separate privacy adaptation;
|
|
this module does not activate launch or change operational node outputs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict
|
|
from importlib.metadata import version
|
|
from typing import TYPE_CHECKING, Any, Literal, NoReturn, Protocol
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
|
|
from configs import dify_config
|
|
from core.dify_builder.models import Actor
|
|
from graphon.graph.scoping import resolve_container_id
|
|
from graphon.nodes.http_request import HttpRequestNode, build_http_request_config
|
|
from graphon.nodes.http_request.entities import HttpRequestNodeData
|
|
from graphon.nodes.http_request.executor import Executor
|
|
from models.dify_builder import DifyBuilderExecutionRequest
|
|
from models.dify_builder_native_tests import (
|
|
NativeEffectOperation,
|
|
NativeInvocationState,
|
|
NativePendingAction,
|
|
NativeTestInvocation,
|
|
)
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest, canonical_json
|
|
from runtime_contracts.builder.native.approval_contracts import NativeApprovalProposal, NativeApprovalTerms
|
|
from runtime_contracts.builder.native.contracts import (
|
|
EffectIntent,
|
|
OperationKey,
|
|
operation_digest,
|
|
)
|
|
from runtime_contracts.builder.native.runtime_contracts import NativeStatePort, ResolvedNativeTestContext
|
|
from services.dify_builder.execution_policy_service import check_execution_permission
|
|
from services.dify_builder.native_testing.call_adapter import ScalarAuthorizationPort
|
|
from services.dify_builder.native_testing.context_binding import NativeRuntimeProfile
|
|
from services.dify_builder.native_testing.http_adapter import (
|
|
NativeHttpClient,
|
|
PreparedHttpBinding,
|
|
PreparedHttpRequest,
|
|
ScalarCallPort,
|
|
)
|
|
from services.dify_builder.native_testing.operation_journal import NodeOperationJournal, OperationCursor
|
|
from services.dify_builder.native_testing.private_reads import (
|
|
PrivateReadOwner,
|
|
validate_invocation_workflow,
|
|
validate_private_read,
|
|
)
|
|
from services.dify_builder.native_testing.public_contracts import PendingActionDisclosure
|
|
from services.dify_builder.native_testing.repository import NativeBinding, OwnedNativeTest, native_workflow_revision
|
|
from services.dify_builder.native_testing.transitions import require_ancestor_authority
|
|
|
|
if TYPE_CHECKING:
|
|
from graphon.http import HttpResponse
|
|
from services.dify_builder.native_testing.repository import OwnedNativeTestLoader
|
|
from services.dify_builder.native_testing.runtime_bridge import NativeNodePreparation
|
|
from services.dify_builder.native_testing.state_store import NativeExecutionStateStore
|
|
|
|
PROFILE_ID = "builder-native-http-opaque-v1"
|
|
OPACITY_NOTICE = (
|
|
"The remote service's internal actions are unknown. This approval covers one HTTP request with the bound input; "
|
|
"it does not establish that the operation is read-only. Path, query, body and header values are redacted. "
|
|
"One cost unit means one server operation accounting unit, not currency or endpoint fees."
|
|
)
|
|
_VERSIONS = {"graphon_version": version("graphon"), "httpx_version": version("httpx")}
|
|
|
|
|
|
def build_native_http_profile() -> NativeRuntimeProfile:
|
|
return NativeRuntimeProfile(
|
|
profile_id=PROFILE_ID,
|
|
implementation_revision=PROFILE_ID,
|
|
limits={"calls": 16, "dispatches": 16, "cost_units": 16},
|
|
)
|
|
|
|
|
|
def validate_native_http_profile(profile: NativeRuntimeProfile) -> None:
|
|
if profile != build_native_http_profile():
|
|
raise BuilderExecutionPolicyError("native_http_profile_drift")
|
|
|
|
|
|
def native_http_config():
|
|
"""Same actual configuration owner as the ordinary native factory, no constructor defaults."""
|
|
return build_http_request_config(
|
|
max_connect_timeout=dify_config.HTTP_REQUEST_MAX_CONNECT_TIMEOUT,
|
|
max_read_timeout=dify_config.HTTP_REQUEST_MAX_READ_TIMEOUT,
|
|
max_write_timeout=dify_config.HTTP_REQUEST_MAX_WRITE_TIMEOUT,
|
|
max_binary_size=dify_config.HTTP_REQUEST_NODE_MAX_BINARY_SIZE,
|
|
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
|
|
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
|
|
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
|
|
)
|
|
|
|
|
|
def http_policy() -> dict:
|
|
config = native_http_config()
|
|
return {
|
|
"isolated": False,
|
|
"egress": True,
|
|
"new_data": True,
|
|
"destructive": False,
|
|
"approval_scope": "operation",
|
|
"destructive_meaning": "not identified as known prohibited destructive; remote internals opaque",
|
|
"cost_units": 1,
|
|
"accounting": "one server operation accounting unit per physical attempt; not currency",
|
|
"additional_budget": {},
|
|
"max_calls": 1,
|
|
"max_dispatches": 1,
|
|
"max_cost_units": 1,
|
|
"max_redirect_hops": 0,
|
|
"native_ssrf_retries": 0,
|
|
"max_response_bytes": min(config.max_text_size, 1048576),
|
|
"max_result_bytes": 8388608,
|
|
"opacity_notice": OPACITY_NOTICE,
|
|
}
|
|
|
|
|
|
def _supported(raw: dict) -> HttpRequestNodeData:
|
|
"""Reject secret/file/template selectors before invoking any native preparation."""
|
|
try:
|
|
if (
|
|
raw.get("type") != "http-request"
|
|
or raw.get("version", "1") != "1"
|
|
or raw.get("authorization") != {"type": "no-auth"}
|
|
or raw.get("headers") != ""
|
|
or raw.get("timeout") is not None
|
|
or "{{" in json.dumps(raw, ensure_ascii=False)
|
|
):
|
|
raise ValueError
|
|
node = HttpRequestNodeData.model_validate(raw, strict=True)
|
|
if node.method.lower() not in {"get", "post", "put", "patch", "delete", "head"}:
|
|
raise ValueError
|
|
parsed = httpx.URL(node.url)
|
|
if (
|
|
parsed.scheme not in {"http", "https"}
|
|
or not parsed.host
|
|
or parsed.username
|
|
or parsed.password
|
|
or parsed.fragment
|
|
or node.ssl_verify not in (None, native_http_config().ssl_verify)
|
|
):
|
|
raise ValueError
|
|
if node.body is not None:
|
|
if node.body.type not in {"none", "raw-text", "json"}:
|
|
raise ValueError
|
|
if node.body.type == "none" and node.body.data:
|
|
raise ValueError
|
|
if node.body.type != "none" and (
|
|
len(node.body.data) != 1 or any(item.type != "text" or item.file for item in node.body.data)
|
|
):
|
|
raise ValueError
|
|
if HttpRequestNode.get_node_type_classes_mapping().get("http-request", {}).get("1") is not HttpRequestNode:
|
|
raise ValueError
|
|
if _VERSIONS != {"graphon_version": "0.8.0", "httpx_version": "0.28.1"}:
|
|
raise ValueError
|
|
return node
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("native_http_resource_unsupported") from None
|
|
|
|
|
|
def _config_projection() -> dict:
|
|
# Proxy strings may contain credentials. Qualify their exact value by digest;
|
|
# never place a raw proxy URL in the manifest or a public notice.
|
|
settings = {
|
|
"SSRF_PROXY_ALL_URL": dify_config.SSRF_PROXY_ALL_URL,
|
|
"SSRF_PROXY_HTTP_URL": dify_config.SSRF_PROXY_HTTP_URL,
|
|
"SSRF_PROXY_HTTPS_URL": dify_config.SSRF_PROXY_HTTPS_URL,
|
|
"SSRF_DEFAULT_TIME_OUT": dify_config.SSRF_DEFAULT_TIME_OUT,
|
|
"SSRF_DEFAULT_CONNECT_TIME_OUT": dify_config.SSRF_DEFAULT_CONNECT_TIME_OUT,
|
|
"SSRF_DEFAULT_READ_TIME_OUT": dify_config.SSRF_DEFAULT_READ_TIME_OUT,
|
|
"SSRF_DEFAULT_WRITE_TIME_OUT": dify_config.SSRF_DEFAULT_WRITE_TIME_OUT,
|
|
"SSRF_POOL_MAX_CONNECTIONS": dify_config.SSRF_POOL_MAX_CONNECTIONS,
|
|
"SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS": dify_config.SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS,
|
|
"SSRF_POOL_KEEPALIVE_EXPIRY": dify_config.SSRF_POOL_KEEPALIVE_EXPIRY,
|
|
}
|
|
return {
|
|
"native": asdict(native_http_config()),
|
|
"ssrf_settings_digest": canonical_digest(settings),
|
|
}
|
|
|
|
|
|
def describe_http_resource(*, node_id: str, raw: dict, workflow_revision: str, app_id: str, workflow_id: str) -> dict:
|
|
_supported(raw)
|
|
if not node_id or not app_id or not workflow_id or len(workflow_revision) != 64:
|
|
raise BuilderExecutionPolicyError("native_http_resource_unsupported")
|
|
return {
|
|
"kind": "http",
|
|
"app_id": app_id,
|
|
"workflow_id": workflow_id,
|
|
"node_id": node_id,
|
|
"native_class": "graphon.nodes.http_request.node.HttpRequestNode",
|
|
"native_type": "http-request",
|
|
"native_version": HttpRequestNode.version(),
|
|
"node_config_digest": canonical_digest(raw),
|
|
"workflow_revision": workflow_revision,
|
|
"implementation_revision": PROFILE_ID,
|
|
**_VERSIONS,
|
|
"policy": http_policy(),
|
|
"http_config": _config_projection(),
|
|
}
|
|
|
|
|
|
def _node(graph: dict, node_id: str) -> dict:
|
|
matches = [node["data"] for node in graph.get("nodes", []) if node.get("id") == node_id]
|
|
if len(matches) != 1:
|
|
raise BuilderExecutionPolicyError("native_http_resource_unsupported")
|
|
return matches[0]
|
|
|
|
|
|
class NativeHttpResourceSnapshot:
|
|
"""Compiler HTTP metadata from the actual tenant-owned invocation Workflow.
|
|
|
|
An empty tuple means the graph declares no HTTP resources. Native node
|
|
capability checks remain the delivery bridge's responsibility.
|
|
"""
|
|
|
|
def __init__(self, *, session_factory):
|
|
self._sessions = session_factory
|
|
|
|
def __call__(
|
|
self, *, actor: Actor, app_id: str, workflow_id: str, graph: dict, profile: NativeRuntimeProfile
|
|
) -> tuple[dict, ...]:
|
|
validate_native_http_profile(profile)
|
|
check_execution_permission(actor, app_id, self._sessions)
|
|
with self._sessions() as session:
|
|
workflow = validate_invocation_workflow(
|
|
session, tenant_id=actor.tenant_id, app_id=app_id, workflow_id=workflow_id
|
|
)
|
|
if json.loads(workflow.graph) != graph:
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
revision = native_workflow_revision(workflow)
|
|
entries = tuple(
|
|
describe_http_resource(
|
|
node_id=node["id"],
|
|
raw=node["data"],
|
|
workflow_revision=revision,
|
|
app_id=workflow.app_id,
|
|
workflow_id=workflow.id,
|
|
)
|
|
for node in graph.get("nodes", [])
|
|
if node.get("data", {}).get("type") == "http-request"
|
|
)
|
|
if len({entry["node_id"] for entry in entries}) != len(entries):
|
|
raise BuilderExecutionPolicyError("native_http_manifest_unavailable")
|
|
return entries
|
|
|
|
|
|
class RefusingNativeHttpFiles:
|
|
"""Explicit no-IO collaborators. A real file response refuses instead of returning empty files."""
|
|
|
|
def download(self, *_args, **_kwargs):
|
|
raise BuilderExecutionPolicyError("native_http_files_unsupported")
|
|
|
|
def create_file_by_raw(self, *_args, **_kwargs):
|
|
raise BuilderExecutionPolicyError("native_http_files_unsupported")
|
|
|
|
def __call__(self, *_args, **_kwargs):
|
|
raise BuilderExecutionPolicyError("native_http_files_unsupported")
|
|
|
|
|
|
class _OfflineHttpClient:
|
|
"""Executor construction may never dispatch, even after a dependency upgrade."""
|
|
|
|
request_error = RuntimeError
|
|
max_retries_exceeded_error = RuntimeError
|
|
|
|
def get(self, *_args, **_kwargs) -> NoReturn:
|
|
raise BuilderExecutionPolicyError("native_http_offline_io_forbidden")
|
|
|
|
head = post = put = delete = patch = get
|
|
|
|
|
|
def native_http_request(*, raw: dict, variable_pool: Any) -> dict:
|
|
node = _supported(raw)
|
|
config = native_http_config()
|
|
try:
|
|
executor = Executor(
|
|
node_data=node,
|
|
timeout=config.default_timeout(),
|
|
variable_pool=variable_pool,
|
|
http_request_config=config,
|
|
max_retries=0,
|
|
ssl_verify=node.ssl_verify,
|
|
http_client=_OfflineHttpClient(),
|
|
file_manager=RefusingNativeHttpFiles(),
|
|
)
|
|
return {
|
|
"url": executor.url,
|
|
"headers": {**executor.build_headers(), "Cookie": ""},
|
|
"params": executor.params,
|
|
"data": executor.data,
|
|
"files": executor.files,
|
|
"json": executor.json,
|
|
"content": executor.content,
|
|
"timeout": (executor.timeout.connect, executor.timeout.read, executor.timeout.write),
|
|
"ssl_verify": executor.ssl_verify,
|
|
"follow_redirects": True,
|
|
}
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("native_http_resource_unsupported") from None
|
|
|
|
|
|
def attest_http_request(*, request: PreparedHttpRequest, raw: dict, variable_pool: Any) -> None:
|
|
expected = native_http_request(raw=raw, variable_pool=variable_pool)
|
|
try:
|
|
if (
|
|
type(request) is not PreparedHttpRequest
|
|
or request.method != raw["method"].upper()
|
|
or canonical_digest(request.arguments()) != canonical_digest(expected)
|
|
or request.timeout != tuple(expected["timeout"])
|
|
or request.ssl_verify is not expected["ssl_verify"]
|
|
or request.follow_redirects is not True
|
|
):
|
|
raise ValueError
|
|
wire = httpx.Request(
|
|
request.method,
|
|
expected["url"],
|
|
**{k: expected[k] for k in ("headers", "params", "data", "files", "json", "content")},
|
|
)
|
|
if (request.url, request.headers, request.content) != (str(wire.url), tuple(wire.headers.raw), wire.read()):
|
|
raise ValueError
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("native_http_request_mismatch") from None
|
|
|
|
|
|
def _current_metadata(*, session, owned: OwnedNativeTest, node_id: str) -> tuple[dict, dict, str]:
|
|
"""Local SQL/config projection only, safe inside C02's existing root lock.
|
|
|
|
The first compiler qualifies HTTP entries in its actual admitted root graph.
|
|
A child must have its own matching admitted descriptor; no root descriptor is
|
|
silently reinterpreted as child authority. Broader compiler composition is gated.
|
|
"""
|
|
state = session.scalar(
|
|
select(NativeInvocationState).where(
|
|
NativeInvocationState.tenant_id == owned.tenant_id,
|
|
NativeInvocationState.request_id == owned.ref.request_id,
|
|
NativeInvocationState.invocation_id == owned.ref.invocation_id,
|
|
)
|
|
)
|
|
if state is None or not state.app_id or not state.workflow_id:
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
require_ancestor_authority(session, owned.ref, owned.tenant_id)
|
|
workflow = validate_invocation_workflow(
|
|
session, tenant_id=owned.tenant_id, app_id=state.app_id, workflow_id=state.workflow_id
|
|
)
|
|
request = session.get(DifyBuilderExecutionRequest, owned.ref.request_id)
|
|
if request is None:
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
validate_native_http_profile(NativeRuntimeProfile.model_validate(request.context.get("profile")))
|
|
graph, revision = json.loads(workflow.graph), native_workflow_revision(workflow)
|
|
entries = [
|
|
describe_http_resource(
|
|
node_id=node["id"],
|
|
raw=node["data"],
|
|
workflow_revision=revision,
|
|
app_id=workflow.app_id,
|
|
workflow_id=workflow.id,
|
|
)
|
|
for node in graph.get("nodes", [])
|
|
if node.get("data", {}).get("type") == "http-request"
|
|
]
|
|
if not entries or len({entry["node_id"] for entry in entries}) != len(entries):
|
|
raise BuilderExecutionPolicyError("native_http_manifest_unavailable")
|
|
manifest_digest = canonical_digest(entries)
|
|
if manifest_digest != request.context.get("resource_manifest_digest"):
|
|
reason = (
|
|
"native_http_child_qualification_unavailable"
|
|
if state.parent_invocation_id
|
|
else "native_http_manifest_drift"
|
|
)
|
|
raise BuilderExecutionPolicyError(reason)
|
|
raw = _node(graph, node_id)
|
|
descriptor = describe_http_resource(
|
|
node_id=node_id, raw=raw, workflow_revision=revision, app_id=workflow.app_id, workflow_id=workflow.id
|
|
)
|
|
if descriptor not in entries:
|
|
raise BuilderExecutionPolicyError("native_http_manifest_unavailable")
|
|
return raw, descriptor, manifest_digest
|
|
|
|
|
|
def _classified_binding(
|
|
*, intent_digest: str, execution_id: str, descriptor: dict, manifest_digest: str
|
|
) -> NativeBinding:
|
|
policy = descriptor["policy"]
|
|
# False means no known prohibited destructive operation was identified under
|
|
# this opaque policy. It is never an assertion of safety or read-only semantics.
|
|
return NativeBinding(
|
|
intent_digest=intent_digest,
|
|
kind="http",
|
|
resource_ref=descriptor["node_id"],
|
|
implementation_revision=descriptor["implementation_revision"],
|
|
account_ref=None,
|
|
capability_digest=manifest_digest,
|
|
resource_version=descriptor["workflow_revision"],
|
|
credential_version=None,
|
|
isolated=policy["isolated"],
|
|
egress=policy["egress"],
|
|
new_data=policy["new_data"],
|
|
destructive=policy["destructive"],
|
|
approval_scope=policy["approval_scope"],
|
|
cost_units=policy["cost_units"],
|
|
budget=policy["additional_budget"],
|
|
execution_id=execution_id,
|
|
invocation_digest=intent_digest,
|
|
)
|
|
|
|
|
|
class NativeHttpBindingResolver:
|
|
"""Reclassify actual current SQL graph/config; stored correlation grants no flags."""
|
|
|
|
def resolve_binding(self, *, session, owned: OwnedNativeTest, key: OperationKey) -> NativeBinding:
|
|
if key.invocation_id != owned.ref.invocation_id:
|
|
raise BuilderExecutionPolicyError("native_binding_mismatch")
|
|
frozen = NativeBinding.model_validate(owned.bindings.get(operation_digest(key)))
|
|
if frozen.kind != "http" or key.occurrence_path != (frozen.execution_id,):
|
|
raise BuilderExecutionPolicyError("native_binding_mismatch")
|
|
_, descriptor, digest = _current_metadata(session=session, owned=owned, node_id=frozen.resource_ref)
|
|
return _classified_binding(
|
|
intent_digest=frozen.intent_digest,
|
|
execution_id=frozen.execution_id,
|
|
descriptor=descriptor,
|
|
manifest_digest=digest,
|
|
)
|
|
|
|
def resolve_pending(self, *, session, owned, category: Literal["configuration", "interaction"], reference: str):
|
|
raise BuilderExecutionPolicyError("native_pending_requires_resolution")
|
|
|
|
|
|
class NativeHttpBindingProducer:
|
|
"""Per-native-occurrence producer; no client binding/classification arguments.
|
|
|
|
begin/finish bracket a complete existing NativeHttpClient call. A wait or stop
|
|
preserves the real open journal, and retry reopens that occurrence at zero.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
association: NativeNodePreparation,
|
|
loader: OwnedNativeTestLoader,
|
|
state_store: NativeExecutionStateStore,
|
|
journal: NodeOperationJournal,
|
|
):
|
|
self.association, self.loader, self.store, self.journal = association, loader, state_store, journal
|
|
self._context: ResolvedNativeTestContext | None = None
|
|
self._authority: PrivateReadOwner | None = None
|
|
self._cursor: OperationCursor | None = None
|
|
|
|
def _node_snapshot(self, context: ResolvedNativeTestContext):
|
|
from services.dify_builder.native_testing.control import marked_native_node_class
|
|
|
|
node = self.association.bound_node
|
|
if type(node) is not marked_native_node_class(HttpRequestNode, marked=True):
|
|
raise BuilderExecutionPolicyError("native_http_node_unsupported")
|
|
raw = _node(context.graph, self.association.node_id)
|
|
expected = _supported(raw)
|
|
raw_nodes = context.graph.get("nodes")
|
|
if not isinstance(raw_nodes, list):
|
|
raise BuilderExecutionPolicyError("native_http_node_drift")
|
|
nodes_by_id: dict[str, dict] = {}
|
|
for item in raw_nodes:
|
|
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
raise BuilderExecutionPolicyError("native_http_node_drift")
|
|
nodes_by_id[item["id"]] = item
|
|
owned_config = nodes_by_id[self.association.node_id]
|
|
|
|
def normalized(data: dict) -> dict:
|
|
# Graphon 0.8 adds its canonical direct owner while materializing
|
|
# nodes. Apply that same official rule to both snapshots, using
|
|
# trusted persisted topology for legacy/nested container links.
|
|
# Every other runtime field/extra remains in the strict comparison.
|
|
try:
|
|
container_id = resolve_container_id({**owned_config, "data": data}, nodes_by_id=nodes_by_id)
|
|
except (TypeError, ValueError):
|
|
raise BuilderExecutionPolicyError("native_http_node_drift") from None
|
|
return {**data, "container_id": container_id}
|
|
|
|
if canonical_digest(normalized(node.node_data.model_dump(mode="json"))) != canonical_digest(
|
|
normalized(expected.model_dump(mode="json"))
|
|
):
|
|
raise BuilderExecutionPolicyError("native_http_node_drift")
|
|
descriptor = describe_http_resource(
|
|
node_id=self.association.node_id,
|
|
raw=raw,
|
|
workflow_revision=context.graph_revision,
|
|
app_id=context.app_id,
|
|
workflow_id=context.workflow_id,
|
|
)
|
|
if not context.resource_manifest or context.resource_manifest.count(descriptor) != 1:
|
|
reason = (
|
|
"native_http_child_qualification_unavailable"
|
|
if context.parent_invocation_id
|
|
else "native_http_manifest_unavailable"
|
|
)
|
|
raise BuilderExecutionPolicyError(reason)
|
|
return raw, descriptor
|
|
|
|
def begin(self) -> None:
|
|
# resolve checks attachment and the public canonical UUID before private IO.
|
|
context = self.association.resolve()
|
|
self._context = self._authority = self._cursor = None
|
|
self._node_snapshot(context)
|
|
self.loader.load(ref=context.ref)
|
|
authority = validate_private_read(self.loader, context.ref)
|
|
if authority.generation != context.generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
self._context, self._authority = context, authority
|
|
|
|
def prepare(self, *, request: PreparedHttpRequest, hop: int, previous_response) -> PreparedHttpBinding:
|
|
context, authority = self._context, self._authority
|
|
if context is None or authority is None or type(hop) is not int or hop != 0 or previous_response is not None:
|
|
raise BuilderExecutionPolicyError("native_http_delivery_unavailable")
|
|
current = self.association.resolve()
|
|
if current != context:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
raw, descriptor = self._node_snapshot(current)
|
|
attest_http_request(request=request, raw=raw, variable_pool=self.association.runtime_state.variable_pool)
|
|
material = request.material()
|
|
input_ref = self.store.store_node_inputs(
|
|
ref=context.ref,
|
|
generation=context.generation,
|
|
native_execution_id=self.association.execution_id,
|
|
inputs=material,
|
|
)
|
|
# A successful C03 return is not a substitute for exact typed readback.
|
|
if canonical_digest(self.store.load_result(ref=context.ref, result_ref=input_ref)) != canonical_digest(
|
|
material
|
|
):
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
if self._cursor is not None:
|
|
raise BuilderExecutionPolicyError("native_http_delivery_conflict")
|
|
cursor = self.journal.open(ref=context.ref, occurrence_path=self.association.occurrence_path)
|
|
cursor.bind_input(input_result_ref=input_ref, inputs=material)
|
|
self._cursor = cursor
|
|
intent = EffectIntent(
|
|
key=cursor.next_key(),
|
|
kind="http",
|
|
resource_ref=self.association.node_id,
|
|
implementation_revision=PROFILE_ID,
|
|
account_ref=None,
|
|
target=request.url,
|
|
parameters=material,
|
|
input_digest=canonical_digest(material),
|
|
capability_digest=canonical_digest(list(context.resource_manifest)),
|
|
)
|
|
# Current full private/RBAC reads and encrypted payload access precede lock.
|
|
self.loader.load(ref=context.ref)
|
|
fresh_authority = validate_private_read(self.loader, context.ref)
|
|
if fresh_authority != authority or self.association.resolve() != context:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
self._install(context=context, authority=authority, descriptor=descriptor, intent=intent)
|
|
return PreparedHttpBinding(request=request, intent=intent)
|
|
|
|
def _install(self, *, context, authority, descriptor, intent: EffectIntent) -> None:
|
|
from services.dify_builder.native_testing.state_store import NativeExecutionStateStore
|
|
|
|
with self.loader.locked(ref=context.ref) as (session, owned, scope):
|
|
NativeExecutionStateStore._validate_node_inputs_owner(
|
|
session, owned, scope, context, authority, context.generation
|
|
)
|
|
_, current, digest = _current_metadata(session=session, owned=owned, node_id=self.association.node_id)
|
|
if current != descriptor or digest != intent.capability_digest:
|
|
raise BuilderExecutionPolicyError("native_http_manifest_drift")
|
|
if self.association.execution_id != intent.key.occurrence_path[0]:
|
|
raise BuilderExecutionPolicyError("native_run_mismatch")
|
|
binding = _classified_binding(
|
|
intent_digest=canonical_digest(intent.model_dump(mode="json")),
|
|
execution_id=self.association.execution_id,
|
|
descriptor=current,
|
|
manifest_digest=digest,
|
|
)
|
|
invocation = session.scalar(
|
|
select(NativeTestInvocation).where(
|
|
NativeTestInvocation.tenant_id == owned.tenant_id,
|
|
NativeTestInvocation.request_id == context.ref.request_id,
|
|
NativeTestInvocation.invocation_id == context.ref.invocation_id,
|
|
NativeTestInvocation.active.is_(True),
|
|
)
|
|
)
|
|
if invocation is None:
|
|
raise BuilderExecutionPolicyError("native_invocation_mismatch")
|
|
identity = operation_digest(intent.key)
|
|
original = invocation.bindings.get(identity)
|
|
if original is not None and NativeBinding.model_validate(original) != binding:
|
|
raise BuilderExecutionPolicyError("native_binding_conflict")
|
|
if original is None:
|
|
invocation.bindings = {**invocation.bindings, identity: binding.model_dump(mode="json")}
|
|
# Context manager commits before the attested request can reach scalar C02.
|
|
|
|
def approval_proposals(self) -> tuple[NativeApprovalProposal, ...]:
|
|
"""Host checkpoint owner collects these real proposals after a native wait.
|
|
|
|
This exports no authority: public decisions still require the encrypted
|
|
committed proposal and the existing current pending-decision guard.
|
|
"""
|
|
if self._context is None or self._cursor is None:
|
|
raise BuilderExecutionPolicyError("native_http_delivery_unavailable")
|
|
if self.association.resolve() != self._context:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
owned = self.loader.load(ref=self._context.ref)
|
|
saved = self._cursor.snapshot()
|
|
return tuple(
|
|
NativeApprovalProposal(
|
|
key=intent.key,
|
|
terms=NativeApprovalTerms(
|
|
max_calls=1, max_dispatches=1, max_cost_units=1, expires_at=owned.expires_at.isoformat()
|
|
),
|
|
)
|
|
for intent in saved.intents
|
|
)
|
|
|
|
def finish(self) -> None:
|
|
if self._cursor is None:
|
|
raise BuilderExecutionPolicyError("native_http_delivery_unavailable")
|
|
self._cursor.finish()
|
|
|
|
|
|
class NativeHttpCallsFactory(Protocol):
|
|
"""Trusted server composition of the scalar owner; no added authority."""
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
state: NativeStatePort,
|
|
authorization: ScalarAuthorizationPort,
|
|
journal: NodeOperationJournal,
|
|
max_result_bytes: int,
|
|
) -> ScalarCallPort: ...
|
|
|
|
|
|
class NativeHttpPreparedObserver(Protocol):
|
|
"""Acknowledge the exact prepared binding before scalar authorization."""
|
|
|
|
def __call__(self, *, binding: PreparedHttpBinding) -> None: ...
|
|
|
|
|
|
class _ObservedHttpIntents:
|
|
"""Observe the actual producer result once without taking preparation ownership."""
|
|
|
|
def __init__(self, *, producer: NativeHttpBindingProducer, on_prepared: NativeHttpPreparedObserver) -> None:
|
|
self._producer, self._on_prepared = producer, on_prepared
|
|
|
|
def prepare(
|
|
self, *, request: PreparedHttpRequest, hop: int, previous_response: HttpResponse | None
|
|
) -> PreparedHttpBinding:
|
|
binding = self._producer.prepare(request=request, hop=hop, previous_response=previous_response)
|
|
if self._on_prepared(binding=binding) is not None:
|
|
raise ValueError("native_http_preparation_not_acknowledged")
|
|
return binding
|
|
|
|
|
|
class DeferredNativeHttpClient:
|
|
"""Defer private work until native entry, using required trusted host seams.
|
|
|
|
The factory and preparation observer are internal server composition. The
|
|
observer closes over the delivery ref when registering the exact binding
|
|
with its host; neither collaborator is client-provided approval authority.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
producer: NativeHttpBindingProducer,
|
|
authorization: ScalarAuthorizationPort,
|
|
calls_factory: NativeHttpCallsFactory,
|
|
on_prepared: NativeHttpPreparedObserver,
|
|
) -> None:
|
|
self._producer, self._authorization = producer, authorization
|
|
self._calls_factory, self._on_prepared = calls_factory, on_prepared
|
|
|
|
@property
|
|
def request_error(self):
|
|
from core.helper import ssrf_proxy
|
|
|
|
return ssrf_proxy.request_error
|
|
|
|
@property
|
|
def max_retries_exceeded_error(self):
|
|
from core.helper import ssrf_proxy
|
|
|
|
return ssrf_proxy.max_retries_exceeded_error
|
|
|
|
def _call(self, method: str, url: str, max_retries: int, kwargs: dict):
|
|
self._producer.begin()
|
|
policy = http_policy()
|
|
try:
|
|
calls = self._calls_factory(
|
|
state=self._producer.store,
|
|
authorization=self._authorization,
|
|
journal=self._producer.journal,
|
|
max_result_bytes=policy["max_result_bytes"],
|
|
)
|
|
if not callable(calls.call):
|
|
raise ValueError("native_http_calls_invalid")
|
|
except Exception:
|
|
from services.dify_builder.native_testing.control import NativePolicyStop
|
|
|
|
raise NativePolicyStop(
|
|
ref=self._producer.association.ref, reason_code="native_http_calls_unavailable"
|
|
) from None
|
|
client = NativeHttpClient(
|
|
ref=self._producer.association.ref,
|
|
calls=calls,
|
|
intents=_ObservedHttpIntents(producer=self._producer, on_prepared=self._on_prepared),
|
|
max_redirect_hops=policy["max_redirect_hops"],
|
|
max_response_bytes=policy["max_response_bytes"],
|
|
)
|
|
methods = {
|
|
"get": client.get,
|
|
"head": client.head,
|
|
"post": client.post,
|
|
"put": client.put,
|
|
"patch": client.patch,
|
|
"delete": client.delete,
|
|
}
|
|
result = methods[method](url, max_retries=max_retries, **kwargs)
|
|
self._producer.finish()
|
|
return result
|
|
|
|
def get(self, url: str, max_retries: int = 0, **kwargs):
|
|
return self._call("get", url, max_retries, kwargs)
|
|
|
|
def head(self, url: str, max_retries: int = 0, **kwargs):
|
|
return self._call("head", url, max_retries, kwargs)
|
|
|
|
def post(self, url: str, max_retries: int = 0, **kwargs):
|
|
return self._call("post", url, max_retries, kwargs)
|
|
|
|
def put(self, url: str, max_retries: int = 0, **kwargs):
|
|
return self._call("put", url, max_retries, kwargs)
|
|
|
|
def delete(self, url: str, max_retries: int = 0, **kwargs):
|
|
return self._call("delete", url, max_retries, kwargs)
|
|
|
|
def patch(self, url: str, max_retries: int = 0, **kwargs):
|
|
return self._call("patch", url, max_retries, kwargs)
|
|
|
|
|
|
def _redacted_shape(value: Any, depth: int = 0) -> dict:
|
|
"""Typed counts/lengths only; even user-controlled JSON keys remain private."""
|
|
if isinstance(value, dict):
|
|
shaped: dict[str, Any] = {"type": "object", "fields": len(value)}
|
|
if depth < 3:
|
|
shaped["values"] = [_redacted_shape(item, depth + 1) for item in list(value.values())[:8]]
|
|
return shaped
|
|
if isinstance(value, (tuple, list)):
|
|
result: dict[str, Any] = {"type": type(value).__name__, "length": len(value)}
|
|
if depth < 3:
|
|
result["items"] = [_redacted_shape(item, depth + 1) for item in value[:8]]
|
|
return result
|
|
if isinstance(value, (str, bytes)):
|
|
return {"type": type(value).__name__, "length": len(value)}
|
|
return {"type": type(value).__name__}
|
|
|
|
|
|
def http_disclosure_summary(*, intent: EffectIntent, descriptor: dict) -> tuple[dict, dict]:
|
|
if not isinstance(intent.target, str) or not isinstance(intent.parameters, dict):
|
|
raise BuilderExecutionPolicyError("native_http_disclosure_unavailable")
|
|
parsed = httpx.URL(intent.target)
|
|
# A validated ASCII origin is the minimum meaningful destination. Raw
|
|
# path/query/fragment/userinfo values never cross this public boundary.
|
|
host = parsed.host
|
|
if (
|
|
parsed.scheme not in {"http", "https"}
|
|
or not host
|
|
or parsed.username
|
|
or parsed.password
|
|
or not host.isascii()
|
|
or len(host) > 253
|
|
or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789.-:" for char in host.lower())
|
|
):
|
|
raise BuilderExecutionPolicyError("native_http_disclosure_unavailable")
|
|
origin = str(parsed.copy_with(path="", query=None, fragment=None))
|
|
return (
|
|
{"method": intent.parameters["method"], "origin": origin},
|
|
{
|
|
"request_digest": canonical_digest(intent.parameters),
|
|
"typed_material_bytes": len(canonical_json(intent.parameters)),
|
|
"source_workflow_revision": descriptor["workflow_revision"],
|
|
"redacted_fields": ["path", "query", "body", "headers"],
|
|
"typed_shape": _redacted_shape(intent.parameters),
|
|
"network_scope": "one outbound HTTP request; zero redirects",
|
|
"new_remote_data": True,
|
|
},
|
|
)
|
|
|
|
|
|
class NativeHttpDisclosure:
|
|
"""Actual protected pending intent, current classification and bounded public notice."""
|
|
|
|
def __init__(self, *, loader, state_store):
|
|
from services.dify_builder.native_testing.pending_intents import ProtectedPendingIntentReader
|
|
|
|
self.loader = loader
|
|
self.reader = ProtectedPendingIntentReader(loader=loader, state_store=state_store)
|
|
|
|
def describe(self, *, actor: Actor, resolved) -> PendingActionDisclosure:
|
|
if actor != self.loader.actor or resolved.intent.kind != "http":
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
proposed = self.reader.load_terms(resolved=resolved, for_projection=True) # Actual protected proposal.
|
|
owned = self.loader.load(ref=resolved.ref)
|
|
terms = NativeApprovalTerms(
|
|
max_calls=1, max_dispatches=1, max_cost_units=1, expires_at=owned.expires_at.isoformat()
|
|
)
|
|
if proposed != terms:
|
|
raise BuilderExecutionPolicyError("native_approval_proposal_unavailable")
|
|
with self.loader.session_factory() as session:
|
|
binding = NativeHttpBindingResolver().resolve_binding(session=session, owned=owned, key=resolved.intent.key)
|
|
if (
|
|
binding.intent_digest != canonical_digest(resolved.intent.model_dump(mode="json"))
|
|
or binding.destructive
|
|
or binding != NativeBinding.model_validate(owned.bindings.get(operation_digest(resolved.intent.key)))
|
|
):
|
|
raise BuilderExecutionPolicyError("native_binding_drift")
|
|
_, descriptor, _ = _current_metadata(session=session, owned=owned, node_id=binding.resource_ref)
|
|
pending = session.scalar(
|
|
select(NativePendingAction)
|
|
.join(NativeEffectOperation, NativeEffectOperation.id == NativePendingAction.operation_id)
|
|
.where(
|
|
NativeEffectOperation.tenant_id == owned.tenant_id,
|
|
NativeEffectOperation.request_id == resolved.ref.request_id,
|
|
NativeEffectOperation.invocation_id == resolved.ref.invocation_id,
|
|
NativeEffectOperation.operation_digest == operation_digest(resolved.intent.key),
|
|
NativeEffectOperation.intent_digest == binding.intent_digest,
|
|
NativePendingAction.tenant_id == owned.tenant_id,
|
|
NativePendingAction.request_id == resolved.ref.request_id,
|
|
NativePendingAction.invocation_id == resolved.ref.invocation_id,
|
|
NativePendingAction.category == "approval",
|
|
NativePendingAction.owner_version == self._scope(resolved.intent, binding),
|
|
)
|
|
)
|
|
if pending is None or pending.generation != resolved.generation:
|
|
raise BuilderExecutionPolicyError("native_pending_conflict")
|
|
pending_id, scope_digest = pending.id, pending.owner_version
|
|
target, summary = http_disclosure_summary(intent=resolved.intent, descriptor=descriptor)
|
|
return PendingActionDisclosure(
|
|
pending_operation_id=pending_id,
|
|
wait_reason="approval",
|
|
version=resolved.generation + 1,
|
|
scope_digest=scope_digest,
|
|
account_label=None,
|
|
action="Send one opaque HTTP request",
|
|
target=target,
|
|
parameters_summary=summary,
|
|
risk_notice=OPACITY_NOTICE,
|
|
required_configuration=(),
|
|
native_form_ref=None,
|
|
approval_limits=terms,
|
|
)
|
|
|
|
@staticmethod
|
|
def _scope(intent, binding):
|
|
# Reuse C02's canonical scope recipe; prose never defines grant authority.
|
|
from services.dify_builder.native_testing.authorization import _scope_digest
|
|
|
|
return _scope_digest(intent, binding)
|