diff --git a/agent/lsp/reporter.py b/agent/lsp/reporter.py index 0eba96ba1ff..2be1779cced 100644 --- a/agent/lsp/reporter.py +++ b/agent/lsp/reporter.py @@ -8,6 +8,7 @@ OpenCode's ``lsp/diagnostic.ts`` and Claude Code's """ from __future__ import annotations +import html from typing import Any, Dict, List # Severity-1 only by default — warnings/info/hints would flood the @@ -18,18 +19,65 @@ DEFAULT_SEVERITIES = frozenset({1}) # ERROR only MAX_PER_FILE = 20 MAX_TOTAL_CHARS = 4000 +# Per-field caps for diagnostic content sourced from the language server. +# These bound the length of any single attacker-controlled identifier that +# can ride into the model's tool output via an LSP diagnostic message. +MAX_MESSAGE_CHARS = 300 +MAX_CODE_CHARS = 80 +MAX_SOURCE_CHARS = 80 + + +def _sanitize_field(value: Any, *, limit: int) -> str: + """Make a language-server field safe to embed in a tool-result block. + + Diagnostic ``message``, ``code``, and ``source`` originate from a + language server that has just parsed user-controlled source code, so + they're untrusted from the agent's point of view. A hostile repo can + place instruction-shaped text inside identifier names, type aliases, + or import paths so the resulting diagnostic echoes that text back + into the ```` block the model reads. + + This helper: + + * Collapses CR/LF so a raw newline can't synthesize a new line in the + formatted block. + * Drops non-printable ASCII control characters that have no business + in a single-line summary. + * Caps length per-field so a long identifier can't push past the + block boundary. + * HTML-escapes ``< > &`` so the result can't close ```` + early or open a new tag. + + Returns ``""`` for ``None`` / empty so the surrounding format string + naturally omits the part (mirrors the prior ``if code not in {None, + ""}`` check at call sites). + """ + if value is None: + return "" + raw = str(value) + # Collapse newlines so identifier text with raw \n can't fake new lines. + raw = raw.replace("\r", " ").replace("\n", " ") + # Drop ASCII control chars; keep regular spaces. + raw = "".join(ch for ch in raw if ch == " " or ch.isprintable()) + raw = raw.strip()[:limit] + return html.escape(raw, quote=False) + def format_diagnostic(d: Dict[str, Any]) -> str: - """One-line representation of a single diagnostic.""" + """One-line representation of a single diagnostic. + + ``message``, ``code``, and ``source`` are sanitized before + interpolation — see ``_sanitize_field``. + """ sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR") rng = d.get("range") or {} start = rng.get("start") or {} line = int(start.get("line", 0)) + 1 col = int(start.get("character", 0)) + 1 - msg = str(d.get("message") or "").rstrip() - code = d.get("code") - code_part = f" [{code}]" if code not in {None, ""} else "" - source = d.get("source") + msg = _sanitize_field(d.get("message"), limit=MAX_MESSAGE_CHARS) + code = _sanitize_field(d.get("code"), limit=MAX_CODE_CHARS) + code_part = f" [{code}]" if code else "" + source = _sanitize_field(d.get("source"), limit=MAX_SOURCE_CHARS) source_part = f" ({source})" if source else "" return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}" @@ -57,7 +105,11 @@ def report_for_file( body = "\n".join(lines) if extra > 0: body += f"\n... and {extra} more" - return f"\n{body}\n" + # quote=True escapes both ``"`` and ``&`` so a crafted file name like + # ``foo">\n{body}\n" def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str: diff --git a/tests/agent/lsp/test_reporter.py b/tests/agent/lsp/test_reporter.py index 67794e40401..b3785ea63f6 100644 --- a/tests/agent/lsp/test_reporter.py +++ b/tests/agent/lsp/test_reporter.py @@ -91,3 +91,81 @@ def test_truncate_above_limit_appends_marker(): out = truncate(s, limit=200) assert out.endswith("[truncated]") assert len(out) <= 200 + + +# -- security: sanitize untrusted LSP fields ----------------------------------- + + +def test_format_diagnostic_escapes_html_in_message(): + """A hostile identifier name must not introduce raw < > & into tool output. + + Regression for the indirect prompt-injection surface where the model + reads ```` blocks produced from LSP server output. + """ + diag = _diag(msg="conflict with exfil") + line = format_diagnostic(diag) + # Raw < and > must be HTML-escaped so the attacker can't synthesize a + # closing tag or open a new tag. + assert "" not in line + assert "" not in line + assert "</diagnostics>" in line + assert "<tool_call>" in line + + +def test_format_diagnostic_collapses_newlines_in_message(): + """Raw newlines in a message must not produce extra lines in the output.""" + diag = _diag(msg="line one\nline two\rline three") + line = format_diagnostic(diag) + # Single-line output: no embedded newlines from the message field. + assert "\n" not in line + assert "\r" not in line + assert "line one line two line three" in line + + +def test_format_diagnostic_caps_message_length(): + """A long identifier must not push the message past MAX_MESSAGE_CHARS.""" + long_msg = "A" * 1000 + diag = _diag(msg=long_msg) + line = format_diagnostic(diag) + # The message portion is capped at 300 chars; the surrounding + # "ERROR [1:1] " prefix and " [E001] (ls)" suffix add a small amount. + assert "A" * 1000 not in line + assert line.count("A") <= 300 + + +def test_format_diagnostic_escapes_brackets_in_code_and_source(): + """code and source must also be sanitized, not just message.""" + diag = _diag(code="