mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(dify-builder): requirements analysis stops inventing endpoints and credentials
S5b (local E2E 2026-09-22, F2): analyze_goal's "a sensible default value per field" led the LLM to prefill render_api_url=https://api.yourcompany.com/... and Authorization: Bearer YOUR_API_KEY for a goal that named neither -- caught only because "yourcompany" happened to match an existing placeholder-URL regex; a plausible invented host would slip through and recreate ESQ1-302's launch failure one step upstream, in the requirements form itself. Adds services/dify_builder/agent/user_supplied.py (pure, no I/O): url_hosts, is_user_supplied_url, CREDENTIAL_PLACEHOLDER_RE, is_user_supplied_secret, trusted_text_for -- shared primitives for telling a literal the user actually wrote from one an LLM invented, used by this task and consumed by later tasks in the same plan. build.analyze_goal now tells the model never to invent such a field's default, and _scrub_invented_defaults deterministically blanks any top-level field whose value contains a URL host, or a credential-shaped or credential-keyed literal, not present in the goal text -- decided on hosts and literal text, never on LLM prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fc0f5cf8fd
commit
be24b00a2a
4 files changed
+367
-3
No files matched your search
@@ -18,7 +18,7 @@ from core.dify_builder.contract import ResourceOption
|
||||
from core.dify_builder.models import BuildNodesResult, MutationIntent
|
||||
from graphon.enums import BUILT_IN_NODE_TYPES
|
||||
from services.dify_builder import graph_ops
|
||||
from services.dify_builder.agent import form_schema, graph_translate, llm, resources
|
||||
from services.dify_builder.agent import form_schema, graph_translate, llm, resources, user_supplied
|
||||
from services.dify_builder.agent.model_resolver import resolve_model_instance
|
||||
from services.dify_builder.agent.resources import ResourceRef
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
@@ -41,7 +41,9 @@ def analyze_goal(
|
||||
system = (
|
||||
"You are a Dify workflow requirements analyst. Given a build goal, propose 3-6 "
|
||||
"clarifying requirement fields SHAPED BY THE GOAL, and a sensible default value per "
|
||||
f"field. {form_schema.FORM_FIELD_TYPE_GUIDANCE}"
|
||||
"field. Never invent a URL/endpoint, API key, token, password, or account/resource id "
|
||||
"the goal does not state -- give such a field an empty string as its default so the "
|
||||
f"user fills it in. {form_schema.FORM_FIELD_TYPE_GUIDANCE}"
|
||||
'Reply with ONLY JSON: {"fields": [{"key": "...", "label": "...", "type": "...", '
|
||||
'"options": ["..."]}], "values": {"<key>": <default>}}.'
|
||||
) + llm.json_language_instruction("field labels and values")
|
||||
@@ -53,7 +55,65 @@ def analyze_goal(
|
||||
values = data.get("values")
|
||||
if not isinstance(fields, list) or not isinstance(values, dict):
|
||||
return _degraded_form(goal_text)
|
||||
return {"fields": form_schema.reconcile_form_fields(fields, values), "values": values}
|
||||
scrubbed_values = _scrub_invented_defaults(values, goal_text)
|
||||
return {"fields": form_schema.reconcile_form_fields(fields, scrubbed_values), "values": scrubbed_values}
|
||||
|
||||
|
||||
# A dict key naming a credential -- checked against ``is_user_supplied_secret``
|
||||
# rather than the plain placeholder regex, so a realistic-looking but
|
||||
# never-stated secret (e.g. a made-up ``sk-live-...`` string) is still caught
|
||||
# even though it doesn't match any of CREDENTIAL_PLACEHOLDER_RE's shapes.
|
||||
_CREDENTIAL_KEY_RE = re.compile(r"(?i)(authorization|api[_-]?key|token|secret|password)")
|
||||
|
||||
# Mirrors the prefix ``user_supplied.is_user_supplied_secret`` strips, so a
|
||||
# literal is checked against CREDENTIAL_PLACEHOLDER_RE the same way regardless
|
||||
# of which of the two credential checks below is doing the looking.
|
||||
_AUTH_SCHEME_PREFIX_RE = re.compile(r"^(?:bearer|basic|token)\s+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_invented_literal(value: Any, key: str | None, goal_text: str, trusted_hosts: set[str]) -> bool:
|
||||
"""True when ``value`` (found under ``key``, possibly nested inside a
|
||||
field's dict/list default) is a URL or credential the LLM invented
|
||||
rather than one the goal actually states."""
|
||||
if isinstance(value, dict):
|
||||
return any(_is_invented_literal(v, k, goal_text, trusted_hosts) for k, v in value.items())
|
||||
if isinstance(value, list):
|
||||
return any(_is_invented_literal(item, None, goal_text, trusted_hosts) for item in value)
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if user_supplied.url_hosts(value) - trusted_hosts:
|
||||
return True
|
||||
stripped = _AUTH_SCHEME_PREFIX_RE.sub("", value, count=1)
|
||||
if user_supplied.CREDENTIAL_PLACEHOLDER_RE.search(stripped):
|
||||
return True
|
||||
if (
|
||||
key is not None
|
||||
and _CREDENTIAL_KEY_RE.search(key)
|
||||
and not user_supplied.is_user_supplied_secret(value, goal_text)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _scrub_invented_defaults(values: dict[str, Any], goal_text: str) -> dict[str, Any]:
|
||||
"""Blank any top-level field whose default is an invented URL or
|
||||
credential rather than one the goal states (S5b/F2: analysis prefilled
|
||||
``render_api_url=https://api.yourcompany.com/...`` and
|
||||
``Authorization: Bearer YOUR_API_KEY`` for a goal that named neither).
|
||||
|
||||
The whole field is replaced with ``""`` -- even one whose default was a
|
||||
dict -- so the user is prompted to fill it in themselves rather than the
|
||||
workflow running against a host or secret that doesn't exist.
|
||||
"""
|
||||
trusted_hosts = user_supplied.url_hosts(goal_text)
|
||||
blanked = [key for key, value in values.items() if _is_invented_literal(value, key, goal_text, trusted_hosts)]
|
||||
if not blanked:
|
||||
return values
|
||||
scrubbed = dict(values)
|
||||
for key in blanked:
|
||||
scrubbed[key] = ""
|
||||
logger.info("dify_builder: blanked invented default(s) for %s", ", ".join(sorted(blanked)))
|
||||
return scrubbed
|
||||
|
||||
|
||||
def propose_app_name(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Telling a user-supplied URL or credential from one an LLM invented.
|
||||
|
||||
Pure, no I/O -- these helpers make no network or model calls and import
|
||||
nothing from ``services`` (besides each other), ``flask``, or model-runtime,
|
||||
so they are safe to call from anywhere in Dify Builder's cognition.
|
||||
|
||||
ESQ1-302 traces to the Builder inventing a plausible-looking endpoint when a
|
||||
goal gave it none (``https://api.example.com/ppt/generate``); S5b/F2 shows
|
||||
the same failure one step earlier, in requirements analysis, which also
|
||||
invented an ``Authorization: Bearer YOUR_API_KEY`` header. These helpers let
|
||||
callers decide "did the user actually write this?" by comparing literal text
|
||||
against what the user actually typed -- never by asking an LLM to judge its
|
||||
own output (no control flow here is keyed on LLM prose).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# A URL literal as it appears embedded in freeform prose -- stops at
|
||||
# whitespace or a quoting/bracketing character so "https://a.com/x." at the
|
||||
# end of a sentence doesn't pull in trailing punctuation.
|
||||
_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE)
|
||||
|
||||
# A Dify workflow template reference, e.g. ``{{#node1.url#}}``. It names a
|
||||
# variable, not a literal value, so it can never be "invented" the way a
|
||||
# hardcoded URL or secret can -- it's already bound to whatever that
|
||||
# variable resolves to at runtime.
|
||||
_TEMPLATE_MARKER = "{{#"
|
||||
|
||||
_AUTH_SCHEME_PREFIX_RE = re.compile(r"^(?:bearer|basic|token)\s+", re.IGNORECASE)
|
||||
|
||||
# Case-insensitive tokens an LLM writes in place of a secret it doesn't have.
|
||||
CREDENTIAL_PLACEHOLDER_RE = re.compile(
|
||||
r"YOUR[_-]?(API[_-]?)?(KEY|TOKEN|SECRET)"
|
||||
r"|(API[_-]?KEY|TOKEN|SECRET)[_-]?HERE"
|
||||
r"|<[^>]+>"
|
||||
r"|REPLACE[_-]?ME"
|
||||
r"|CHANGE[_-]?ME"
|
||||
r"|x{6,}"
|
||||
r"|\*{6,}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def url_hosts(text: str) -> set[str]:
|
||||
"""Lowercase hostnames of every ``http(s)://…`` URL literally present in
|
||||
``text``. This is the trust set for a URL check: a host the user's own
|
||||
text actually names."""
|
||||
hosts: set[str] = set()
|
||||
for raw_url in _URL_RE.findall(text or ""):
|
||||
try:
|
||||
host = urlsplit(raw_url).hostname
|
||||
except ValueError:
|
||||
continue
|
||||
if host:
|
||||
hosts.add(host.lower())
|
||||
return hosts
|
||||
|
||||
|
||||
def is_user_supplied_url(url: str, trusted_text: str) -> bool:
|
||||
"""True iff ``url`` is something the user actually gave: its host
|
||||
appears among the URLs literally present in ``trusted_text``.
|
||||
|
||||
A Dify template reference (contains ``{{#``) is a variable, not a
|
||||
literal, so it is never a fabrication and always returns True.
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
if _TEMPLATE_MARKER in url:
|
||||
return True
|
||||
try:
|
||||
host = urlsplit(url).hostname
|
||||
except ValueError:
|
||||
return False
|
||||
if not host:
|
||||
return False
|
||||
return host.lower() in url_hosts(trusted_text)
|
||||
|
||||
|
||||
def is_user_supplied_secret(value: str, trusted_text: str) -> bool:
|
||||
"""True iff ``value`` -- after stripping a leading ``Bearer ``/``Basic
|
||||
``/``Token `` auth-scheme prefix -- is a literal the user actually
|
||||
wrote, rather than an LLM's placeholder for a secret it does not have.
|
||||
"""
|
||||
remainder = _AUTH_SCHEME_PREFIX_RE.sub("", value or "", count=1)
|
||||
if not remainder:
|
||||
return False
|
||||
if _TEMPLATE_MARKER in remainder:
|
||||
return True
|
||||
if CREDENTIAL_PLACEHOLDER_RE.search(remainder):
|
||||
return False
|
||||
return remainder in (trusted_text or "")
|
||||
|
||||
|
||||
def trusted_text_for(goal_text: str, requirements: Mapping[str, Any]) -> str:
|
||||
"""The text a candidate URL/credential is checked against: what the user
|
||||
actually submitted -- the goal prose plus the requirements form they
|
||||
filled in, as they submitted it (never anything an LLM wrote about
|
||||
either)."""
|
||||
return f"{goal_text}\n{json.dumps(dict(requirements), ensure_ascii=False)}"
|
||||
@@ -80,6 +80,110 @@ def test_analyze_goal_degrades_to_generic_field_on_boom():
|
||||
assert out["values"].get(out["fields"][0]["key"]) == "some goal"
|
||||
|
||||
|
||||
# The real S5b goal (ESQ1-302 / F2): no URL, no credential anywhere in it.
|
||||
_S5B_GOAL = (
|
||||
"Generate a PowerPoint presentation from a topic the user provides: an LLM writes the "
|
||||
"slide content, then the workflow POSTs the slides as JSON to our company's PPT rendering "
|
||||
"HTTP API, which returns the .pptx download link."
|
||||
)
|
||||
|
||||
|
||||
def test_scrub_invented_defaults_blanks_the_real_s5b_invented_endpoint_and_credential():
|
||||
values = {
|
||||
"tone": "Professional",
|
||||
"slide_count": 10,
|
||||
"render_api_url": "https://api.yourcompany.com/v1/ppt/render",
|
||||
"api_auth_header": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_API_KEY",
|
||||
},
|
||||
"include_speaker_notes": True,
|
||||
}
|
||||
|
||||
out = build._scrub_invented_defaults(values, _S5B_GOAL)
|
||||
|
||||
assert out["render_api_url"] == ""
|
||||
assert out["api_auth_header"] == ""
|
||||
assert out["tone"] == "Professional"
|
||||
assert out["slide_count"] == 10
|
||||
assert out["include_speaker_notes"] is True
|
||||
|
||||
|
||||
def test_scrub_invented_defaults_blanks_a_plausible_invented_host():
|
||||
values = {"render_api_url": "https://api.pptrender.io/v1/render"}
|
||||
|
||||
out = build._scrub_invented_defaults(values, _S5B_GOAL)
|
||||
|
||||
assert out["render_api_url"] == ""
|
||||
|
||||
|
||||
def test_scrub_invented_defaults_keeps_a_url_whose_host_the_goal_names():
|
||||
goal = "POST the finished slides to https://render.acme.internal/v2 and return the link"
|
||||
values = {"render_api_url": "https://render.acme.internal/v2/pptx"}
|
||||
|
||||
out = build._scrub_invented_defaults(values, goal)
|
||||
|
||||
assert out["render_api_url"] == "https://render.acme.internal/v2/pptx"
|
||||
|
||||
|
||||
def test_scrub_invented_defaults_keeps_a_credential_the_goal_states():
|
||||
goal = "Authenticate with the API key sk-live-abc123"
|
||||
values = {"api_key": "sk-live-abc123"}
|
||||
|
||||
out = build._scrub_invented_defaults(values, goal)
|
||||
|
||||
assert out["api_key"] == "sk-live-abc123"
|
||||
|
||||
|
||||
def test_scrub_invented_defaults_blanks_a_credential_the_goal_does_not_state():
|
||||
values = {"api_key": "sk-live-abc123"}
|
||||
|
||||
out = build._scrub_invented_defaults(values, _S5B_GOAL)
|
||||
|
||||
assert out["api_key"] == ""
|
||||
|
||||
|
||||
def test_analyze_goal_scrubs_invented_defaults_end_to_end(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
raw = {
|
||||
"fields": [
|
||||
{"key": "tone", "label": "Tone", "type": "text"},
|
||||
{"key": "slide_count", "label": "Slide count", "type": "number"},
|
||||
{"key": "render_api_url", "label": "Render API URL", "type": "text"},
|
||||
{"key": "api_auth_header", "label": "Auth header", "type": "json_object"},
|
||||
{"key": "include_speaker_notes", "label": "Include speaker notes", "type": "bool"},
|
||||
],
|
||||
"values": {
|
||||
"tone": "Professional",
|
||||
"slide_count": 10,
|
||||
"render_api_url": "https://api.yourcompany.com/v1/ppt/render",
|
||||
"api_auth_header": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_API_KEY",
|
||||
},
|
||||
"include_speaker_notes": True,
|
||||
},
|
||||
}
|
||||
|
||||
def fake_invoke_json(model, *, system, user, on_reasoning=None): # noqa: ARG001
|
||||
captured["system"] = system
|
||||
return raw
|
||||
|
||||
monkeypatch.setattr(build.llm, "invoke_json", fake_invoke_json)
|
||||
|
||||
out = build.analyze_goal(object(), _S5B_GOAL)
|
||||
|
||||
assert out["values"]["render_api_url"] == ""
|
||||
assert out["values"]["api_auth_header"] == ""
|
||||
assert out["values"]["tone"] == "Professional"
|
||||
assert out["values"]["slide_count"] == 10
|
||||
assert out["values"]["include_speaker_notes"] is True
|
||||
assert (
|
||||
"Never invent a URL/endpoint, API key, token, password, or account/resource id the goal does not state"
|
||||
in captured["system"]
|
||||
)
|
||||
|
||||
|
||||
def test_propose_plan_v1_returns_bullets():
|
||||
m = _FakeInstance([json.dumps({"plan": ["Ingest", "Summarize", "Emit"]})])
|
||||
assert build.propose_plan_v1(m, {"x": 1}) == ["Ingest", "Summarize", "Emit"]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Unit tests for services.dify_builder.agent.user_supplied.
|
||||
|
||||
These are the shared "did the user actually write this, or did the model
|
||||
invent it" primitives (ESQ1-302 / S5b / F1, F2): they underpin the
|
||||
requirements-analysis scrub in Task 1 and later tasks' checks on http-request
|
||||
URLs and headers. Pure functions, no I/O.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from services.dify_builder.agent import user_supplied as us
|
||||
|
||||
|
||||
def test_url_hosts_extracts_lowercase_hostnames_from_prose():
|
||||
text = "Call https://API.Example.com/v1 and also http://other.example.org:8080/x please."
|
||||
assert us.url_hosts(text) == {"api.example.com", "other.example.org"}
|
||||
|
||||
|
||||
def test_url_hosts_empty_set_when_no_urls_present():
|
||||
assert us.url_hosts("no urls in this goal at all") == set()
|
||||
|
||||
|
||||
def test_url_hosts_empty_text_is_empty_set():
|
||||
assert us.url_hosts("") == set()
|
||||
|
||||
|
||||
def test_is_user_supplied_url_true_when_host_matches_case_insensitively():
|
||||
trusted = "the goal mentions https://render.acme.internal/v2 as the target"
|
||||
assert us.is_user_supplied_url("https://Render.Acme.Internal/v2/pptx", trusted) is True
|
||||
|
||||
|
||||
def test_is_user_supplied_url_false_when_host_absent_from_trusted_text():
|
||||
assert us.is_user_supplied_url("https://api.pptrender.io/v1/render", "no url here") is False
|
||||
|
||||
|
||||
def test_is_user_supplied_url_empty_url_is_false():
|
||||
assert us.is_user_supplied_url("", "https://anything.example.com") is False
|
||||
|
||||
|
||||
def test_is_user_supplied_url_dify_template_is_always_true():
|
||||
# A template reference is a variable, not a literal -- it can't be "invented".
|
||||
assert us.is_user_supplied_url("{{#node1.node5_url#}}", "goal has no url at all") is True
|
||||
|
||||
|
||||
def test_credential_placeholder_re_matches_known_placeholder_shapes():
|
||||
for token in (
|
||||
"YOUR_API_KEY",
|
||||
"YOURKEY",
|
||||
"your-token",
|
||||
"API_KEY_HERE",
|
||||
"TOKEN_HERE",
|
||||
"<api-key>",
|
||||
"REPLACE_ME",
|
||||
"CHANGE-ME",
|
||||
"xxxxxxxx",
|
||||
"******",
|
||||
):
|
||||
assert us.CREDENTIAL_PLACEHOLDER_RE.search(token), token
|
||||
|
||||
|
||||
def test_credential_placeholder_re_does_not_match_a_real_looking_secret():
|
||||
assert us.CREDENTIAL_PLACEHOLDER_RE.search("sk-live-abc123") is None
|
||||
|
||||
|
||||
def test_is_user_supplied_secret_true_when_bearer_stripped_value_is_literal():
|
||||
assert us.is_user_supplied_secret("Bearer sk-live-abc123", "the key is sk-live-abc123") is True
|
||||
|
||||
|
||||
def test_is_user_supplied_secret_strips_basic_and_token_prefixes_case_insensitively():
|
||||
assert us.is_user_supplied_secret("basic sk-live-abc123", "sk-live-abc123") is True
|
||||
assert us.is_user_supplied_secret("TOKEN sk-live-abc123", "sk-live-abc123") is True
|
||||
|
||||
|
||||
def test_is_user_supplied_secret_false_for_placeholder_even_with_bearer_prefix():
|
||||
assert us.is_user_supplied_secret("Bearer YOUR_API_KEY", "anything at all") is False
|
||||
|
||||
|
||||
def test_is_user_supplied_secret_false_for_empty_or_prefix_only_value():
|
||||
assert us.is_user_supplied_secret("", "anything") is False
|
||||
assert us.is_user_supplied_secret("Bearer ", "anything") is False
|
||||
|
||||
|
||||
def test_is_user_supplied_secret_true_for_dify_template_reference():
|
||||
assert us.is_user_supplied_secret("{{#node1.api_key#}}", "goal text with nothing matching") is True
|
||||
|
||||
|
||||
def test_is_user_supplied_secret_false_when_remainder_not_in_trusted_text():
|
||||
assert us.is_user_supplied_secret("sk-live-abc123", "the goal names nothing like that") is False
|
||||
|
||||
|
||||
def test_trusted_text_for_combines_goal_and_requirements_json():
|
||||
out = us.trusted_text_for("build a thing", {"a": 1, "b": "x"})
|
||||
assert out == "build a thing\n" + json.dumps({"a": 1, "b": "x"}, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_trusted_text_for_empty_requirements():
|
||||
assert us.trusted_text_for("goal only", {}) == "goal only\n{}"
|
||||
Reference in new issue
Block a user