diff --git a/scripts/commands/guard.py b/scripts/commands/guard.py index bc110739..140c1890 100644 --- a/scripts/commands/guard.py +++ b/scripts/commands/guard.py @@ -433,8 +433,15 @@ def _analyze_file(workspace: str, rel_path: str) -> List[Dict]: if not os.path.exists(abs_path): return issues - from utils import safe_read_file - content = safe_read_file(abs_path) + # Issue #58, Phase 1: validate the agent-supplied path stays inside + # the workspace before reading. ``--file`` on `guard pre` / `guard + # post` is the most direct agent-controlled file-read surface, so + # it's the highest-leverage place to enforce path confinement. + # ``safe_read_file_within_project`` returns None on refusal (and + # logs the refusal at WARNING level), which preserves the legacy + # "empty issues list" behavior for unreadable files. + from utils import safe_read_file_within_project + content = safe_read_file_within_project(abs_path, workspace) if not content: return issues diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 6f8e1a78..323739a9 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -1853,6 +1853,15 @@ def _execute_command(self, cmd_name: str, arguments: Dict[str, Any], workspace: "available_commands": sorted(self._command_registry.keys()) } + # Issue #58, Phase 1: validate any agent-supplied ``file`` / + # ``path`` argument stays inside the workspace. This is the + # MCP-side enforcement point — the most agent-driven read + # surface. Refusals return a structured error so the agent + # can self-correct instead of getting an opaque crash. + path_error = self._validate_path_args(arguments, workspace) + if path_error is not None: + return path_error + # Build an args namespace from the arguments dict args = _ArgsNamespace(arguments, workspace) @@ -1871,6 +1880,67 @@ def _execute_command(self, cmd_name: str, arguments: Dict[str, Any], workspace: return {"status": "ok", "items": [result]} + # Argument names that agents can use to point CodeLens at a file. + # Kept narrow on purpose — only arguments that directly translate + # to a filesystem read are validated here. ``workspace`` itself is + # resolved separately by the MCP server's workspace resolution + # layer and is NOT agent-controlled per-call. + _PATH_LIKE_ARGS = frozenset({"file", "path", "file_path"}) + + def _validate_path_args( + self, + arguments: Dict[str, Any], + workspace: str, + ) -> Optional[Dict[str, Any]]: + """Validate agent-supplied path-like arguments stay in ``workspace``. + + Returns ``None`` if all path-like args are safe (or absent), + otherwise returns a structured error dict suitable for direct + return to the MCP client. + + Phase 1 scope: only refuses paths that lexically/symlink-escape + the workspace. Does NOT refuse relative paths (those are + resolved against ``workspace`` by the commands themselves, + which is the desired behavior). + """ + if not workspace: + return None + try: + from security.path_traversal import ( + PathRefusalError, + resolve_path_within_project, + ) + except ImportError: + # If the security module isn't available, degrade to + # pre-issue-#58 behavior rather than blocking all calls. + return None + + for arg_name in self._PATH_LIKE_ARGS: + if arg_name not in arguments: + continue + raw = arguments[arg_name] + if not isinstance(raw, str) or not raw: + continue + # Resolve relative paths against the workspace before + # checking — this matches how commands consume them. + candidate = raw if os.path.isabs(raw) else os.path.join(workspace, raw) + try: + resolve_path_within_project(workspace, candidate) + except PathRefusalError as exc: + return { + "status": "error", + "error": "path_refusal", + "message": str(exc), + "argument": arg_name, + "value": raw, + "workspace": workspace, + "suggestion": ( + "Use a path that resolves inside the workspace root. " + "Relative paths are resolved against the workspace." + ), + } + return None + def _detect_workspace(self) -> str: """Auto-detect workspace from current directory.""" try: diff --git a/scripts/security/__init__.py b/scripts/security/__init__.py new file mode 100644 index 00000000..20fcfbda --- /dev/null +++ b/scripts/security/__init__.py @@ -0,0 +1,25 @@ +"""CodeLens security hardening modules (issue #58). + +This package groups together the security-related helpers that protect +CodeLens — and the AI agents driving it — from untrusted input: + +* :mod:`scripts.security.path_traversal` — symlink-aware path + confinement to the project root (Phase 1 of issue #58). + +Future phases will add config secret redaction, git safety guard, +Secretlint integration, and LLM output schema validation. +""" + +from .path_traversal import ( + PathRefusalError, + is_path_within_project, + resolve_path_within_project, + safe_resolve_path, +) + +__all__ = [ + "PathRefusalError", + "is_path_within_project", + "resolve_path_within_project", + "safe_resolve_path", +] diff --git a/scripts/security/path_traversal.py b/scripts/security/path_traversal.py new file mode 100644 index 00000000..52649361 --- /dev/null +++ b/scripts/security/path_traversal.py @@ -0,0 +1,202 @@ +"""Path traversal protection for CodeLens (issue #58, Phase 1). + +Why this module exists +---------------------- +CodeLens reads source files from a workspace directory. When the +workspace path — or any file path derived from agent-supplied input — +is naively joined with ``os.path.join`` and opened, a malicious or +buggy caller can escape the workspace via: + +* Relative traversal: ``../../etc/passwd`` +* Symlinks inside the workspace that point outside it +* Absolute paths: ``/etc/passwd`` +* UNC paths on Windows (``\\\\server\\share``) + +This module implements **symlink-aware path confinement** to the +project root: + +1. ``os.path.realpath`` is used to resolve **all** symlinks in both + the project root and the candidate path. This defeats symlink-based + escapes — the real on-disk location is what matters, not the + apparent path the caller handed us. +2. The realpath of the candidate must be **equal to** or **start with** + the realpath of the project root plus a path separator. +3. Symlinks that stay inside the project are still allowed — only + escapes are refused. + +The module exposes three entry points so callers can pick the +ergonomic that fits their context: + +* :func:`resolve_path_within_project` — raises + :class:`PathRefusalError` on escape. Use when the caller cannot + recover and wants the exception to bubble up. +* :func:`is_path_within_project` — returns ``bool``. Use for + pre-flight checks ("should I even attempt this read?"). +* :func:`safe_resolve_path` — returns ``Optional[str]`` (``None`` on + refusal). Use in defensive code paths that already handle ``None`` + returns from :func:`utils.safe_read_file`. + +The refusal error message is **actionable** — it includes the +offending path, the project root, and a one-line suggestion so an AI +agent receiving the error can self-correct instead of looping. + +Integration points (Phase 1) +---------------------------- +* :func:`utils.safe_read_file_within_project` — thin wrapper that + composes this module with :func:`utils.safe_read_file`. +* :mod:`scripts.commands.guard` — agent-driven ``--file`` argument + is now validated before the file is read. +* :mod:`scripts.mcp_server` — any MCP tool argument named ``file`` / + ``path`` is validated against the resolved workspace before the + underlying command is dispatched. +""" + +from __future__ import annotations + +import os +from typing import Optional + +__all__ = [ + "PathRefusalError", + "is_path_within_project", + "resolve_path_within_project", + "safe_resolve_path", +] + + +class PathRefusalError(PermissionError): + """Raised when a path resolves outside the project root. + + Inherits from :class:`PermissionError` so existing ``except + OSError`` / ``except PermissionError`` handlers in callers + continue to catch it without modification. + + Attributes: + requested_path: The original path the caller handed in + (unmodified, for diagnostics). + resolved_path: The realpath of the requested path (may be + ``None`` if the path did not exist on disk and could not + be resolved — in which case the refusal was triggered by + an absolute path or traversal pattern that *would* escape + if it did exist). + project_root: The realpath of the project root that the + requested path was checked against. + """ + + def __init__( + self, + requested_path: str, + resolved_path: Optional[str], + project_root: str, + ) -> None: + self.requested_path = requested_path + self.resolved_path = resolved_path + self.project_root = project_root + super().__init__(self._build_message()) + + def _build_message(self) -> str: + return ( + f"Path refusal: '{self.requested_path}' resolves outside the " + f"project root '{self.project_root}'. " + f"Resolved to: {self.resolved_path or '(non-existent, refused by pattern)'}. " + f"Use a path that stays within the project root." + ) + + +def _normalize_project_root(project_root: str) -> str: + """Realpath the project root, ensuring a trailing separator. + + The trailing separator is critical for the + ``startswith(project_root + sep)`` check — without it, + ``/home/proj-evil`` would falsely appear to be inside + ``/home/proj``. + """ + if not project_root: + raise ValueError("project_root must be a non-empty path") + real_root = os.path.realpath(project_root) + # Append os.sep so that startswith() respects path-segment + # boundaries. realpath() strips trailing separators, so we + # re-add one unconditionally. + return real_root + os.sep + + +def is_path_within_project(project_root: str, path: str) -> bool: + """Return ``True`` if ``path`` resolves inside ``project_root``. + + Non-throwing variant of :func:`resolve_path_within_project`. + Safe to call with paths that do not exist on disk — ``realpath`` + on a non-existent path returns the resolved absolute path, which + is then checked against the project root. + + Args: + project_root: Absolute or relative path to the project root. + Need not exist (though a non-existent root will refuse + everything, which is the safe default). + path: The candidate path to check. May be absolute, relative + (resolved against cwd), or contain ``..`` segments. + + Returns: + ``True`` if the realpath of ``path`` is equal to or nested + under the realpath of ``project_root``. + """ + if not path: + return False + try: + normalized_root = _normalize_project_root(project_root) + except ValueError: + return False + + # realpath on a non-existent path still normalizes the lexical + # components (collapses '..', resolves symlinks for the parts that + # DO exist). That's exactly the behavior we want — a path like + # '/proj/../../etc/passwd' lexically resolves to '/etc/passwd' + # even if '/proj' itself doesn't exist. + resolved = os.path.realpath(path) + # Equality with the root (without the trailing sep) covers the + # case where path == project_root exactly. + root_without_sep = normalized_root[:-1] # strip the trailing sep + if resolved == root_without_sep: + return True + return resolved.startswith(normalized_root) + + +def resolve_path_within_project(project_root: str, path: str) -> str: + """Resolve ``path`` against ``project_root`` and return its realpath. + + Raises: + PathRefusalError: If the realpath of ``path`` escapes + ``project_root``. + + Args: + project_root: Absolute or relative path to the project root. + path: The candidate path. May be absolute, relative, or + contain ``..`` / symlinks. + + Returns: + The realpath of ``path`` (with all symlinks resolved), safe + to ``open()``. + """ + if not path: + raise PathRefusalError(path or "", None, os.path.realpath(project_root or "")) + + normalized_root = _normalize_project_root(project_root) + resolved = os.path.realpath(path) + root_without_sep = normalized_root[:-1] + + if resolved == root_without_sep or resolved.startswith(normalized_root): + return resolved + + raise PathRefusalError(path, resolved, root_without_sep) + + +def safe_resolve_path(project_root: str, path: str) -> Optional[str]: + """Non-throwing variant of :func:`resolve_path_within_project`. + + Returns the realpath on success, or ``None`` if the path escapes + the project root. Designed for code paths that already handle + ``None`` from :func:`utils.safe_read_file` — drop-in compat. + """ + try: + return resolve_path_within_project(project_root, path) + except PathRefusalError: + return None diff --git a/scripts/utils.py b/scripts/utils.py index b0afd8d5..2fcbb549 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -192,6 +192,51 @@ def safe_read_file(file_path: str, max_size: int = MAX_FILE_SIZE) -> Optional[st return None +def safe_read_file_within_project( + file_path: str, + project_root: str, + max_size: int = MAX_FILE_SIZE, +) -> Optional[str]: + """Read a file after confirming it resolves inside ``project_root``. + + Composes :func:`safe_read_file` with + :func:`scripts.security.path_traversal.resolve_path_within_project` + so that agent-supplied paths cannot escape the workspace via + symlinks or ``..`` traversal (issue #58, Phase 1). + + Args: + file_path: Path to read. May be absolute, relative, or + contain ``..`` / symlinks. + project_root: The workspace root the read must stay inside. + max_size: Maximum file size in bytes (same default as + :func:`safe_read_file`). + + Returns: + File content as string, or ``None`` if the path escapes the + project root, the file is too large, or the read fails for + any other I/O reason. The refusal is logged at WARNING level + so agents see why their read returned nothing. + """ + try: + from security.path_traversal import resolve_path_within_project, PathRefusalError + except ImportError: + # Defensive: if the scripts/security package is unavailable + # (e.g., older install that hasn't pulled the new files), + # fall back to the legacy behavior rather than crash. The + # path_traversal module is the security boundary; without it + # we degrade to pre-issue-#58 behavior. + logger.debug("security.path_traversal unavailable; falling back to safe_read_file") + return safe_read_file(file_path, max_size=max_size) + + try: + resolved = resolve_path_within_project(project_root, file_path) + except PathRefusalError as exc: + logger.warning("Path refusal: %s", exc) + return None + + return safe_read_file(resolved, max_size=max_size) + + def time_budget_expired(start_time: float, budget_sec: float = GLOBAL_TIMEOUT_SEC) -> bool: """Check if a time budget has expired. diff --git a/tests/test_path_traversal.py b/tests/test_path_traversal.py new file mode 100644 index 00000000..661d4b96 --- /dev/null +++ b/tests/test_path_traversal.py @@ -0,0 +1,397 @@ +"""Tests for path traversal protection (issue #58, Phase 1). + +Covers: + +* :class:`scripts.security.path_traversal.PathRefusalError` +* :func:`scripts.security.path_traversal.is_path_within_project` +* :func:`scripts.security.path_traversal.resolve_path_within_project` +* :func:`scripts.security.path_traversal.safe_resolve_path` +* :func:`utils.safe_read_file_within_project` integration + +Test strategy: + +* Real filesystem fixtures (``tmp_path``) — no mocking of ``os.path`` + or ``os.symlink``, because the whole point of Phase 1 is that + ``realpath`` correctly defeats symlink-based escapes. +* Both Linux-style and Windows-style path separators where the + behavior is platform-sensitive (skipped on non-relevant platforms). +* Boundary cases: path == project_root, path immediately under root, + path with trailing slash, path with ``..`` that resolves back + inside the project. +* Negative cases: absolute escape, relative traversal escape, symlink + escape, sibling-directory prefix collision (``/home/proj-evil`` vs + ``/home/proj``). +""" + +from __future__ import annotations + +import os +import sys +import tempfile +import textwrap + +import pytest + +# Add scripts dir to sys.path so we can import the security package. +SCRIPTS_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "scripts", +) +if SCRIPTS_DIR not in sys.path: + sys.path.insert(0, SCRIPTS_DIR) + +from security.path_traversal import ( # noqa: E402 + PathRefusalError, + is_path_within_project, + resolve_path_within_project, + safe_resolve_path, +) +from utils import safe_read_file_within_project # noqa: E402 + + +# ─── Fixtures ────────────────────────────────────────────────── + + +@pytest.fixture +def project_tree(tmp_path): + """Build a small project tree with a few files and one inside-symlink. + + Layout:: + + /proj/ + src/ + app.py + utils.py + inside_link -> src/app.py (symlink, stays in project) + outside_link -> /etc/hostname (symlink, escapes project) + """ + proj = tmp_path / "proj" + src = proj / "src" + src.mkdir(parents=True) + (src / "app.py").write_text("print('hi')\n", encoding="utf-8") + (src / "utils.py").write_text("def x(): pass\n", encoding="utf-8") + + # Inside symlink: points to a file inside the project. + inside = proj / "inside_link" + inside.symlink_to(src / "app.py") + + # Outside symlink: points to a system file outside the project. + # /etc/hostname exists on virtually every POSIX system; if not, + # we point to /tmp which definitely exists. + target = "/etc/hostname" + if not os.path.exists(target): + target = tempfile.gettempdir() + outside = proj / "outside_link" + outside.symlink_to(target) + + return { + "root": str(proj), + "src_app": str(src / "app.py"), + "inside_link": str(inside), + "outside_link": str(outside), + } + + +# ─── is_path_within_project ──────────────────────────────────── + + +class TestIsPathWithinProject: + """Boolean pre-flight check.""" + + def test_path_inside_project(self, project_tree): + assert is_path_within_project(project_tree["root"], project_tree["src_app"]) is True + + def test_path_is_project_root_itself(self, project_tree): + assert is_path_within_project(project_tree["root"], project_tree["root"]) is True + + def test_relative_path_inside(self, project_tree): + # cwd-independent check: pass an absolute project_root and a + # relative path that, when resolved against cwd, does NOT + # land inside. We can't easily control cwd here, so instead + # test the absolute variant. + assert is_path_within_project( + project_tree["root"], + os.path.join(project_tree["root"], "src", "app.py"), + ) is True + + def test_dotdot_that_stays_inside(self, project_tree): + # src/../src/app.py lexically resolves to src/app.py — still inside. + candidate = os.path.join(project_tree["root"], "src", "..", "src", "app.py") + assert is_path_within_project(project_tree["root"], candidate) is True + + def test_dotdot_escape(self, project_tree, tmp_path): + # proj/../other should resolve to /other — outside proj. + candidate = os.path.join(project_tree["root"], "..", "other") + assert is_path_within_project(project_tree["root"], candidate) is False + + def test_absolute_path_outside(self, project_tree, tmp_path): + other = tmp_path / "other" + other.mkdir() + assert is_path_within_project(project_tree["root"], str(other)) is False + + def test_sibling_prefix_collision(self, tmp_path): + """``/tmp/proj-evil`` must NOT match ``/tmp/proj`` as a prefix. + + This is the core reason ``_normalize_project_root`` appends + ``os.sep`` — naive ``startswith`` would let ``proj-evil`` + slip through. + """ + proj = tmp_path / "proj" + proj.mkdir() + (tmp_path / "proj-evil").mkdir() + candidate = str(tmp_path / "proj-evil" / "secret.txt") + assert is_path_within_project(str(proj), candidate) is False + + def test_symlink_inside_project(self, project_tree): + # The realpath of inside_link is src/app.py, which IS inside. + assert is_path_within_project(project_tree["root"], project_tree["inside_link"]) is True + + def test_symlink_escape(self, project_tree): + # The realpath of outside_link is /etc/hostname (or /tmp), which + # is NOT inside the project. + assert is_path_within_project(project_tree["root"], project_tree["outside_link"]) is False + + def test_empty_path_returns_false(self, project_tree): + assert is_path_within_project(project_tree["root"], "") is False + + def test_empty_project_root_returns_false(self, project_tree): + assert is_path_within_project("", project_tree["src_app"]) is False + + def test_non_existent_path_inside(self, project_tree): + # A path that doesn't exist but lexically resolves inside the + # project is fine — we're checking the resolved location, not + # existence. This matters for pre-flight "should I even try?" + # checks. + candidate = os.path.join(project_tree["root"], "src", "does_not_exist.py") + assert is_path_within_project(project_tree["root"], candidate) is True + + def test_non_existent_path_outside(self, project_tree): + candidate = os.path.join(project_tree["root"], "..", "does_not_exist.py") + assert is_path_within_project(project_tree["root"], candidate) is False + + +# ─── resolve_path_within_project ─────────────────────────────── + + +class TestResolvePathWithinProject: + """Throwing variant — returns realpath on success, raises on escape.""" + + def test_returns_realpath_for_inside_path(self, project_tree): + result = resolve_path_within_project(project_tree["root"], project_tree["src_app"]) + # realpath should equal the canonical absolute path of src/app.py + assert result == os.path.realpath(project_tree["src_app"]) + assert os.path.isfile(result) + + def test_returns_realpath_for_symlink_inside(self, project_tree): + result = resolve_path_within_project(project_tree["root"], project_tree["inside_link"]) + # The symlink is resolved to its target — which is inside the project. + assert result == os.path.realpath(project_tree["src_app"]) + + def test_raises_for_symlink_escape(self, project_tree): + with pytest.raises(PathRefusalError) as exc_info: + resolve_path_within_project(project_tree["root"], project_tree["outside_link"]) + err = exc_info.value + # The error message must be actionable — include the requested + # path, the resolved target, and the project root. + assert project_tree["outside_link"] in str(err) + assert project_tree["root"] in str(err) + # The resolved_path attribute should point at the escape target. + assert err.resolved_path is not None + assert err.resolved_path == os.path.realpath(project_tree["outside_link"]) + + def test_raises_for_dotdot_escape(self, project_tree, tmp_path): + candidate = os.path.join(project_tree["root"], "..", "other") + with pytest.raises(PathRefusalError): + resolve_path_within_project(project_tree["root"], candidate) + + def test_raises_for_absolute_outside(self, project_tree, tmp_path): + other = tmp_path / "other" + other.mkdir() + with pytest.raises(PathRefusalError): + resolve_path_within_project(project_tree["root"], str(other)) + + def test_raises_for_empty_path(self, project_tree): + with pytest.raises(PathRefusalError): + resolve_path_within_project(project_tree["root"], "") + + def test_raises_value_error_for_empty_project_root(self, project_tree): + with pytest.raises(ValueError): + resolve_path_within_project("", project_tree["src_app"]) + + def test_project_root_itself_returns_root(self, project_tree): + result = resolve_path_within_project(project_tree["root"], project_tree["root"]) + assert result == os.path.realpath(project_tree["root"]) + + def test_pathrefusalerror_is_permission_error_subclass(self, project_tree): + """``PathRefusalError`` must be catchable as ``PermissionError`` + and ``OSError`` so existing error handlers in callers continue + to work without modification. + """ + try: + resolve_path_within_project(project_tree["root"], project_tree["outside_link"]) + except PathRefusalError as exc: + assert isinstance(exc, PermissionError) + assert isinstance(exc, OSError) + else: + pytest.fail("PathRefusalError was not raised") + + +# ─── safe_resolve_path ───────────────────────────────────────── + + +class TestSafeResolvePath: + """Non-throwing variant — returns Optional[str].""" + + def test_returns_realpath_on_success(self, project_tree): + result = safe_resolve_path(project_tree["root"], project_tree["src_app"]) + assert result == os.path.realpath(project_tree["src_app"]) + + def test_returns_none_on_refusal(self, project_tree): + result = safe_resolve_path(project_tree["root"], project_tree["outside_link"]) + assert result is None + + def test_returns_none_on_empty_path(self, project_tree): + assert safe_resolve_path(project_tree["root"], "") is None + + +# ─── utils.safe_read_file_within_project integration ─────────── + + +class TestSafeReadFileWithinProject: + """End-to-end: refusing paths return None instead of file content.""" + + def test_reads_file_inside_project(self, project_tree): + content = safe_read_file_within_project(project_tree["src_app"], project_tree["root"]) + assert content is not None + assert "print('hi')" in content + + def test_returns_none_for_symlink_escape(self, project_tree): + # The symlink target is /etc/hostname (or /tmp) — readable by + # the OS, but our security boundary must refuse it regardless + # of OS permissions. + content = safe_read_file_within_project(project_tree["outside_link"], project_tree["root"]) + assert content is None + + def test_returns_none_for_dotdot_escape(self, project_tree, tmp_path): + # Build a real file outside the project that would be readable + # if not for the path check. + outside_file = tmp_path / "outside.txt" + outside_file.write_text("SECRET", encoding="utf-8") + candidate = os.path.join(project_tree["root"], "..", "outside.txt") + content = safe_read_file_within_project(candidate, project_tree["root"]) + assert content is None + + def test_reads_file_via_inside_symlink(self, project_tree): + # Symlink that stays inside the project should be followed + # transparently — the realpath is src/app.py, which is inside. + content = safe_read_file_within_project(project_tree["inside_link"], project_tree["root"]) + assert content is not None + assert "print('hi')" in content + + def test_returns_none_for_nonexistent_inside_path(self, project_tree): + # Path that doesn't exist but stays inside the project — + # refusal layer passes, then safe_read_file returns None + # because the file doesn't exist. No exception. + candidate = os.path.join(project_tree["root"], "src", "nope.py") + content = safe_read_file_within_project(candidate, project_tree["root"]) + assert content is None + + +# ─── MCP-side path validation integration ────────────────────── + + +class TestMcpPathValidation: + """Verify the MCP server's ``_validate_path_args`` helper. + + This is the agent-facing enforcement point — any MCP tool call + that includes a ``file`` / ``path`` / ``file_path`` argument is + checked before the underlying command is dispatched. + """ + + def _make_server(self): + # Import lazily so test collection doesn't fail if MCP + # dependencies change shape. + from mcp_server import MCPServer + return MCPServer() + + def test_safe_file_arg_passes(self, project_tree): + srv = self._make_server() + args = {"file": os.path.join(project_tree["root"], "src", "app.py")} + assert srv._validate_path_args(args, project_tree["root"]) is None + + def test_relative_file_arg_resolved_against_workspace(self, project_tree): + srv = self._make_server() + # A relative path is resolved against the workspace before + # checking — this is the desired behavior (commands do the + # same). So "src/app.py" relative to workspace should pass. + args = {"file": "src/app.py"} + assert srv._validate_path_args(args, project_tree["root"]) is None + + def test_dotdot_escape_returns_structured_error(self, project_tree): + srv = self._make_server() + args = {"file": "../../../etc/passwd"} + result = srv._validate_path_args(args, project_tree["root"]) + assert result is not None + assert result["status"] == "error" + assert result["error"] == "path_refusal" + assert result["argument"] == "file" + assert "suggestion" in result + + def test_symlink_escape_returns_structured_error(self, project_tree): + srv = self._make_server() + args = {"file": project_tree["outside_link"]} + result = srv._validate_path_args(args, project_tree["root"]) + assert result is not None + assert result["status"] == "error" + assert result["error"] == "path_refusal" + + def test_absolute_outside_path_refused(self, project_tree, tmp_path): + srv = self._make_server() + other = tmp_path / "other" + other.mkdir() + args = {"file": str(other / "x.py")} + result = srv._validate_path_args(args, project_tree["root"]) + assert result is not None + assert result["error"] == "path_refusal" + + def test_path_arg_also_validated(self, project_tree): + srv = self._make_server() + args = {"path": "../../../etc/passwd"} + result = srv._validate_path_args(args, project_tree["root"]) + assert result is not None + assert result["argument"] == "path" + + def test_file_path_arg_also_validated(self, project_tree): + srv = self._make_server() + args = {"file_path": "../../../etc/passwd"} + result = srv._validate_path_args(args, project_tree["root"]) + assert result is not None + assert result["argument"] == "file_path" + + def test_no_path_args_returns_none(self, project_tree): + srv = self._make_server() + # A query call with no file/path arg should not be blocked. + args = {"name": "my_func"} + assert srv._validate_path_args(args, project_tree["root"]) is None + + def test_empty_workspace_returns_none(self, project_tree): + srv = self._make_server() + # If the MCP server couldn't resolve a workspace, we don't + # block — commands will produce their own "workspace not + # found" error. Path validation is a security layer, not a + # workspace-resolution layer. + args = {"file": "/etc/passwd"} + assert srv._validate_path_args(args, "") is None + + def test_non_string_arg_ignored(self, project_tree): + srv = self._make_server() + # Defensive: a malformed MCP call with a non-string file arg + # should be ignored by the validator (the command will + # produce its own type error downstream). + args = {"file": 12345} + assert srv._validate_path_args(args, project_tree["root"]) is None + + def test_empty_string_arg_ignored(self, project_tree): + srv = self._make_server() + args = {"file": ""} + assert srv._validate_path_args(args, project_tree["root"]) is None