diff --git a/src/cleveragents/application/services/llm_actors.py b/src/cleveragents/application/services/llm_actors.py index 3d6c026f1..57a65edff 100644 --- a/src/cleveragents/application/services/llm_actors.py +++ b/src/cleveragents/application/services/llm_actors.py @@ -11,7 +11,6 @@ from __future__ import annotations import os import re from datetime import UTC, datetime -from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable import structlog @@ -481,7 +480,15 @@ class LLMExecuteActor: sandbox_root: str, llm_output: str, ) -> None: - """Write generated file contents to the sandbox directory.""" + """Write generated file contents to the sandbox directory. + + Uses semantic path containment via os.path.relpath instead of + string prefix matching (str.startswith). String prefix matching + is vulnerable to sibling-directory prefix-collision attacks where + /tmp/sandbox would incorrectly match /tmp/sandboxmalicious/file. + + See issue #7478 — startswith bypass in path containment checks. + """ pattern = re.compile( r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```", @@ -491,17 +498,14 @@ class LLMExecuteActor: for match in pattern.finditer(llm_output): path = match.group(1).strip() content = match.group(2) - full_path = Path(os.path.normpath(os.path.join(sandbox_root, path))) - # Path traversal guard: use relative_to (not string prefix) to - # avoid the *prefix-collision bypass* on symlink resolution. - sandbox_root_resolved = Path(sandbox_root).resolve() - try: - full_path.relative_to(sandbox_root_resolved) - except ValueError: + full_path = os.path.normpath(os.path.join(sandbox_root, path)) + rel = os.path.relpath(full_path, sandbox_root) + # Path traversal guard: reject paths escaping sandbox + if rel.startswith(".." + os.sep) or rel == "..": logger.warning( "Rejected path traversal in LLM output", path=path, - resolved=str(full_path), + resolved=full_path, ) continue os.makedirs(os.path.dirname(full_path), exist_ok=True) diff --git a/src/cleveragents/tool/path_mapper.py b/src/cleveragents/tool/path_mapper.py index a74879173..e1895ac58 100644 --- a/src/cleveragents/tool/path_mapper.py +++ b/src/cleveragents/tool/path_mapper.py @@ -161,10 +161,22 @@ def _normalise(path: str) -> str: def _is_under(path: str, root: str) -> bool: - """Return ``True`` if *path* is equal to or a child of *root*.""" + """Return ``True`` if *path* is equal to or a child of *root*. + + Uses semantic path containment via posixpath.relpath instead of + string prefix matching (str.startswith). String prefix matching + is vulnerable to sibling-directory prefix-collision attacks where + /tmp/sandbox would incorrectly match /tmp/sandboxmalicious/file. + + See issue #7478 — startswith bypass in path containment checks. + """ if path == root: return True - return path.startswith(root + "/") + try: + relative = posixpath.relpath(path, root) + except (ValueError, TypeError): + return False + return not relative.startswith(".." + posixpath.sep) and relative != ".." def _relative_to(path: str, root: str) -> str: