fix(lsp): restore secure ASCII decoding, top-level LspError import, printable-ASCII guard

This commit is contained in:
2026-05-14 15:48:14 +00:00
committed by Forgejo
parent dbc382f3d9
commit 33672ce805
+34 -23
View File
@@ -27,6 +27,8 @@ from typing import Any
import structlog
from cleveragents.lsp.errors import LspError
logger = structlog.get_logger(__name__)
# Maximum time (seconds) to wait for a graceful process termination
@@ -112,36 +114,15 @@ class StdioTransport:
cwd=self._cwd,
)
except FileNotFoundError as exc:
self._process = None
from cleveragents.lsp.errors import LspError
raise LspError(
f"LSP server command not found: {self._command}",
details={"command": self._command, "args": self._args},
) from exc
except OSError as exc:
# Popen may have partially started the subprocess before
# raising (e.g. execve failure post-fork on some platforms).
# Ensure cleanup so the process does not leak into the caller's
# address space.
if self._process is not None:
self.stop()
self._process = None
from cleveragents.lsp.errors import LspError
raise LspError(
f"Failed to start LSP server: {exc}",
details={"command": self._command, "error": str(exc)},
) from exc
except Exception:
# Catch-all for any other low-level OSError / resource-error
# variants that might leave a zombie process behind.
if self._process is not None:
self.stop()
self._process = None
raise
logger.info(
"lsp.transport.started",
@@ -239,7 +220,23 @@ class StdioTransport:
return self._read_one_message(effective_timeout)
def _read_one_message(self, timeout: float) -> dict[str, Any] | None:
"""Parse a single ``Content-Length`` framed JSON-RPC message."""
"""Parse a single ``Content-Length``-framed JSON-RPC message.
Header lines are decoded with ``errors="strict"``; any non-ASCII byte
raises :class:`~cleveragents.lsp.errors.LspError`. An additional
printable-ASCII guard rejects decoded headers containing characters
outside the codepoint range 0x20 (space) to 0x7E (tilde).
Returns
-------
dict[str, Any] | None
Parsed JSON body, or ``None`` on EOF / timeout.
Raises
------
LspError
If a header contains non-ASCII bytes or non-printable characters.
"""
assert self._process is not None
assert self._process.stdout is not None
@@ -254,7 +251,21 @@ class StdioTransport:
line = stdout.readline()
if not line:
return None # EOF — server exited
decoded = line.decode("ascii", errors="replace").strip()
try:
decoded = line.decode("ascii", errors="strict").strip()
except UnicodeDecodeError as exc:
raise LspError(
f"LSP header contains non-ASCII bytes: {exc}",
details={"raw_header": repr(line)},
) from exc
# Printable-ASCII guard: reject control characters
# outside the range 0x20 (space) through 0x7E (tilde).
if not all(0x20 <= ord(c) <= 0x7E for c in decoded):
raise LspError(
f"LSP header contains non-printable ASCII characters: "
f"{decoded!r}",
details={"raw_header": repr(line)},
)
if not decoded:
break # Empty line = end of headers
if decoded.lower().startswith("content-length:"):