mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
677 lines
24 KiB
Python
677 lines
24 KiB
Python
"""What a Builder Test Run touches outside Dify, and which of it the user confirmed.
|
|
|
|
PRD §9.1: Test Run defaults to Mock; a real side effect (send, write, create)
|
|
runs only after the user saw its target account and object and confirmed it
|
|
separately; a destructive operation is never executed by Builder. Model calls,
|
|
sandboxed code and read-only calls are disclosed but need no separate
|
|
confirmation (they are L2 cost and access, approved with the plan).
|
|
|
|
Pure: reads one draft graph mapping, account metadata, and optional test inputs. The
|
|
classification is deliberately conservative: custom API Tools require owned
|
|
operation facts and saved semantic assessment; missing facts are mocked. Other
|
|
Tools retain their existing disclosure policy. A confirmation is bound to the node's effect signature (type,
|
|
target, account and constant parameters), so a repair that changes what a node
|
|
would touch drops that node's confirmation while unrelated repairs keep it.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from collections.abc import Iterable, Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any, Literal
|
|
from urllib.parse import urlsplit
|
|
|
|
from core.dify_builder.credentials import is_credential_param_key
|
|
from domain.workflow.agent_node import is_dify_agent_node_data
|
|
from services.dify_builder.revision import node_config_digest, normalize_execution_graph
|
|
|
|
EffectTier = Literal["pure", "sandboxed", "read", "side_effect", "destructive"]
|
|
_CODE_TIERS: dict[str, EffectTier] = {
|
|
"pure": "sandboxed",
|
|
"read": "read",
|
|
"side_effect": "side_effect",
|
|
"destructive": "destructive",
|
|
"unknown": "side_effect",
|
|
}
|
|
|
|
# Test-data form keys for the per-node confirmation checkboxes. ``:`` never
|
|
# appears in a Start variable name, so these keys cannot shadow an input.
|
|
CONFIRM_FIELD_PREFIX = "builder_confirm:"
|
|
|
|
_PURE_TYPES = frozenset(
|
|
{
|
|
"start",
|
|
"end",
|
|
"answer",
|
|
"if-else",
|
|
"variable-aggregator",
|
|
"variable-assigner",
|
|
"assigner",
|
|
"iteration",
|
|
"iteration-start",
|
|
"loop",
|
|
"loop-start",
|
|
"loop-end",
|
|
"list-operator",
|
|
"human-input",
|
|
"trigger-webhook",
|
|
"trigger-schedule",
|
|
"trigger-plugin",
|
|
}
|
|
)
|
|
_SANDBOXED_TYPES = frozenset({"code", "template-transform"})
|
|
_MODEL_TYPES = frozenset({"llm", "question-classifier", "parameter-extractor"})
|
|
_READ_TYPES = frozenset({"document-extractor", "knowledge-retrieval"})
|
|
|
|
_READ_METHODS = frozenset({"get", "head", "options"})
|
|
_DESTRUCTIVE_METHODS = frozenset({"delete"})
|
|
|
|
_DESTRUCTIVE_WORDS = frozenset(
|
|
{"delete", "remove", "drop", "destroy", "purge", "truncate", "erase", "wipe", "trash", "unpublish", "revoke"}
|
|
)
|
|
_WRITE_WORDS = frozenset(
|
|
{
|
|
"send",
|
|
"post",
|
|
"create",
|
|
"update",
|
|
"write",
|
|
"insert",
|
|
"upsert",
|
|
"add",
|
|
"put",
|
|
"patch",
|
|
"publish",
|
|
"upload",
|
|
"reply",
|
|
"forward",
|
|
"invite",
|
|
"share",
|
|
"set",
|
|
"move",
|
|
"rename",
|
|
"comment",
|
|
"schedule",
|
|
"book",
|
|
"pay",
|
|
"transfer",
|
|
"execute",
|
|
"trigger",
|
|
"deploy",
|
|
"merge",
|
|
"close",
|
|
"assign",
|
|
"label",
|
|
"mark",
|
|
"edit",
|
|
"append",
|
|
"submit",
|
|
"save",
|
|
"notify",
|
|
"tweet",
|
|
"message",
|
|
"email",
|
|
"sms",
|
|
"call",
|
|
"order",
|
|
"cancel",
|
|
"archive",
|
|
"approve",
|
|
"reject",
|
|
"subscribe",
|
|
"unsubscribe",
|
|
"register",
|
|
}
|
|
)
|
|
_READ_WORDS = frozenset(
|
|
{
|
|
"get",
|
|
"list",
|
|
"search",
|
|
"read",
|
|
"fetch",
|
|
"query",
|
|
"find",
|
|
"lookup",
|
|
"retrieve",
|
|
"download",
|
|
"describe",
|
|
"view",
|
|
"show",
|
|
"check",
|
|
"count",
|
|
"parse",
|
|
"extract",
|
|
"convert",
|
|
"calculate",
|
|
"calc",
|
|
"compute",
|
|
"translate",
|
|
"summarize",
|
|
"analyze",
|
|
"analyse",
|
|
"time",
|
|
"date",
|
|
"weather",
|
|
"scrape",
|
|
"crawl",
|
|
"browse",
|
|
"generate",
|
|
"image",
|
|
"tts",
|
|
"speech",
|
|
"transcribe",
|
|
"ocr",
|
|
"embed",
|
|
"classify",
|
|
"detect",
|
|
"validate",
|
|
"format",
|
|
"encode",
|
|
"decode",
|
|
"hash",
|
|
"random",
|
|
"json",
|
|
"math",
|
|
"eval",
|
|
"wikipedia",
|
|
"arxiv",
|
|
"pubmed",
|
|
"news",
|
|
"stock",
|
|
"price",
|
|
"info",
|
|
"status",
|
|
"profile",
|
|
"export",
|
|
}
|
|
)
|
|
_WORD_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+")
|
|
_MAX_OBJECT_CHARS = 160
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EffectItem:
|
|
node_id: str
|
|
title: str
|
|
node_type: str
|
|
tier: EffectTier
|
|
# What the node reaches: model, provider and tool, or method and host.
|
|
target: str
|
|
# Credential display name, or "" when the node uses none or it is unknown.
|
|
account: str
|
|
# Constant parameters naming the object (recipient, channel, URL), truncated.
|
|
object: str
|
|
signature: str
|
|
# Same graph/account snapshot, before direct Start values are bound.
|
|
config_signature: str = ""
|
|
risk_notice: str = ""
|
|
assessment_digest: str = ""
|
|
assessment_status: str = ""
|
|
|
|
@property
|
|
def confirmable(self) -> bool:
|
|
return self.tier == "side_effect" and self.assessment_status != "unavailable"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EffectDisclosure:
|
|
items: tuple[EffectItem, ...] = ()
|
|
|
|
def by_tier(self, *tiers: EffectTier) -> tuple[EffectItem, ...]:
|
|
return tuple(item for item in self.items if item.tier in tiers)
|
|
|
|
@property
|
|
def confirmable(self) -> tuple[EffectItem, ...]:
|
|
return tuple(item for item in self.items if item.confirmable)
|
|
|
|
def confirmation(self, node_ids: Iterable[str]) -> dict[str, str]:
|
|
"""Bind the confirmed side-effect nodes to their current signatures; others are ignored."""
|
|
wanted = set(node_ids)
|
|
return {item.node_id: item.signature for item in self.confirmable if item.node_id in wanted}
|
|
|
|
def mocked_node_ids(self, confirmation: Mapping[str, str] | None) -> frozenset[str]:
|
|
"""Unconfirmed side effects, side effects whose target changed since, and every destructive node."""
|
|
confirmed = confirmation or {}
|
|
return frozenset(
|
|
item.node_id
|
|
for item in self.items
|
|
if item.assessment_status == "unavailable"
|
|
or item.tier == "destructive"
|
|
or (item.tier == "side_effect" and confirmed.get(item.node_id) != item.signature)
|
|
)
|
|
|
|
|
|
def disclose(
|
|
graph: Mapping[str, Any],
|
|
accounts: Mapping[str, str] | None = None,
|
|
*,
|
|
account_bindings: Mapping[str, str] | None = None,
|
|
inputs: Mapping[str, Any] | None = None,
|
|
scope_binding: str = "",
|
|
code_risk_assessments: Mapping[str, Any] | None = None,
|
|
code_facts_digests: Mapping[str, str] | None = None,
|
|
api_tool_definitions: Mapping[str, Mapping[str, Any]] | None = None,
|
|
) -> EffectDisclosure:
|
|
"""Disclose configuration, resolved accounts, and direct invocation inputs.
|
|
|
|
``account_bindings`` carries opaque resolved credential identities, never
|
|
credential values. Inputs bind Start references and the advanced-chat
|
|
``sys.query`` input. Upstream outputs cannot be resolved before execution.
|
|
``scope_binding`` is an adapter-owned digest of environment/conversation
|
|
variable state; its values are never returned as part of the disclosure.
|
|
"""
|
|
from runtime_contracts.builder.execution import canonical_digest
|
|
from services.dify_builder.api_tool_effects import is_api_tool
|
|
from services.dify_builder.code_risk_assessment import UNAVAILABLE, _safe_urls, validated_assessments
|
|
|
|
code_risks = validated_assessments(code_risk_assessments)
|
|
names = accounts or {}
|
|
bindings = account_bindings or {}
|
|
items: list[EffectItem] = []
|
|
nodes = graph.get("nodes")
|
|
start_ids = (
|
|
{
|
|
str(node["id"])
|
|
for node in nodes
|
|
if isinstance(node, Mapping)
|
|
and isinstance(node.get("id"), str)
|
|
and isinstance(node.get("data"), Mapping)
|
|
and node["data"].get("type") == "start"
|
|
}
|
|
if isinstance(nodes, list)
|
|
else set()
|
|
)
|
|
node_ids = (
|
|
{str(node["id"]) for node in nodes if isinstance(node, Mapping) and isinstance(node.get("id"), str)}
|
|
if isinstance(nodes, list)
|
|
else set()
|
|
)
|
|
start_config = (
|
|
{
|
|
str(node["id"]): node_config_digest(node["data"])
|
|
for node in nodes
|
|
if isinstance(node, Mapping)
|
|
and isinstance(node.get("id"), str)
|
|
and node["id"] in start_ids
|
|
and isinstance(node.get("data"), Mapping)
|
|
}
|
|
if isinstance(nodes, list)
|
|
else {}
|
|
)
|
|
for node in nodes if isinstance(nodes, list) else []:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
node_id = node.get("id")
|
|
if not isinstance(data, Mapping) or not isinstance(node_id, str) or not node_id:
|
|
continue
|
|
node_type = data.get("type")
|
|
if not isinstance(node_type, str) or not node_type:
|
|
continue
|
|
bound_inputs = _referenced_start_inputs(data, start_ids, inputs) if inputs is not None else {}
|
|
upstream = _has_upstream_reference(data, node_ids - start_ids)
|
|
# An upstream value is unknowable before execution. Conservatively bind
|
|
# dynamic effects to the graph configuration, while fixed targets keep
|
|
# their existing per-node binding through unrelated repairs.
|
|
# An omitted Start input may use its declared default at runtime, so
|
|
# bind the declaration as well as the submitted input values.
|
|
input_references = _referenced_start_inputs(data, start_ids, {})
|
|
graph_binding = (
|
|
normalize_execution_graph(dict(graph))
|
|
if upstream
|
|
else ({"start_config": start_config} if input_references else None)
|
|
)
|
|
if upstream and inputs is not None:
|
|
bound_inputs.update(_referenced_start_inputs(graph, start_ids, inputs))
|
|
display_data = _resolve_input_references(data, start_ids, inputs) if inputs is not None else data
|
|
tier, target, obj = _classify(node_type, display_data)
|
|
if upstream:
|
|
obj = "; ".join(
|
|
part for part in (obj, "Some values come from upstream outputs and resolve during Test Run.") if part
|
|
)
|
|
account = names.get(node_id, "")
|
|
config_tier, config_target, _ = _classify(node_type, data)
|
|
risk_notice, assessment_digest, assessment_status = "", "", ""
|
|
code_identity = None
|
|
api_tool = is_api_tool(data)
|
|
if node_type == "code" or api_tool:
|
|
risk = code_risks.get(node_id)
|
|
if risk is not None and risk["facts_digest"] == (code_facts_digests or {}).get(node_id):
|
|
risk_notice = risk["risk_notice"]
|
|
assessment_digest = canonical_digest(risk)
|
|
assessment_status = risk["status"]
|
|
tier = _CODE_TIERS[risk["classification"]]
|
|
if api_tool and tier == "sandboxed":
|
|
tier = "read"
|
|
else:
|
|
assessment_status = "unavailable"
|
|
risk_notice = UNAVAILABLE
|
|
if assessment_status == "unavailable":
|
|
tier = "side_effect"
|
|
# The Code facts digest already binds typed inputs and the whole
|
|
# executable graph without canvas labels. Do not reintroduce the
|
|
# older dynamic HTTP/Tool graph signature or stringified inputs.
|
|
bound_inputs = {}
|
|
graph_binding = None
|
|
config_tier = tier
|
|
if api_tool:
|
|
definition = (api_tool_definitions or {}).get(node_id, {})
|
|
bundle = definition.get("bundle") or {}
|
|
target = config_target = (
|
|
f"{str(bundle.get('method', '')).upper()} {_host(str(bundle.get('server_url', '')))}"
|
|
)
|
|
obj = _truncate(
|
|
"; ".join(part for part in (_safe_urls(str(bundle.get("server_url") or "")), obj) if part)
|
|
)
|
|
account = str(definition.get("account") or account)
|
|
if not definition:
|
|
assessment_status = "unavailable"
|
|
tier = config_tier = "side_effect"
|
|
else:
|
|
target = config_target = "Code runtime (destinations depend on the bound source and inputs)"
|
|
obj = ""
|
|
code_identity = {
|
|
"assessment": assessment_digest,
|
|
"status": assessment_status,
|
|
"facts": (code_facts_digests or {}).get(node_id, ""),
|
|
**({"api_definition": (api_tool_definitions or {}).get(node_id)} if api_tool else {}),
|
|
}
|
|
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
|
items.append(
|
|
EffectItem(
|
|
node_id=node_id,
|
|
title=title or node_id,
|
|
node_type=node_type,
|
|
tier=tier,
|
|
risk_notice=risk_notice,
|
|
assessment_digest=assessment_digest,
|
|
assessment_status=assessment_status,
|
|
target=target,
|
|
account=account,
|
|
object=obj,
|
|
signature=_signature(
|
|
node_type,
|
|
tier,
|
|
target,
|
|
data,
|
|
account,
|
|
bindings.get(node_id, ""),
|
|
bound_inputs,
|
|
graph_binding,
|
|
scope_binding,
|
|
code_identity,
|
|
),
|
|
config_signature=_signature(
|
|
node_type,
|
|
config_tier,
|
|
config_target,
|
|
data,
|
|
account,
|
|
bindings.get(node_id, ""),
|
|
{},
|
|
graph_binding,
|
|
scope_binding,
|
|
code_identity,
|
|
),
|
|
)
|
|
)
|
|
return EffectDisclosure(items=tuple(items))
|
|
|
|
|
|
_VARIABLE_REF = re.compile(r"\{\{#([^#]+)#\}\}")
|
|
|
|
|
|
def _has_upstream_reference(value: object, upstream_ids: set[str]) -> bool:
|
|
if isinstance(value, Mapping):
|
|
return any(_has_upstream_reference(child, upstream_ids) for child in value.values())
|
|
if isinstance(value, (list, tuple)):
|
|
if len(value) >= 2 and isinstance(value[0], str) and value[0] in upstream_ids:
|
|
return True
|
|
return any(_has_upstream_reference(child, upstream_ids) for child in value)
|
|
if isinstance(value, str):
|
|
return any(match.group(1).split(".", 1)[0] in upstream_ids for match in _VARIABLE_REF.finditer(value))
|
|
return False
|
|
|
|
|
|
def _input_selector(value: object, start_ids: set[str]) -> str | None:
|
|
if isinstance(value, (tuple, list)) and list(value) == ["sys", "query"]:
|
|
return "sys.query"
|
|
if (
|
|
isinstance(value, (tuple, list))
|
|
and len(value) >= 2
|
|
and isinstance(value[0], str)
|
|
and value[0] in start_ids
|
|
and isinstance(value[1], str)
|
|
):
|
|
return value[1]
|
|
return None
|
|
|
|
|
|
def _input_reference_value(selector: object, start_ids: set[str], inputs: Mapping[str, Any]) -> tuple[bool, Any]:
|
|
"""Read public mapping/list paths only; never evaluate code or object attributes."""
|
|
name = _input_selector(selector, start_ids)
|
|
if name is None or name not in inputs or not isinstance(selector, (list, tuple)):
|
|
return False, None
|
|
value = inputs[name]
|
|
for part in selector[2:]:
|
|
if isinstance(value, Mapping) and isinstance(part, str) and part in value:
|
|
value = value[part]
|
|
elif isinstance(value, list) and isinstance(part, str) and part.isdecimal() and int(part) < len(value):
|
|
value = value[int(part)]
|
|
else:
|
|
return False, None
|
|
return True, value
|
|
|
|
|
|
def _referenced_start_inputs(value: object, start_ids: set[str], inputs: Mapping[str, Any]) -> dict[str, Any]:
|
|
referenced: dict[str, Any] = {}
|
|
if isinstance(value, Mapping):
|
|
selector = _input_selector(value.get("value"), start_ids) if value.get("type") == "variable" else None
|
|
if selector is not None:
|
|
referenced[selector] = inputs.get(selector)
|
|
for child in value.values():
|
|
referenced.update(_referenced_start_inputs(child, start_ids, inputs))
|
|
elif isinstance(value, (list, tuple)):
|
|
selector = _input_selector(value, start_ids)
|
|
if selector is not None:
|
|
referenced[selector] = inputs.get(selector)
|
|
for child in value:
|
|
referenced.update(_referenced_start_inputs(child, start_ids, inputs))
|
|
elif isinstance(value, str):
|
|
for match in _VARIABLE_REF.finditer(value):
|
|
selector = _input_selector(match.group(1).split("."), start_ids)
|
|
if selector is not None:
|
|
referenced[selector] = inputs.get(selector)
|
|
return referenced
|
|
|
|
|
|
def _resolve_input_references(value: Any, start_ids: set[str], inputs: Mapping[str, Any]) -> Any:
|
|
"""Resolve known Start/query references for display, without executing a graph."""
|
|
if isinstance(value, Mapping):
|
|
found, resolved = (
|
|
_input_reference_value(value.get("value"), start_ids, inputs)
|
|
if value.get("type") == "variable"
|
|
else (False, None)
|
|
)
|
|
if found:
|
|
return {**value, "type": "constant", "value": resolved}
|
|
return {key: _resolve_input_references(child, start_ids, inputs) for key, child in value.items()}
|
|
if isinstance(value, list):
|
|
return [_resolve_input_references(child, start_ids, inputs) for child in value]
|
|
if isinstance(value, str):
|
|
|
|
def replace(match: re.Match[str]) -> str:
|
|
found, resolved = _input_reference_value(match.group(1).split("."), start_ids, inputs)
|
|
return str(resolved) if found else match.group(0)
|
|
|
|
return _VARIABLE_REF.sub(replace, value)
|
|
return value
|
|
|
|
|
|
def split_confirmations(inputs: Mapping[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
"""Separate the test-data form's confirmation checkboxes from the Start inputs."""
|
|
plain: dict[str, Any] = {}
|
|
confirmed: list[str] = []
|
|
for key, value in inputs.items():
|
|
if isinstance(key, str) and key.startswith(CONFIRM_FIELD_PREFIX):
|
|
if value is True:
|
|
confirmed.append(key.removeprefix(CONFIRM_FIELD_PREFIX))
|
|
continue
|
|
plain[key] = value
|
|
return plain, confirmed
|
|
|
|
|
|
def confirm_field_key(node_id: str) -> str:
|
|
return CONFIRM_FIELD_PREFIX + node_id
|
|
|
|
|
|
def _classify(node_type: str, data: Mapping[str, Any]) -> tuple[EffectTier, str, str]:
|
|
if node_type in _PURE_TYPES:
|
|
return "pure", "", ""
|
|
if node_type in _SANDBOXED_TYPES:
|
|
return "sandboxed", "", ""
|
|
if node_type in _MODEL_TYPES:
|
|
return "read", _model_target(data), ""
|
|
if node_type in _READ_TYPES:
|
|
return "read", "", ""
|
|
if node_type == "http-request":
|
|
return _http(data)
|
|
if node_type == "tool":
|
|
return _tool(data)
|
|
if node_type == "agent" and is_dify_agent_node_data(data):
|
|
return _agent_v2(data)
|
|
# Agents may call any tool; datasource/indexing and unknown types are not
|
|
# Workflow test material. All of them need an explicit confirmation.
|
|
return "side_effect", _model_target(data) or node_type, ""
|
|
|
|
|
|
def _agent_v2(data: Mapping[str, Any]) -> tuple[EffectTier, str, str]:
|
|
"""Name the selected graph binding without predicting nested runtime actions."""
|
|
target = "Dify Agent (binding unavailable)"
|
|
binding = data.get("agent_binding")
|
|
if isinstance(binding, Mapping):
|
|
agent_id, snapshot_id = binding.get("agent_id"), binding.get("current_snapshot_id")
|
|
if isinstance(agent_id, str) and agent_id.strip() and isinstance(snapshot_id, str) and snapshot_id.strip():
|
|
target = _truncate(f"Agent {agent_id} / snapshot {snapshot_id}")
|
|
return (
|
|
"side_effect",
|
|
target,
|
|
"May use its configured model and tools. Nested runtime targets cannot be predicted from this outer node.",
|
|
)
|
|
|
|
|
|
def _model_target(data: Mapping[str, Any]) -> str:
|
|
model = data.get("model")
|
|
if isinstance(model, Mapping):
|
|
provider = str(model.get("provider") or "").rsplit("/", 1)[-1]
|
|
name = str(model.get("name") or "")
|
|
return " / ".join(part for part in (provider, name) if part)
|
|
return ""
|
|
|
|
|
|
def _http(data: Mapping[str, Any]) -> tuple[EffectTier, str, str]:
|
|
method = str(data.get("method") or "get").lower()
|
|
url = str(data.get("url") or "")
|
|
host = _host(url)
|
|
target = f"{method.upper()} {host}".strip()
|
|
obj = _truncate(url)
|
|
if method in _READ_METHODS:
|
|
return "read", target, obj
|
|
if method in _DESTRUCTIVE_METHODS:
|
|
return "destructive", target, obj
|
|
return "side_effect", target, obj
|
|
|
|
|
|
def _host(url: str) -> str:
|
|
if "{{#" in url:
|
|
# A host taken from a variable is shown as written.
|
|
return url.split("://", 1)[-1].split("/", 1)[0]
|
|
try:
|
|
return urlsplit(url).hostname or url
|
|
except ValueError:
|
|
return url
|
|
|
|
|
|
def _tool(data: Mapping[str, Any]) -> tuple[EffectTier, str, str]:
|
|
provider_type = str(data.get("provider_type") or "")
|
|
provider = str(data.get("provider_name") or data.get("provider_id") or "").rsplit("/", 1)[-1]
|
|
tool_name = str(data.get("tool_name") or "")
|
|
tool_label = str(data.get("tool_label") or tool_name)
|
|
target = " / ".join(part for part in (provider, tool_label) if part)
|
|
obj = _constant_parameters(data.get("tool_parameters"))
|
|
if provider_type in {"workflow", "app"}:
|
|
# A child workflow or app can do anything its own graph does.
|
|
return "side_effect", target, obj
|
|
words = _words(tool_name) or _words(tool_label)
|
|
if words & _DESTRUCTIVE_WORDS:
|
|
return "destructive", target, obj
|
|
if words & _WRITE_WORDS:
|
|
return "side_effect", target, obj
|
|
if provider_type == "mcp":
|
|
# Remote names and cached annotations cannot authorize opaque actions.
|
|
# Retain explicit confirmation even for names such as docs_search.
|
|
return "side_effect", target, obj
|
|
if words & _READ_WORDS or provider_type == "dataset-retrieval":
|
|
return "read", target, obj
|
|
return "side_effect", target, obj
|
|
|
|
|
|
def _words(name: str) -> set[str]:
|
|
words: set[str] = set()
|
|
for part in re.split(r"[^A-Za-z0-9]+", name):
|
|
words.update(match.lower() for match in _WORD_RE.findall(part))
|
|
return words
|
|
|
|
|
|
def _constant_parameters(parameters: object) -> str:
|
|
if not isinstance(parameters, Mapping):
|
|
return ""
|
|
shown: list[str] = []
|
|
for key in sorted(parameters):
|
|
value = parameters[key]
|
|
if isinstance(value, Mapping):
|
|
if value.get("type") not in {"constant", "mixed"}:
|
|
continue
|
|
value = value.get("value")
|
|
if isinstance(value, (str, int, float, bool)) and str(value).strip() and not is_credential_param_key(str(key)):
|
|
shown.append(f"{key}={value}")
|
|
return _truncate(", ".join(shown))
|
|
|
|
|
|
def _truncate(text: str) -> str:
|
|
return text if len(text) <= _MAX_OBJECT_CHARS else text[: _MAX_OBJECT_CHARS - 1] + "…"
|
|
|
|
|
|
def _signature(
|
|
node_type: str,
|
|
tier: EffectTier,
|
|
target: str,
|
|
data: Mapping[str, Any],
|
|
account: str,
|
|
account_binding: str,
|
|
bound_inputs: Mapping[str, Any],
|
|
graph_binding: Mapping[str, Any] | None,
|
|
scope_binding: str,
|
|
code_identity: Mapping[str, Any] | None = None,
|
|
) -> str:
|
|
material: dict[str, Any] = {
|
|
"type": node_type,
|
|
"tier": tier,
|
|
"target": target,
|
|
"account": account,
|
|
"account_binding": account_binding,
|
|
"inputs": bound_inputs,
|
|
"dynamic_graph": graph_binding,
|
|
"scope_binding": scope_binding,
|
|
}
|
|
if code_identity is not None:
|
|
material["code_assessment"] = code_identity
|
|
# Bind all executable configuration, including HTTP headers and Tool form
|
|
# configurations. A field allowlist can silently miss a new target/account
|
|
# setting. Reuse the execution revision's canvas exclusions.
|
|
material["data"] = node_config_digest({key: value for key, value in data.items() if key not in {"title", "desc"}})
|
|
encoded = json.dumps(material, sort_keys=True, ensure_ascii=False, default=str).encode()
|
|
return hashlib.sha256(encoded).hexdigest()
|