agent-Specialization/modules/file_manager/path_mixin.py
JOJO 011f9e14bc fix(file-manager): direct 执行环境下文件工具不再误拦授权范围外路径
run_command 是否走沙箱只看执行环境,而 read_file/write_file/edit_file 是
进程内文件操作不走 OS 沙箱,唯一防线是 FileManager._ensure_host_access
的授权路径检查,但它不知道执行环境,导致完全访问权限(direct)下仍按
授权列表拦截。现 FileManager 同步 host_execution_mode,direct 时与
run_command 语义对齐直接放行,sandbox 模式检查保持不变。
2026-08-12 11:23:27 +08:00

192 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# modules/file_manager.py - 文件管理模块(添加行编辑功能)
import os
import shutil
from pathlib import Path
import re
from bisect import bisect_right
from typing import Any, Optional, Dict, List, Set, Tuple, TYPE_CHECKING
from datetime import datetime
try:
from config import (
MAX_FILE_SIZE,
FORBIDDEN_PATHS,
FORBIDDEN_ROOT_PATHS,
OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES,
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
)
except ImportError: # 兼容全局环境中存在同名包的情况
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
MAX_FILE_SIZE,
FORBIDDEN_PATHS,
FORBIDDEN_ROOT_PATHS,
OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES,
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
)
from modules.container_file_proxy import ContainerFileProxy
from modules.host_sandbox_policy import get_macos_writable_paths, get_macos_readable_paths
from utils.logger import setup_logger
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
# 临时禁用长度检查
DISABLE_LENGTH_CHECK = True
logger = setup_logger(__name__)
class PathMixin:
"""FileManager path mixin 能力 mixin。"""
def _get_project_size(self) -> int:
"""计算项目目录的总大小(字节),遇到异常时记录并抛出。"""
if not self._is_docker_mode():
return 0
total = 0
if not self.project_path.exists():
return 0
for path in self.project_path.rglob('*'):
if not path.is_file():
continue
try:
total += path.stat().st_size
except Exception as exc:
logger.error(
"Failed to stat %s while calculating project size: %s",
path,
exc,
exc_info=True,
)
raise
return total
def _validate_path(self, path: str) -> Tuple[bool, str, Path]:
"""
验证路径安全性
Returns:
(是否有效, 错误信息, 完整路径)
"""
original_path = path
project_root = Path(self.project_path).resolve()
if project_root != self.project_path:
self.project_path = project_root
if self._is_host_mode():
normalized = (path or "").strip()
if normalized == "/workspace":
normalized = ""
elif normalized.startswith("/workspace/"):
normalized = normalized.split("/workspace/", 1)[1]
if not normalized:
return True, "", project_root
if Path(normalized).is_absolute() or (len(normalized) > 1 and normalized[1] == ":"):
full_path = Path(normalized).expanduser().resolve()
else:
full_path = (project_root / normalized).resolve()
return True, "", full_path
# 不允许绝对路径(除非是在项目内的绝对路径)
if path.startswith('/') or path.startswith('\\') or (len(path) > 1 and path[1] == ':'):
# 如果是绝对路径,检查是否指向项目内
try:
test_path = Path(path).resolve()
test_path.relative_to(project_root)
# 如果成功,说明绝对路径在项目内,转换为相对路径
path = str(test_path.relative_to(project_root))
except ValueError:
if str(original_path).replace("\\", "/").startswith("/workspace"):
return False, "路径必须在项目文件夹内。请检查是否使用的是不带/workspace的相对路径。", None
return False, "路径必须在项目文件夹内", None
# 检查是否包含向上遍历
if ".." in path:
return False, "不允许使用../向上遍历", None
# 构建完整路径
full_path = (project_root / path).resolve()
# 检查是否在项目目录内
try:
full_path.relative_to(project_root)
except ValueError:
return False, "路径必须在项目文件夹内", None
# 检查禁止的路径
path_str = str(full_path)
for forbidden_root in FORBIDDEN_ROOT_PATHS:
if path_str == forbidden_root:
return False, f"禁止访问根目录: {forbidden_root}", None
for forbidden in FORBIDDEN_PATHS:
if path_str.startswith(forbidden + os.sep) or path_str == forbidden:
return False, f"禁止访问系统目录: {forbidden}", None
return True, "", full_path
def _relative_path(self, full_path: Path) -> str:
try:
return str(full_path.relative_to(self.project_path))
except ValueError:
return str(full_path)
@staticmethod
def _path_in_allowed_roots(target: Path, roots: List[Path]) -> bool:
for root in roots:
try:
target.relative_to(root)
return True
except Exception:
continue
return False
def _host_allowed_roots(self, access: str) -> List[Path]:
# 临时目录白名单按平台分流POSIX 用 /tmp、/private/tmp
# Windows 用系统临时目录Path("/tmp") 在 Windows 会解析为 C:\tmp语义错误
if os.name == "nt":
import tempfile
temp_roots = [Path(tempfile.gettempdir()).resolve()]
else:
temp_roots = [Path("/tmp").resolve(), Path("/private/tmp").resolve()]
roots: List[Path] = [self.project_path.resolve(), *temp_roots]
raw_items = get_macos_writable_paths() if access == "write" else get_macos_readable_paths()
for raw in raw_items:
try:
p = Path(raw).expanduser().resolve()
except Exception:
continue
if p not in roots:
roots.append(p)
return roots
def _ensure_host_access(self, full_path: Path, access: str) -> Tuple[bool, str]:
if not self._is_host_mode():
return True, ""
# 执行环境为 direct完全访问权限run_command 不套沙箱、可读写任意路径;
# read_file/write_file/edit_file 为进程内文件操作,本就不走 OS 沙箱,此处与
# run_command 语义对齐直接放行。sandbox 模式下保持授权范围检查不变。
if getattr(self, "host_execution_mode", "sandbox") == "direct":
return True, ""
check_target = full_path
if access == "write" and not full_path.exists():
check_target = full_path.parent.resolve()
allowed_roots = self._host_allowed_roots(access)
if self._path_in_allowed_roots(check_target.resolve(), allowed_roots):
return True, ""
if access == "write":
return False, "目标路径不在可写授权范围内,请在路径授权中添加后重试。"
return False, "目标路径不在可读授权范围内,请在路径授权中添加后重试。"