mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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>
142 lines
6.4 KiB
Python
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)
|