From a37b4e0ccb11eca001325d432e47a5eaf94c7e3d Mon Sep 17 00:00:00 2001 From: CleverThis Date: Thu, 14 May 2026 23:56:24 +0000 Subject: [PATCH 1/2] fix(security): use relpath containment instead of startswith to prevent prefix-collision bypass --- .../application/services/llm_actors.py | 21 ++++++++++++------- src/cleveragents/tool/path_mapper.py | 17 +++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/cleveragents/application/services/llm_actors.py b/src/cleveragents/application/services/llm_actors.py index 3d6c026f1..51e4c2ca0 100644 --- a/src/cleveragents/application/services/llm_actors.py +++ b/src/cleveragents/application/services/llm_actors.py @@ -481,7 +481,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,13 +499,10 @@ 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, diff --git a/src/cleveragents/tool/path_mapper.py b/src/cleveragents/tool/path_mapper.py index a74879173..b451f5440 100644 --- a/src/cleveragents/tool/path_mapper.py +++ b/src/cleveragents/tool/path_mapper.py @@ -12,6 +12,7 @@ Based on issue #515 — container-aware tool execution and I/O forwarding. from __future__ import annotations +import os import posixpath from dataclasses import dataclass @@ -161,10 +162,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: -- 2.52.0 From ef6829b6f890adaa5930be9258887785c15fb02f Mon Sep 17 00:00:00 2001 From: CleverThis Date: Fri, 15 May 2026 00:34:30 +0000 Subject: [PATCH 2/2] chore(lint): remove unused pathlib.Path import in llm_actors.py --- src/cleveragents/application/services/llm_actors.py | 5 ++--- src/cleveragents/tool/path_mapper.py | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/cleveragents/application/services/llm_actors.py b/src/cleveragents/application/services/llm_actors.py index 51e4c2ca0..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 @@ -499,14 +498,14 @@ class LLMExecuteActor: for match in pattern.finditer(llm_output): path = match.group(1).strip() content = match.group(2) - full_path = os.path.normpath(os.path.join(sandbox_root, path)) + 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 b451f5440..e1895ac58 100644 --- a/src/cleveragents/tool/path_mapper.py +++ b/src/cleveragents/tool/path_mapper.py @@ -12,7 +12,6 @@ Based on issue #515 — container-aware tool execution and I/O forwarding. from __future__ import annotations -import os import posixpath from dataclasses import dataclass -- 2.52.0