diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 9d99344c8..695c7cc6d 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -4,6 +4,7 @@ import asyncio import atexit import concurrent.futures import copy +import html import json import logging import math @@ -515,11 +516,11 @@ def _build_staleness_section( lines: list[str] = [] for fact in stale_candidates: fid = fact.get("id", "?") - cat = str(fact.get("category", "context")).strip() or "context" + cat = html.escape(str(fact.get("category", "context")).strip() or "context") conf = fact.get("confidence", 0.0) created_raw = fact.get("createdAt", "") created_short = created_raw[:10] if isinstance(created_raw, str) and len(created_raw) >= 10 else created_raw - content = str(fact.get("content", "")) + content = html.escape(str(fact.get("content", ""))) lines.append(f'- [{fid} | {cat} | {conf:.2f} | {created_short}] "{content}"') return STALENESS_REVIEW_PROMPT.format( stale_facts="\n".join(lines), @@ -575,10 +576,10 @@ def _build_consolidation_section( for fact in group[:max_sources]: fid = fact.get("id", "?") conf = _coerce_source_confidence(fact) - content = str(fact.get("content", "")) + content = html.escape(str(fact.get("content", ""))) lines.append(f'- [{fid} | {conf:.2f}] "{content}"') shown = min(len(group), max_sources) - parts.append(f'\n' + "\n".join(lines) + "\n") + parts.append(f'\n' + "\n".join(lines) + "\n") return CONSOLIDATION_PROMPT.format(consolidation_groups="\n\n".join(parts), max_groups=max_groups) @@ -671,6 +672,8 @@ class MemoryUpdater: max_sources=config.consolidation_max_sources, ) + # conscious accept: json.dumps leaves < > & unescaped in fact content (tracked in #4044); + # lower-risk than staleness/consolidation — read-only context, not delete/merge instructions. prompt = MEMORY_UPDATE_PROMPT.format( current_memory=json.dumps(current_memory, indent=2, ensure_ascii=False), conversation=conversation_text, diff --git a/backend/tests/test_memory_consolidation.py b/backend/tests/test_memory_consolidation.py index c7d422576..bd04ebbbf 100644 --- a/backend/tests/test_memory_consolidation.py +++ b/backend/tests/test_memory_consolidation.py @@ -156,6 +156,44 @@ class TestBuildConsolidationSection: assert 'category="preference"' in section assert "Memory Consolidation" in section + def test_html_special_chars_in_content_are_escaped(self): + """Fact content with XML tags or quotes is HTML-escaped so it cannot + break the surrounding prompt structure.""" + candidates = { + "knowledge": [ + _make_fact("fact_x", 'Like bold & "quotes"', "knowledge", 0.9), + _make_fact("fact_y", "normal content", "knowledge", 0.8), + ], + } + section = _build_consolidation_section(candidates) + assert "" not in section + assert "<b>" in section + assert "&" in section + assert """ in section + + def test_closing_tag_in_content_is_escaped(self): + """A closing tag in content must not + prematurely end the prompt XML block.""" + candidates = { + "knowledge": [ + _make_fact("fact_a", "injected", "knowledge", 0.9), + _make_fact("fact_b", "normal", "knowledge", 0.8), + ], + } + section = _build_consolidation_section(candidates) + assert "" not in section + assert "</consolidation_candidates>" in section + + def test_special_chars_in_category_attribute_are_escaped(self): + """A category name with a quote character must not break the XML + attribute value in the prompt.""" + candidates = { + 'pref"erences': [_make_fact(f"f_{i}", category='pref"erences') for i in range(3)], + } + section = _build_consolidation_section(candidates) + assert 'category="pref"erences"' not in section + assert "pref"erences" in section + # ── _normalize_memory_update_data with factsToConsolidate ───────────────── diff --git a/backend/tests/test_memory_staleness_review.py b/backend/tests/test_memory_staleness_review.py index 7dca2ce09..186850002 100644 --- a/backend/tests/test_memory_staleness_review.py +++ b/backend/tests/test_memory_staleness_review.py @@ -212,6 +212,38 @@ class TestBuildStalenessSection: assert "fact_b" in section assert "" in section + def test_html_special_chars_in_content_are_escaped(self): + """Fact content with XML tags or quotes is HTML-escaped so it cannot + break the surrounding prompt structure.""" + candidates = [ + _make_fact("fact_x", 'Like bold & "quotes"', "knowledge", 0.9, days_ago=100), + ] + section = _build_staleness_section(candidates, 90) + assert "" not in section + assert "<b>" in section + assert "&" in section + assert """ in section + + def test_closing_tag_in_content_is_escaped(self): + """A closing tag embedded in content must not prematurely + end the prompt XML block.""" + candidates = [ + _make_fact("fact_y", "bad", "knowledge", 0.8, days_ago=100), + ] + section = _build_staleness_section(candidates, 90) + assert "" not in section + assert "</stale_facts>" in section + + def test_special_chars_in_category_are_escaped(self): + """A category name with XML tags or quotes is HTML-escaped, consistent + with how category is handled in the consolidation section.""" + candidates = [ + _make_fact("fact_z", "content", 'pref<"erences>', 0.8, days_ago=100), + ] + section = _build_staleness_section(candidates, 90) + assert 'pref<"erences>' not in section + assert "pref<"erences>" in section + # ── _apply_updates with staleness removals ─────────────────────────────────