mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
* fix(gateway): reject forged framework-injection markers in run input
`is_genuine_user_message` treats `hide_from_ui` and a human `name="summary"`
as proof the framework authored a message, and `InputSanitizationMiddleware`
skips those — escaping a real reminder's blocks would corrupt trusted context.
Neither marker was server-owned, so an external caller could set either one and
place a raw `<system-reminder>` outside the user-input boundary markers, which
the lead-agent system prompt declares trusted internal framework data. The
`hide_from_ui` variant is also filtered out of the thread UI, so the forgery
was invisible where it landed.
Both markers are now stripped from untrusted input, on the run path and on the
thread-state mutation route that writes straight into a checkpoint. Framework
injection happens inside the graph and never crosses this boundary, so nothing
the framework does is affected, and `trusted_internal` callers (IM channels,
the MCP task-notification launcher) keep writing hidden messages.
HumanInputCard replies are the one legitimate external `hide_from_ui`: the
frontend sends it alongside a `human_input_response` payload, so a message
carrying a valid one keeps the marker. That buys no bypass — the predicate
already classifies those as genuine, so they stay sanitized.
The name check reuses the predicate's own `_SUMMARY_MESSAGE_NAME` rather than a
fourth copy of the literal, and matches by `isinstance` exactly as the predicate
does: `HumanMessageChunk` is a `HumanMessage` whose `type` is not `"human"`, so
a type-based check would leave that subclass's marker settable. `name` is only
reserved on human messages — on a ToolMessage it is the tool's own name.
Three tests in test_gateway_services.py and test_message_provenance.py asserted
that a caller-supplied `hide_from_ui` survives. That assumption was the bypass;
they now pin the opposite, with a genuinely caller-owned key kept alongside to
prove the stripper is surgical.
* fix(agents): sanitize every genuine user message, not only the newest
The input guardrail scanned backwards for the first genuine user message and
returned, so only the newest turn was ever sanitized. The transformation is
request-scoped (`wrap_model_call`, never written to state), so thread state
keeps the raw text: once a newer turn arrived, the previous turn's payload was
replayed to the model verbatim, outside the boundary markers the lead-agent
prompt declares trusted framework data. The guardrail therefore held for
exactly one model call.
Reaching it needed no forged metadata and no crafted request body — type the
payload in one turn, then send anything at all in the next. A single request
carrying two user messages did it in one shot, since every message but the last
was skipped.
`_process_request` now walks the whole list and `_sanitize_message` owns the
per-message work; every existing branch (the `original_user_content` split for
upload turns, the multimodal rfind fallback, the metadata repair) is unchanged.
Framework-injected messages stay excluded by `is_genuine_user_message`.
Unexpected errors now fail open per message rather than per request. Iterating
history widened the old blast radius: one unprocessable row would have dropped
sanitization for the whole request, handing an attacker the newest turn by
crafting an older one. `GraphBubbleUp` still propagates.
Side effect worth noting: each turn's rendering is now stable across model
calls. Previously a turn was wrapped on its own call and unwrapped on the next,
changing the prompt prefix behind the newest turn and defeating prompt caching.
test_only_processes_last_user_message pinned the old scope; it now pins that
every turn is processed, and keeps driving the `wrap_model_call` entry point.
* docs: record the message-metadata trust boundary and sanitization scope
`agents/middlewares/AGENTS.md` owns the depth for InputSanitizationMiddleware
and documented only the `original_user_content` half of its trust boundary. Left
alone it would teach an agent that `hide_from_ui` is caller-owned and that the
guardrail covers one turn — and the usual failure mode is an agent "restoring"
the behaviour it believes was lost. The entry now carries both markers, the
HumanInputCard exception, the whole-history scope, and the per-message fail-open
rule.
The note lives only there. The root and `backend/AGENTS.md` layers are
orientation that points at the module guides owning the depth, and
`backend/AGENTS.md` is inherited by every backend chain — prose added there
inflates more than twenty of them, and `scripts/check_agent_guidance.py` shows
the middlewares chain has about a kilobyte of room against its hard limit.
CHANGELOG.md and CHANGELOG_zh.md record it under Security, continuing the
existing prompt-injection lineage.
* fix(gateway): mark caller-hidden messages instead of stripping the marker
Review follow-up. Stripping a caller-supplied `hide_from_ui` closed the bypass
but broke three frontend senders that use the marker purely to keep a context
message out of the transcript: the quoted conversation context
(`buildHiddenConversationQuoteMessage`), the sidecar context prompt
(`buildHiddenSidecarContextMessage`), and the agent save command. None carries a
`human_input_response`, so the HumanInputCard carve-out did not cover them, and
nothing else hides them — `_is_branch_visible_message` and the frontend's
`isHiddenFromUIMessage` both key solely on `hide_from_ui`, and no backend reads
`conversation_quote_context` or `sidecar_context`. All three would have rendered
as user-visible chat bubbles.
The marker plays two roles and only one of them is a vulnerability. The security
requirement is that a caller-supplied marker cannot skip sanitization, not that
it cannot hide a message. So the roles are separated instead of the marker being
removed: the Gateway keeps it and stamps the server-owned `UNTRUSTED_INPUT_KEY`,
and the guardrail now asks `requires_input_sanitization` — the mark, else the
genuine-user test. Hidden stays hidden; untrusted content is sanitized either
way. The reserved `summary` name is handled the same way and no longer rewritten.
Marking rather than removing is also the safer shape in general: `hide_from_ui`
is read for presentation, journal persistence, memory filtering and IM outbound
as well, and this boundary should not silently change any of them.
`is_genuine_user_message` is deliberately left alone. `ToolReceiptMiddleware`
uses it for turn-boundary detection, where a caller's hidden context message must
keep counting as not user-authored; widening it there would move the ledger's
turn window. `requires_input_sanitization` sits beside it in `message_utils` so
the two questions can be compared.
The three tests that asserted a caller-supplied `hide_from_ui` is removed now
assert it survives and carries the mark — which restores the original intent of
the two provenance cases, whose comment already read "caller-owned keys must
survive".
* docs(gateway): rewrite normalize_input's docstring around the mark
Review follow-up. The paragraph still described the pre-4a3344f6 strip model and
contradicted both the implementation and the middlewares AGENTS.md paragraph
updated in that same commit: it called `hide_from_ui` server-owned, said
carrying it skips sanitization entirely, and repeated the premise this branch
disproved — that HumanInputCard replies are the only legitimate external use.
It now describes what the code does: the markers stay caller-owned and are
preserved because three frontend senders rely on `hide_from_ui` for hiding
alone, the message is stamped with `untrusted_input` instead, and
`requires_input_sanitization` sanitizes it anyway. `untrusted_input` joins the
server-owned inventory in the preceding paragraph, which is what makes the stamp
unforgeable and unclearable.
The three surrounding docstrings now also say that these functions mark as well
as strip; `_strip_external_message_metadata` had advertised only the removal,
leaving a reader no way to find the stamp from the name.
* fix(gateway): mark state writes whose message omits additional_kwargs
Review follow-up. The state-route half of the fix missed the most natural
request shape. `_strip_external_metadata_from_message_like` returned early when
`additional_kwargs` was absent or not a dict — there was nothing to strip — and
that early return also skipped the mark. A `POST /threads/{id}/state` body of
`{"values": {"messages": [{"role": "user", "name": "summary", "content":
"<system-reminder>…</system-reminder>"}]}}` therefore reached the checkpoint
unmarked. The messages reducer's `convert_to_messages` then supplies
`additional_kwargs={}`, so at model-call time `requires_input_sanitization` fell
back to `is_genuine_user_message`, which a `summary` name fails, and the forged
tag reached the model raw and outside the boundary markers.
A missing or non-dict `additional_kwargs` is now treated as empty for both the
strip and the mark. The identity return is kept for the case where nothing
changes, so an ordinary key-omitted message does not gain an empty dict just by
passing through. The run path was never affected: `normalize_input` coerces to
BaseMessage first, which always carries the dict.
Every existing state-write test supplied an `additional_kwargs` dict, which is
why this shape slipped through; the regression now covers it at the route and
end to end through the reducer into the guardrail.
While checking the neighbouring shapes, `_skips_input_guardrail` keyed off key
presence where `is_genuine_user_message` keys off truthiness, so
`hide_from_ui: False` — already covered without a mark — would have been
stamped. It now mirrors the predicate exactly, as its docstring claimed.
* docs(middlewares): compress the sanitization note to fit the guidance chain
main's growth left the middlewares AGENTS.md chain 84 bytes under its hard
limit, and the fuller wording did not fit. The load-bearing facts stay — the
markers are marked rather than stripped, and the scan covers every turn — since
those are the two an agent editing this middleware could otherwise get wrong.
The full model lives in the normalize_input, _mark_untrusted_framework_markers
and requires_input_sanitization docstrings.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
498 lines
23 KiB
Python
498 lines
23 KiB
Python
"""Input guardrail middleware for prompt-injection defense (issue #3630).
|
|
|
|
Escapes blocked XML-like tags in every genuine user message (e.g.
|
|
``<system>`` → ``<system>``) so they render as literal text instead
|
|
of structured-context markers. This preserves the user's intent ("how do
|
|
I use DeerFlow's <think> tag?") while neutralizing injection attempts —
|
|
the same de-identify-don't-reject strategy as AWS Bedrock's PII ANONYMIZE.
|
|
|
|
The whole conversation is covered, not only the newest turn: the transformation
|
|
is request-scoped, so thread state keeps the raw text, and a last-turn-only scan
|
|
would neutralize a payload for exactly one model call before replaying it
|
|
verbatim on the next.
|
|
|
|
Blocked: system-reserved tags (memory, analysis, etc.) + common injection
|
|
tags (system, instruction, role, etc.). Normal HTML/XML tags (<div>,
|
|
<span>) are NOT escaped.
|
|
|
|
Clean input is wrapped in plain-text boundary markers as a secondary
|
|
semantic defense (OWASP structured-prompt guidance).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import override
|
|
|
|
from langchain.agents import AgentState
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain.agents.middleware.types import (
|
|
ModelCallResult,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
)
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.errors import GraphBubbleUp
|
|
|
|
from deerflow.agents.middlewares.message_utils import requires_input_sanitization
|
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Finite set of blocked tag names: system-reserved + common injection patterns.
|
|
#
|
|
# Maintenance: when adding a new framework block tag that the system emits into
|
|
# model input, you MUST also update the expected count in
|
|
# test_input_sanitization_middleware.py::test_denylist_covers_framework_authority_blocks.
|
|
# The test pins the exact number of blocked tags so a new framework tag cannot
|
|
# be added without the corresponding regression guard.
|
|
_BLOCKED_TAG_NAMES: frozenset[str] = frozenset(
|
|
{
|
|
# Framework-injected structured/authority blocks. The lead-agent system
|
|
# prompt's "System-Context Confidentiality" section (agents/lead_agent/
|
|
# prompt.py) declares *every* such tag trusted internal data — it names a
|
|
# few then says "and all other structured tags". So the denylist must
|
|
# cover the framework's authority blocks as a class, not a hand-picked
|
|
# subset: any one of them, forged in untrusted input, mimics trusted
|
|
# framework context. Enumerated from the block tags the framework actually
|
|
# emits into model input (system prompt + hidden-context/reminder
|
|
# middlewares) and pinned against drift by
|
|
# test_input_sanitization_middleware.py::test_denylist_covers_framework_authority_blocks.
|
|
# Both spellings of the reminder block are covered: "system-reminder"
|
|
# (dynamic-context) and "system_reminder" (todo/terminal middlewares).
|
|
#
|
|
# Subagents share this denylist: build_subagent_runtime_middlewares reuses
|
|
# the same _build_runtime_middlewares base, so both sanitization paths guard
|
|
# subagent model input too. The subagent system-prompt blocks
|
|
# (file_editing_workflow / guidelines / output_format / working_directory)
|
|
# are therefore authority blocks of the same class as the lead-agent ones.
|
|
"system-reminder",
|
|
"system_reminder",
|
|
"memory",
|
|
"current_date",
|
|
"think",
|
|
"analysis",
|
|
"role",
|
|
"soul",
|
|
"self_update",
|
|
"thinking_style",
|
|
"clarification_system",
|
|
"critical_reminders",
|
|
"response_style",
|
|
"citations",
|
|
"current_uploads",
|
|
"subagent_system",
|
|
"skill_system",
|
|
"skill_index",
|
|
"available_skills",
|
|
"disabled_skills",
|
|
"memory_tool_system",
|
|
"todo_list_system",
|
|
"durable_context_data",
|
|
"slash_skill_activation",
|
|
"mcp_routing_hints",
|
|
"available-deferred-tools",
|
|
"goal_continuation",
|
|
"background_task_event",
|
|
"file_editing_workflow",
|
|
"guidelines",
|
|
"output_format",
|
|
"working_directory",
|
|
# Subagent system-prompt block (general_purpose.py): declares the task
|
|
# tool off-limits. Forging this in untrusted input could trick the
|
|
# model into believing it has (or lacks) tool restrictions it does not.
|
|
"tool_restrictions",
|
|
# Subagent report-contract blocks (subagents/report_contract.py, RFC
|
|
# #4651 PR3): injected by the executor into every subagent system
|
|
# prompt (the criteria pointer note carries no criterion values —
|
|
# those stay in the untrusted task message). Forging them in
|
|
# untrusted input could impersonate the verification contract (e.g.
|
|
# pre-declaring acceptance criteria as met).
|
|
"report_contract",
|
|
"acceptance_criteria",
|
|
# Common prompt-injection tag patterns
|
|
"system",
|
|
"instruction",
|
|
"important",
|
|
"override",
|
|
"ignore",
|
|
"prompt",
|
|
}
|
|
)
|
|
|
|
# Matches a full blocked tag: <tag>, </tag>, <tag attrs>, <tag/>, bare <tag
|
|
_BLOCKED_TAG_PATTERN = re.compile(
|
|
r"<\s*/?\s*(?:" + "|".join(re.escape(t) for t in sorted(_BLOCKED_TAG_NAMES)) + r")\b[^>]*>?",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Plain-text boundary markers (OWASP structured-prompt guidance).
|
|
_USER_INPUT_BEGIN = "--- BEGIN USER INPUT ---"
|
|
_USER_INPUT_END = "--- END USER INPUT ---"
|
|
|
|
# Neutralized forms injected when the user's text already contains a marker.
|
|
# These look visually similar but do not match the real boundary delimiters.
|
|
_NEUTRALIZED_BEGIN = "[BEGIN USER INPUT]"
|
|
_NEUTRALIZED_END = "[END USER INPUT]"
|
|
|
|
# Matches either boundary token as a standalone line or embedded in text.
|
|
_BOUNDARY_TOKEN_RE = re.compile(
|
|
re.escape(_USER_INPUT_BEGIN) + r"|" + re.escape(_USER_INPUT_END),
|
|
)
|
|
|
|
|
|
def _escape_tag_match(match: re.Match) -> str:
|
|
"""Escape < and > in a blocked-tag match so it renders as literal text."""
|
|
return match.group(0).replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def _neutralize_boundary_tokens(text: str) -> str:
|
|
"""Replace real BEGIN/END USER INPUT markers with look-alike inert forms."""
|
|
return _BOUNDARY_TOKEN_RE.sub(
|
|
lambda m: _NEUTRALIZED_BEGIN if m.group(0) == _USER_INPUT_BEGIN else _NEUTRALIZED_END,
|
|
text,
|
|
)
|
|
|
|
|
|
def neutralize_untrusted_tags(text: str) -> str:
|
|
"""Neutralize framework/injection control tokens in untrusted text.
|
|
|
|
Shared primitive for any content that originates outside the trust boundary
|
|
and is about to enter the model context as *data* — currently the genuine
|
|
user message (via :func:`frame_untrusted_text`) and remote tool results
|
|
(web_fetch / web_search and friends, via
|
|
:class:`ToolResultSanitizationMiddleware`).
|
|
|
|
Applies exactly the two structural defenses, and nothing else:
|
|
|
|
* blocked framework/injection tags (e.g. ``<system-reminder>``) are
|
|
HTML-escaped to ``<system-reminder>`` so they lose their structural
|
|
meaning while staying human-readable;
|
|
* the plain-text ``--- BEGIN/END USER INPUT ---`` boundary markers are
|
|
neutralized so untrusted content cannot forge or break out of the
|
|
user-input boundary.
|
|
|
|
It intentionally does **not** wrap the text in boundary markers: that
|
|
framing is specific to the user message. Empty/whitespace-only text is
|
|
returned unchanged so callers do not emit marker noise.
|
|
"""
|
|
if not text.strip():
|
|
return text
|
|
text = _BLOCKED_TAG_PATTERN.sub(_escape_tag_match, text)
|
|
return _neutralize_boundary_tokens(text)
|
|
|
|
|
|
def frame_untrusted_text(text: str) -> str:
|
|
"""Sanitize untrusted text, then wrap it in user-input boundary markers.
|
|
|
|
* Empty/whitespace-only → return unchanged (no marker noise).
|
|
* Blocked tags → HTML-escape ``<``/``>`` (e.g. ``<system>`` → ``<system>``).
|
|
* Boundary tokens in user text → neutralized so they cannot forge boundaries.
|
|
* Already wrapped (strict prefix+suffix) → return text unchanged (idempotent).
|
|
* Otherwise → wrap in boundary markers.
|
|
"""
|
|
if not text.strip():
|
|
return text
|
|
text = _BLOCKED_TAG_PATTERN.sub(_escape_tag_match, text)
|
|
# Idempotency: only skip if text is *exactly* wrapped (prefix+suffix),
|
|
# not if the user merely typed the begin token somewhere.
|
|
if text.startswith(_USER_INPUT_BEGIN) and text.endswith(_USER_INPUT_END):
|
|
# Still neutralize boundary tokens in the inner content — a user
|
|
# can forge the outer wrapping to bypass the neutralization below
|
|
# and inject inner boundary markers (break-out attack).
|
|
inner = text[len(_USER_INPUT_BEGIN) : -len(_USER_INPUT_END)]
|
|
neutralized_inner = _neutralize_boundary_tokens(inner)
|
|
if neutralized_inner == inner:
|
|
return text
|
|
return f"{_USER_INPUT_BEGIN}{neutralized_inner}{_USER_INPUT_END}"
|
|
# Neutralize any boundary tokens the user may have embedded, preventing
|
|
# both self-suppression (begin token skips wrapping) and break-out
|
|
# (end token creates a premature boundary inside the payload).
|
|
text = _neutralize_boundary_tokens(text)
|
|
return f"{_USER_INPUT_BEGIN}\n{text}\n{_USER_INPUT_END}"
|
|
|
|
|
|
def _check_user_content(text: str) -> str:
|
|
"""Backward-compatible internal alias for untrusted text framing."""
|
|
return frame_untrusted_text(text)
|
|
|
|
|
|
class InputSanitizationMiddleware(AgentMiddleware[AgentState]):
|
|
"""Guardrail middleware that escapes prompt-injection tags in user input.
|
|
|
|
Blocked tags are HTML-escaped (not rejected) so the user's intent is
|
|
preserved while the tags lose their semantic significance. Clean input
|
|
is wrapped in plain-text boundary markers. Transformation is temporary
|
|
(wrap_model_call) — never written to state, which is why every genuine
|
|
user message is re-sanitized on each call rather than only the newest.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _extract_text_from_content(content: str | list) -> tuple[str, list | None]:
|
|
"""Extract concatenated text from a plain-string or content-block-list.
|
|
|
|
Returns ``(text, extracted_blocks)``. *extracted_blocks* is None when
|
|
*content* is a string, or the list of text-content blocks when a list.
|
|
|
|
A list can hold bare ``str`` items next to content-block dicts
|
|
(``message_content_to_text`` treats both as text, and some IM/SDK
|
|
clients send exactly that shape), so bare strings are collected too —
|
|
skipping them would skip sanitization entirely for that message.
|
|
"""
|
|
if isinstance(content, str):
|
|
return content, None
|
|
if not isinstance(content, list):
|
|
return "", None
|
|
text_parts: list[str] = []
|
|
text_blocks: list[dict | str] = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
if not block: # skip empty items — matches message_content_to_text behaviour
|
|
continue
|
|
text_parts.append(block)
|
|
text_blocks.append(block)
|
|
elif isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
text = block["text"]
|
|
if not text: # skip empty blocks — matches message_content_to_text behaviour
|
|
continue
|
|
text_parts.append(text)
|
|
text_blocks.append(block)
|
|
return "\n".join(text_parts), text_blocks
|
|
|
|
@staticmethod
|
|
def _rebuild_content(
|
|
original_content: list,
|
|
processed_text: str,
|
|
text_blocks: list,
|
|
) -> list:
|
|
"""Replace text blocks with a single merged text block, preserving interleaved non-text blocks.
|
|
|
|
For ``[text, image, text]`` the image block between the two text blocks
|
|
is kept in place — only the text blocks are collapsed into one.
|
|
"""
|
|
text_block_ids = {id(b) for b in text_blocks}
|
|
first = last = None
|
|
for i, block in enumerate(original_content):
|
|
if id(block) in text_block_ids:
|
|
if first is None:
|
|
first = i
|
|
last = i
|
|
if first is None:
|
|
return original_content
|
|
result: list = [*original_content[:first], {"type": "text", "text": processed_text}]
|
|
# Re-insert any non-text blocks that sat between text blocks
|
|
for i in range(first + 1, last + 1):
|
|
if id(original_content[i]) not in text_block_ids:
|
|
result.append(original_content[i])
|
|
result.extend(original_content[last + 1 :])
|
|
return result
|
|
|
|
def _sanitize_message(self, msg: HumanMessage) -> HumanMessage | None:
|
|
"""Return a sanitized copy of *msg*, or None when nothing needs changing.
|
|
|
|
Blocked tags are HTML-escaped (not rejected) so the user's intent is
|
|
preserved while the tags lose their semantic significance. The original
|
|
message is never mutated.
|
|
"""
|
|
content = msg.content
|
|
text_content, text_blocks = self._extract_text_from_content(content)
|
|
|
|
# No text at all (e.g. image-only message) — pass through
|
|
if not text_content and not isinstance(content, str):
|
|
logger.debug("_sanitize_message: no text content in message — passing through")
|
|
return None
|
|
|
|
# Sanitize only the user's original input when available (set by
|
|
# UploadsMiddleware before it prepends the <current_uploads> block),
|
|
# so server-injected trusted blocks are never scanned for blocked
|
|
# tags. Fall back to full-content scanning only when the marker is
|
|
# absent — UploadsMiddleware sets it on upload turns, so plain text
|
|
# messages without uploads won't have it. Full-content scanning is
|
|
# safe for those: no server-injected <current_uploads> block exists
|
|
# to accidentally escape.
|
|
preserved_kwargs = dict(msg.additional_kwargs or {})
|
|
original_user_content = preserved_kwargs.get(ORIGINAL_USER_CONTENT_KEY)
|
|
if isinstance(original_user_content, str) and original_user_content:
|
|
processed_user = _check_user_content(original_user_content)
|
|
if processed_user != original_user_content:
|
|
# Replace only the user's text suffix within the full
|
|
# content — server-prepended blocks stay untouched.
|
|
idx = text_content.rfind(original_user_content)
|
|
if idx >= 0:
|
|
processed = text_content[:idx] + processed_user
|
|
else:
|
|
# _extract_text_from_content and message_content_to_text
|
|
# disagreed on text extraction — rfind failed (only
|
|
# reachable for multimodal list content; see Decision 18).
|
|
if isinstance(content, list) and len(content) >= 2:
|
|
# content[0] is the server-injected
|
|
# <current_uploads> block (UploadsMiddleware
|
|
# prepends it as the first element for list
|
|
# content). Sanitize only user blocks (content[1:])
|
|
# and rebuild directly — _rebuild_content only
|
|
# handles type:"text" blocks and would miss raw
|
|
# strings or non-standard dict blocks that
|
|
# message_content_to_text sees.
|
|
logger.warning(
|
|
"rfind failed on multimodal content; sanitizing user content blocks individually",
|
|
)
|
|
new_content: list = [content[0]]
|
|
for block in content[1:]:
|
|
if isinstance(block, str):
|
|
new_content.append(neutralize_untrusted_tags(block))
|
|
elif isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
sanitized = neutralize_untrusted_tags(block["text"])
|
|
if sanitized != block["text"]:
|
|
new_content.append({**block, "text": sanitized})
|
|
else:
|
|
new_content.append(block)
|
|
else:
|
|
new_content.append(block)
|
|
return HumanMessage(
|
|
content=new_content,
|
|
id=msg.id,
|
|
name=msg.name,
|
|
additional_kwargs=preserved_kwargs,
|
|
)
|
|
# Cannot distinguish server block from user blocks
|
|
# (non-list content or len(content) < 2).
|
|
# Degrade to full-content sanitization — server
|
|
# block may be escaped (UX degradation) but user
|
|
# forgeries are still neutralized (no security
|
|
# regression).
|
|
logger.warning(
|
|
"rfind failed with original_user_content set; cannot distinguish blocks, falling back to full-content sanitization",
|
|
)
|
|
processed = _check_user_content(text_content)
|
|
else:
|
|
processed = text_content # no change needed
|
|
elif isinstance(original_user_content, str):
|
|
# Key is present but empty string (e.g. file upload with no
|
|
# text input). No user text to sanitize; server-injected
|
|
# blocks must survive untouched.
|
|
processed = text_content
|
|
else:
|
|
processed = _check_user_content(text_content) # fallback
|
|
|
|
if processed == text_content:
|
|
# Already clean / already wrapped — no override needed
|
|
return None
|
|
|
|
if text_blocks:
|
|
new_content = self._rebuild_content(content, processed, text_blocks)
|
|
else:
|
|
new_content = processed
|
|
|
|
# Preserve the pre-sanitization user text so downstream consumers that
|
|
# must see the genuine input (slash skill activation, regenerate) can
|
|
# recover it after the BEGIN/END wrapping. Keep a valid value set by
|
|
# UploadsMiddleware or an IM channel, but repair malformed metadata so
|
|
# persistence never falls back to the wrapped model-facing content.
|
|
if not isinstance(original_user_content, str):
|
|
if ORIGINAL_USER_CONTENT_KEY in preserved_kwargs:
|
|
logger.warning(
|
|
"InputSanitizationMiddleware replaced non-string %s metadata: type=%s",
|
|
ORIGINAL_USER_CONTENT_KEY,
|
|
type(original_user_content).__name__,
|
|
)
|
|
preserved_kwargs[ORIGINAL_USER_CONTENT_KEY] = message_content_to_text(content)
|
|
logger.debug(
|
|
"InputSanitizationMiddleware: original=%r -> processed=%r",
|
|
content if isinstance(content, str) else "[content-blocks]",
|
|
processed,
|
|
)
|
|
return HumanMessage(
|
|
content=new_content,
|
|
id=msg.id,
|
|
name=msg.name,
|
|
additional_kwargs=preserved_kwargs,
|
|
)
|
|
|
|
def _process_request(self, request: ModelRequest) -> ModelRequest:
|
|
"""Return a request with every genuine user message sanitized.
|
|
|
|
Each genuine message is processed, not just the newest one. The
|
|
transformation is request-scoped, so thread state keeps the raw text:
|
|
sanitizing only the last turn would make the guardrail last exactly one
|
|
turn, replaying an earlier turn's payload to the model verbatim — and
|
|
outside the boundary markers, which the lead-agent prompt declares
|
|
trusted internal framework data. Covering the whole history also keeps
|
|
each turn's rendering stable across model calls, so the prompt prefix no
|
|
longer changes shape behind the newest turn.
|
|
|
|
Scope comes from ``requires_input_sanitization``: framework-injected
|
|
messages stay excluded, because escaping their blocks would corrupt
|
|
trusted context, while a caller-supplied message is covered even when it
|
|
carries a framework marker — the Gateway marks those on the way in.
|
|
"""
|
|
messages = list(request.messages)
|
|
changed = False
|
|
for index, msg in enumerate(messages):
|
|
if not requires_input_sanitization(msg):
|
|
if isinstance(msg, HumanMessage):
|
|
logger.debug(
|
|
"_process_request: skipping non-genuine HumanMessage at pos=%d name=%s hide_from_ui=%s content_preview=%.80r",
|
|
index,
|
|
msg.name,
|
|
msg.additional_kwargs.get("hide_from_ui"),
|
|
msg.content,
|
|
)
|
|
continue
|
|
logger.debug("_process_request: found genuine user message at pos=%d content=%.120r", index, msg.content)
|
|
# Recover per message rather than per request. Unexpected errors
|
|
# fail open, and one unprocessable history row must not widen that
|
|
# into "no sanitization this turn" — an attacker who can land such a
|
|
# row would otherwise buy themselves an unescaped newest turn.
|
|
try:
|
|
sanitized = self._sanitize_message(msg)
|
|
except GraphBubbleUp:
|
|
raise
|
|
except Exception:
|
|
logger.warning(
|
|
"Input guardrail failed on message at pos=%d; leaving it unchanged",
|
|
index,
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
if sanitized is None:
|
|
continue
|
|
messages[index] = sanitized
|
|
changed = True
|
|
if not changed:
|
|
return request
|
|
return request.override(messages=messages)
|
|
|
|
def _try_process(self, request: ModelRequest) -> ModelRequest:
|
|
"""Sanitize request; fail-open on unexpected errors.
|
|
|
|
GraphBubbleUp propagates; other exceptions return the original request.
|
|
"""
|
|
try:
|
|
return self._process_request(request)
|
|
except GraphBubbleUp:
|
|
raise
|
|
except Exception:
|
|
logger.warning(
|
|
"Input guardrail processing failed; passing original request to model",
|
|
exc_info=True,
|
|
)
|
|
return request
|
|
|
|
@override
|
|
def wrap_model_call(
|
|
self,
|
|
request: ModelRequest,
|
|
handler: Callable[[ModelRequest], ModelResponse],
|
|
) -> ModelCallResult:
|
|
return handler(self._try_process(request))
|
|
|
|
@override
|
|
async def awrap_model_call(
|
|
self,
|
|
request: ModelRequest,
|
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
) -> ModelCallResult:
|
|
return await handler(self._try_process(request))
|