Files
dify/api/services/dify_builder/credentials.py
Charles YaoandClaude Opus 5 59cc7043a3 fix(dify-builder): a rejected edit tells the next attempt what the engine said
At edit.plan_approval the gate re-reads an UNCHANGED draft, so edit_rules and
graph are byte-identical on every re-approval, and _change_not_applied stored
nothing about WHY the port refused the write. The live F4 user approved the
same plan three times and got a byte-identical card each time.

Add DifyBuilderContext.last_edit_rejection: the raw text of the
DraftWouldNotStartError/ValueError the port raised, capped at 2000 chars,
stored by _change_not_applied and passed into build_edit_intents, which quotes
it into the user prompt as a "PREVIOUS ATTEMPT REJECTED BY THE ENGINE" block.
It is engine-sourced by construction -- a PreflightError's every message is
built by re-validating a credentials.redact_node_config copy of the node, and
graph_ops' ValueErrors carry only paths, ids and type names -- so no model
prose enters. Nothing branches on it. Cleared on a successful write, on a new
edit goal, and by the recovery reset.

Close the silent false success that left, rather than only recording it.
new_preflight_problems now takes the ids whose NODE DATA the batch wrote and
gives them no exemption at all: a node this batch wrote must be startable, not
merely no worse than it was. Keyed on locations alone, a repair that swaps one
bad value for another at the same field path -- or fixes one of three missing
fields -- produced nothing new, was written, and the card said "Applied the
changes" over a draft that dies at Graph.init. Since a repair's culprit is by
definition already invalid, that was Fix's main line. Every OTHER node keeps
the location-keyed exemption, so a defect the user left on the canvas still
cannot veto an unrelated change.

That set is deliberately narrower than a change set, on both sides (graph_ops
.DryRun gains the field filter_applicable already computed and discarded;
apply_repair keeps a separate written_nodes beside changed_nodes). connect is
excluded, because apply_connect reports both endpoints and an edge cannot
change either one's validate_node_config verdict -- counting them would let
merely wiring an already-broken node veto the batch. The heal is excluded too,
because heal_nodes_for_preflight scans the whole graph and a node it merely
normalized is not one this batch is answerable for.

Also, from the reviews of preflight.py: route an intent's config/value through
redact_node_config before a structural rejection line inlines it; key a
non-ValidationError crash on its truncated message as well as its class, and
keep that message out of the text the model is shown; and correct the
_CRASH_LOCATION comment -- verified against graphon, the rule is a config key
present AND type absent-or-invalid.

Finally, stop the docstrings overclaiming: redaction covers authorization,
headers and params, and a secret typed into a request body value is NOT
covered and reaches the stored text. Widening the profile is the wrong fix --
it shares the sentinel guard that refuses any write carrying REDACTED, so it
would make http-request bodies uneditable. Recorded in credentials.py as a
follow-up needing a separate persistence-only profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:40:15 -07:00

142 lines
6.4 KiB
Python

"""Keeping a node's secrets out of the Builder's prompts -- and keeping the
model's stand-in for them out of the draft.
The Builder inlines a node's ``data`` into an LLM prompt so an edit can leave
the fields it was not asked about byte-identical. An http-request node's
``data`` is also where a live bearer token or API key sits, so that inlining
has to stop at the credential.
TWO HALVES, AND NEITHER WORKS ALONE
-----------------------------------
``redact_node_config`` replaces a secret VALUE with ``REDACTED`` while keeping
its KEY: the model still sees that an ``Authorization`` header exists and can
reason about it, but never learns the token.
Redaction on its own creates a worse bug than the leak. The model reads
``Authorization:__DIFY_BUILDER_REDACTED__``, decides to rewrite ``headers``,
and writes the sentinel back -- destroying a working credential and leaving a
node that fails at run time with an unrecognizable token. So
``carries_redaction`` is the other half: ``graph_ops.validate_intent_args``
refuses any mutation whose value carries the sentinel, and the stored value
stands. The refusal is keyed on this exact, generated string -- never on
anything the model says about its own intentions.
The sentinel is deliberately ugly and unmistakable. It must never be
confusable with a real credential, and it must survive a JSON round trip
unchanged (pure ASCII, nothing JSON escapes), because the refusal check
serializes whatever it is handed and looks for it verbatim.
SCOPE, AND WHAT IS DELIBERATELY OUTSIDE IT
-----------------------------------------
This module knows only what the graph itself carries, and only in three places:
an http-request node's ``authorization`` block and its ``headers`` / ``params``
lines. Those are where a workflow author types a secret into a FIELD THAT EXISTS
TO HOLD ONE. Credentials held by a provider or a tool live outside the graph and
never reach a prompt this way.
NOT covered: a secret typed into a request ``body`` value (``body.data[].value``
-- e.g. a JSON payload with an ``api_key`` in it). Verified: it reaches
``preflight``'s refusal text verbatim, because pydantic's ``input_value`` repr
for a model-level error is the node's whole ``data``. Callers must not claim
otherwise.
It is not closed here on purpose, and the obvious fix makes things worse.
``graph_ops.validate_intent_args`` refuses any write carrying ``REDACTED``
(that guard is what stops the model handing the placeholder back and destroying
a live credential). Redaction and that guard are the same profile, so putting
body values in scope would make http-request BODIES UNEDITABLE: the model would
only ever see the sentinel, write it back, and be refused every time -- and a
request body is ordinary, frequently-edited content, not a credential field.
Closing it properly needs a second, persistence-and-prompt-only redaction
profile that the write guard does NOT consult, which is a design change beyond
the plan this comment was written in. Tracked as a follow-up.
"""
import copy
import json
from typing import Any
__all__ = ["REDACTED", "carries_redaction", "redact_node_config"]
REDACTED = "__DIFY_BUILDER_REDACTED__"
# graphon's ``HttpRequestNodeAuthorizationConfig`` (nodes/http_request/
# entities.py:40-43) is ``{type, api_key, header}``. ``type`` ("bearer" /
# "basic" / "custom") and ``header`` (a header NAME) are structure the model
# needs; everything else in that block is treated as the secret, so a field
# added there later is redacted by default rather than leaked by default.
_SAFE_AUTHORIZATION_CONFIG_KEYS = frozenset({"type", "header"})
# Newline-separated ``name: value`` strings, split on the FIRST colon by
# graphon (``executor._parse_header_string`` and ``_init_params``,
# nodes/http_request/executor.py:179-230).
_HEADER_LINE_KEYS = ("headers", "params")
def redact_node_config(config: dict[str, Any]) -> dict[str, Any]:
"""A copy of ``config`` with every credential value replaced by ``REDACTED``.
Returns a new mapping and never mutates ``config`` or anything reachable
from it -- callers hand in a view derived from the live draft graph, whose
nested values are shared references with it.
"""
out = dict(config)
authorization = out.get("authorization")
if isinstance(authorization, dict):
out["authorization"] = _redact_authorization(authorization)
for key in _HEADER_LINE_KEYS:
value = out.get(key)
if isinstance(value, str) and value:
out[key] = _redact_header_lines(value)
return out
def carries_redaction(value: Any) -> bool:
"""True when ``value`` -- at any depth -- contains the redaction sentinel.
Serializing and substring-matching covers a bare string, a rewritten
``authorization`` dict and a list of header entries with one rule instead
of three, and cannot miss a nesting shape nobody thought of.
"""
if isinstance(value, str):
return REDACTED in value
try:
text = json.dumps(value, ensure_ascii=False, default=str)
except (TypeError, ValueError):
text = str(value)
return REDACTED in text
def _redact_authorization(authorization: dict[str, Any]) -> dict[str, Any]:
out = copy.deepcopy(authorization)
config = out.get("config")
if isinstance(config, dict):
out["config"] = {k: (v if k in _SAFE_AUTHORIZATION_CONFIG_KEYS else REDACTED) for k, v in config.items()}
return out
def _redact_header_lines(text: str) -> str:
"""Keep every header/param NAME and drop EVERY non-empty VALUE.
Every value, not the suspicious-looking ones. Deciding by name would mean a
denylist (``Authorization``, ``X-Api-Key``, ...) and a denylist misses
``X-Company-Token``, ``Cookie``, and whatever this tenant calls its header
-- one miss is a leaked credential, so the rule has no exceptions. The
known cost is that a harmless ``Content-Type: application/json`` is
withheld too, and the model cannot usefully rewrite a content type through
``headers``. That is the intended trade; please do not "improve" it into a
denylist.
A line with no colon, or with an empty value, carries no secret and is
left exactly as it is -- graphon reads both as "name with an empty value"
and the model may need to see that.
"""
lines = []
for line in text.splitlines():
name, separator, value = line.partition(":")
if separator and value.strip():
lines.append(f"{name}:{REDACTED}")
else:
lines.append(line)
return "\n".join(lines)