ForcePilot/backend/package/yuxi/services/viewer_filesystem_service.py
Wenjie Zhang 45f18d3122 fix: 修复安全预警依赖与路径校验
- 升级 npm/uv 锁文件中的漏洞依赖,并移除未使用的 python-jose 以消除 ecdsa 预警
- 收紧 viewer/thread/sandbox 路径解析边界,补充路径逃逸测试
- 更新 roadmap 记录本次安全修复
2026-06-05 23:46:06 +08:00

565 lines
21 KiB
Python

from __future__ import annotations
import asyncio
import io
import mimetypes
import shutil
from pathlib import Path, PurePosixPath
from urllib.parse import quote
from fastapi import HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.agents.backends import create_agent_composite_backend
from yuxi.agents.backends.sandbox import (
SKILLS_PATH,
USER_DATA_PATH,
ensure_thread_dirs,
resolve_virtual_path,
sandbox_user_data_dir,
sandbox_workspace_dir,
virtual_path_for_thread_file,
)
from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend
from yuxi.agents.skills.service import normalize_string_list
from yuxi.services.agent_runtime_service import resolve_thread_agent_runtime_context
from yuxi.services.file_preview import detect_preview_type
from yuxi.services.workspace_service import (
create_workspace_directory as create_workspace_directory_entry,
)
from yuxi.services.workspace_service import (
delete_workspace_path,
list_workspace_tree,
)
from yuxi.services.workspace_service import (
download_workspace_file as download_workspace_file_response,
)
from yuxi.services.workspace_service import (
read_workspace_file_content as read_workspace_file_content_response,
)
from yuxi.services.workspace_service import (
upload_workspace_files as upload_workspace_files_entry,
)
from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS, VIRTUAL_PATH_UPLOADS, VIRTUAL_PATH_WORKSPACE
_PROTECTED_USER_DATA_ROOTS = frozenset(
{
USER_DATA_PATH,
VIRTUAL_PATH_WORKSPACE,
VIRTUAL_PATH_UPLOADS,
VIRTUAL_PATH_OUTPUTS,
}
)
def _normalize_path(path: str | None) -> str:
normalized = (path or "/").strip() or "/"
if not normalized.startswith("/"):
normalized = f"/{normalized}"
return normalized.rstrip("/") if normalized not in {"/", SKILLS_PATH, USER_DATA_PATH} else normalized
def _resolve_local_user_data_path(thread_id: str, uid: str, path: str) -> Path:
try:
actual_path = resolve_virtual_path(thread_id, path, uid=uid)
except ValueError as exc:
# 真实路径越过允许根目录时,按权限拒绝处理,而不是当作普通参数错误。
if "path traversal" in str(exc):
raise HTTPException(status_code=403, detail="Access denied") from exc
raise
resolved_path = actual_path.resolve()
user_data_root = sandbox_user_data_dir(thread_id).resolve()
workspace_root = sandbox_workspace_dir(thread_id, uid).resolve()
if not (resolved_path.is_relative_to(user_data_root) or resolved_path.is_relative_to(workspace_root)):
raise HTTPException(status_code=403, detail="Access denied")
return resolved_path
def _is_user_data_path(path: str) -> bool:
return path == USER_DATA_PATH or path.startswith(f"{USER_DATA_PATH}/")
def _is_workspace_path(path: str) -> bool:
return path == VIRTUAL_PATH_WORKSPACE or path.startswith(f"{VIRTUAL_PATH_WORKSPACE}/")
def _is_skills_path(path: str) -> bool:
return path == SKILLS_PATH or path.startswith(f"{SKILLS_PATH}/")
def _is_in_home_gem(path: str) -> bool:
"""检查路径是否在 /home/gem/ 下但不在虚拟挂载点内"""
if not path.startswith("/home/gem/"):
return False
# 排除虚拟挂载点
if path.startswith(f"{USER_DATA_PATH}/") or path == USER_DATA_PATH:
return False
if path.startswith(f"{SKILLS_PATH}/") or path == SKILLS_PATH:
return False
return True
def _strip_skills_prefix(path: str) -> str:
if path == SKILLS_PATH:
return "/"
return path[len(SKILLS_PATH) :] or "/"
def _remap_prefixed_entry(entry: dict, prefix: str) -> dict:
raw_path = str(entry.get("path") or "")
is_dir = bool(entry.get("is_dir", False))
remapped = f"{prefix}{raw_path}" if raw_path != "/" else f"{prefix}/"
if is_dir and not remapped.endswith("/"):
remapped = f"{remapped}/"
return {
"path": remapped,
"name": PurePosixPath(remapped.rstrip("/")).name or remapped,
"is_dir": is_dir,
"size": int(entry.get("size", 0) or 0),
"modified_at": str(entry.get("modified_at", "") or ""),
}
def _sort_entries(entries: list[dict]) -> list[dict]:
"""Sort entries: folders first, then files alphabetically."""
return sorted(
entries,
key=lambda e: (
not bool(e.get("is_dir")),
PurePosixPath(str(e.get("path") or "").rstrip("/")).name.lower(),
),
)
def _entry_for_local_path(thread_id: str, uid: str, path: Path, listing_root: Path) -> dict:
resolved_path = path.resolve()
if not resolved_path.is_relative_to(listing_root):
raise HTTPException(status_code=403, detail="Access denied")
stat = resolved_path.stat()
is_dir = resolved_path.is_dir()
display_path = virtual_path_for_thread_file(thread_id, resolved_path, uid=uid)
if is_dir and not display_path.endswith("/"):
display_path = f"{display_path}/"
return {
"path": display_path,
"name": resolved_path.name,
"is_dir": is_dir,
"size": 0 if is_dir else stat.st_size,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "",
}
def _list_local_entries(thread_id: str, uid: str, actual_path) -> list[dict]:
"""List a local directory and remap children back into viewer virtual paths."""
listing_root = actual_path.resolve()
entries: list[dict] = []
for child in sorted(actual_path.iterdir(), key=lambda item: item.name.lower()):
resolved_child = child.resolve()
if not resolved_child.is_relative_to(listing_root):
continue
entries.append(_entry_for_local_path(thread_id, uid, resolved_child, listing_root))
return entries
def _workspace_relative_path(path: str) -> str:
if path == VIRTUAL_PATH_WORKSPACE:
return "/"
if not path.startswith(f"{VIRTUAL_PATH_WORKSPACE}/"):
raise HTTPException(status_code=400, detail="当前路径不是工作区路径")
return path[len(VIRTUAL_PATH_WORKSPACE) :] or "/"
def _viewer_entry_from_workspace_entry(entry: dict) -> dict:
path = str(entry.get("virtual_path") or "")
if not path:
workspace_path = str(entry.get("path") or "/")
path = VIRTUAL_PATH_WORKSPACE if workspace_path == "/" else f"{VIRTUAL_PATH_WORKSPACE}{workspace_path}"
is_dir = bool(entry.get("is_dir", False))
if is_dir and not path.endswith("/"):
path = f"{path}/"
return {
"path": path,
"name": str(entry.get("name", "") or PurePosixPath(path.rstrip("/")).name or path),
"is_dir": is_dir,
"size": int(entry.get("size", 0) or 0),
"modified_at": str(entry.get("modified_at", "") or ""),
}
def _viewer_response_from_workspace_response(response: dict) -> dict:
result = {**response}
if "entry" in result and isinstance(result["entry"], dict):
result["entry"] = _viewer_entry_from_workspace_entry(result["entry"])
if "entries" in result and isinstance(result["entries"], list):
result["entries"] = [
_viewer_entry_from_workspace_entry(entry) for entry in result["entries"] if isinstance(entry, dict)
]
return result
def _list_user_data_root_entries(thread_id: str, uid: str) -> list[dict]:
"""Expose thread-root files while keeping the user workspace entry visible."""
entries = _list_local_entries(thread_id, uid, sandbox_user_data_dir(thread_id))
visible_paths = {str(entry.get("path") or "").rstrip("/") for entry in entries}
workspace_dir = sandbox_workspace_dir(thread_id, uid)
workspace_virtual_path = virtual_path_for_thread_file(thread_id, workspace_dir, uid=uid).rstrip("/")
if workspace_virtual_path not in visible_paths:
# workspace is stored outside the per-thread root, so add it explicitly when needed.
stat = workspace_dir.stat()
entries.append(
{
"path": f"{workspace_virtual_path}/",
"name": workspace_dir.name,
"is_dir": True,
"size": 0,
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "",
}
)
return entries
async def _resolve_viewer_state(
*,
thread_id: str,
current_user: User,
db: AsyncSession,
):
runtime_context = await resolve_thread_agent_runtime_context(
thread_id=thread_id,
user=current_user,
db=db,
)
selected_skills = getattr(runtime_context, "_readable_skills", [])
selected_skills = normalize_string_list(selected_skills if isinstance(selected_skills, list) else [])
runtime_stub = type("RuntimeStub", (), {"context": runtime_context})()
sandbox_backend = create_agent_composite_backend(runtime_stub)
skills_backend = SelectedSkillsReadonlyBackend(selected_slugs=selected_skills)
return sandbox_backend, skills_backend, selected_skills
async def list_viewer_filesystem_tree(
*,
thread_id: str,
path: str,
current_user: User,
db: AsyncSession,
) -> dict:
if not thread_id:
raise HTTPException(status_code=422, detail="thread_id 不能为空")
normalized_path = _normalize_path(path)
sandbox_backend, skills_backend, selected_skills = await _resolve_viewer_state(
thread_id=thread_id,
current_user=current_user,
db=db,
)
if normalized_path == "/":
# 根目录只显示 viewer 暴露的虚拟命名空间,避免为只读树视图触发 sandbox 冷启动。
entries = []
entries.append(
{"path": f"{USER_DATA_PATH}/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""}
)
if selected_skills:
entries.append({"path": f"{SKILLS_PATH}/", "name": "skills", "is_dir": True, "size": 0, "modified_at": ""})
return {"entries": _sort_entries(entries)}
try:
if _is_user_data_path(normalized_path):
uid = str(current_user.uid)
ensure_thread_dirs(thread_id, uid)
if _is_workspace_path(normalized_path):
response = await list_workspace_tree(
path=_workspace_relative_path(normalized_path),
current_user=current_user,
)
entries = [_viewer_entry_from_workspace_entry(entry) for entry in response.get("entries", [])]
return {"entries": _sort_entries(entries)}
if normalized_path == USER_DATA_PATH:
entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, uid)
return {"entries": _sort_entries(entries)}
actual_path = _resolve_local_user_data_path(thread_id, uid, normalized_path)
if not actual_path.exists():
return {"entries": []}
if not actual_path.is_dir():
raise HTTPException(status_code=400, detail="当前路径不是目录")
entries = await asyncio.to_thread(_list_local_entries, thread_id, uid, actual_path)
return {"entries": _sort_entries(entries)}
if _is_skills_path(normalized_path):
result = await asyncio.to_thread(skills_backend.ls, _strip_skills_prefix(normalized_path))
if result.error:
raise HTTPException(status_code=400, detail=result.error)
remapped = [_remap_prefixed_entry(entry, SKILLS_PATH) for entry in (result.entries or [])]
return {"entries": _sort_entries(remapped)}
except PermissionError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
raise HTTPException(status_code=400, detail=f"Access denied: '{normalized_path}' is outside viewer namespace")
async def read_viewer_file_content(
*,
thread_id: str,
path: str,
current_user: User,
db: AsyncSession,
) -> dict:
if not thread_id:
raise HTTPException(status_code=422, detail="thread_id 不能为空")
normalized_path = _normalize_path(path)
sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state(
thread_id=thread_id,
current_user=current_user,
db=db,
)
try:
if _is_user_data_path(normalized_path):
if _is_workspace_path(normalized_path):
return await read_workspace_file_content_response(
path=_workspace_relative_path(normalized_path),
current_user=current_user,
)
actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path)
if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
raw_content = await asyncio.to_thread(actual_path.read_bytes)
preview_type, supported, message = detect_preview_type(normalized_path, raw_content)
if preview_type in {"image", "pdf"} or not supported:
return {
"content": None,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
return {
"content": raw_content.decode("utf-8"),
"preview_type": preview_type,
"supported": supported,
"message": message,
}
elif _is_skills_path(normalized_path):
responses = await asyncio.to_thread(skills_backend.download_files, [_strip_skills_prefix(normalized_path)])
elif _is_in_home_gem(normalized_path):
# /home/gem/ 下的其他文件(如 workspace 目录)
responses = await asyncio.to_thread(sandbox_backend.download_files, [normalized_path])
else:
raise HTTPException(
status_code=400,
detail=f"Access denied: '{normalized_path}' is outside viewer namespace",
)
except PermissionError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
response = responses[0] if responses else None
if response is None or response.error == "file_not_found":
raise HTTPException(status_code=404, detail="文件不存在")
if response.error == "is_directory":
raise HTTPException(status_code=400, detail="当前路径是目录")
if response.error:
raise HTTPException(status_code=400, detail=str(response.error))
raw_content = response.content or b""
preview_type, supported, message = detect_preview_type(normalized_path, raw_content)
if preview_type in {"image", "pdf"}:
return {
"content": None,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
if not supported:
return {
"content": None,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
content = raw_content.decode("utf-8")
return {
"content": content,
"preview_type": preview_type,
"supported": supported,
"message": message,
}
async def download_viewer_file(
*,
thread_id: str,
path: str,
current_user: User,
db: AsyncSession,
) -> StreamingResponse | FileResponse:
normalized_path = _normalize_path(path)
sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state(
thread_id=thread_id,
current_user=current_user,
db=db,
)
try:
if _is_user_data_path(normalized_path):
if _is_workspace_path(normalized_path):
return await download_workspace_file_response(
path=_workspace_relative_path(normalized_path),
current_user=current_user,
)
actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path)
if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail="当前路径是目录")
file_name = actual_path.name or "download"
media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}",
}
return FileResponse(path=actual_path, media_type=media_type, headers=headers)
if _is_skills_path(normalized_path):
responses = await asyncio.to_thread(skills_backend.download_files, [_strip_skills_prefix(normalized_path)])
elif _is_in_home_gem(normalized_path):
# /home/gem/ 下的其他文件(如 workspace 目录)
responses = await asyncio.to_thread(sandbox_backend.download_files, [normalized_path])
else:
raise HTTPException(
status_code=400,
detail=f"Access denied: '{normalized_path}' is outside viewer namespace",
)
except PermissionError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
response = responses[0] if responses else None
if response is None or response.error == "file_not_found":
raise HTTPException(status_code=404, detail="文件不存在")
if response.error == "is_directory":
raise HTTPException(status_code=400, detail="当前路径是目录")
if response.error:
raise HTTPException(status_code=400, detail=str(response.error))
file_name = PurePosixPath(normalized_path).name or "download"
media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
stream = io.BytesIO(response.content or b"")
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}",
}
return StreamingResponse(stream, media_type=media_type, headers=headers)
async def delete_viewer_file(
*,
thread_id: str,
path: str,
current_user: User,
db: AsyncSession,
) -> dict:
if not thread_id:
raise HTTPException(status_code=422, detail="thread_id 不能为空")
normalized_path = _normalize_path(path)
await _resolve_viewer_state(
thread_id=thread_id,
current_user=current_user,
db=db,
)
if not _is_user_data_path(normalized_path):
raise HTTPException(status_code=400, detail="当前路径不支持删除")
if normalized_path in _PROTECTED_USER_DATA_ROOTS:
raise HTTPException(status_code=400, detail="当前目录不允许删除")
try:
if _is_workspace_path(normalized_path):
await delete_workspace_path(path=_workspace_relative_path(normalized_path), current_user=current_user)
return {"success": True, "path": normalized_path}
actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path)
if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if actual_path.is_dir():
await asyncio.to_thread(shutil.rmtree, actual_path)
else:
await asyncio.to_thread(actual_path.unlink)
except PermissionError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
return {"success": True, "path": normalized_path}
async def create_viewer_directory(
*,
thread_id: str,
parent_path: str,
name: str,
current_user: User,
db: AsyncSession,
) -> dict:
if not thread_id:
raise HTTPException(status_code=422, detail="thread_id 不能为空")
await _resolve_viewer_state(
thread_id=thread_id,
current_user=current_user,
db=db,
)
normalized_parent = _normalize_path(parent_path)
if not _is_workspace_path(normalized_parent):
raise HTTPException(status_code=400, detail="当前路径不支持写入")
response = await create_workspace_directory_entry(
parent_path=_workspace_relative_path(normalized_parent),
name=name,
current_user=current_user,
)
return _viewer_response_from_workspace_response(response)
async def upload_viewer_files(
*,
thread_id: str,
parent_path: str,
files: list[UploadFile],
current_user: User,
db: AsyncSession,
) -> dict:
if not thread_id:
raise HTTPException(status_code=422, detail="thread_id 不能为空")
await _resolve_viewer_state(
thread_id=thread_id,
current_user=current_user,
db=db,
)
normalized_parent = _normalize_path(parent_path)
if not _is_workspace_path(normalized_parent):
raise HTTPException(status_code=400, detail="当前路径不支持写入")
response = await upload_workspace_files_entry(
parent_path=_workspace_relative_path(normalized_parent),
files=files,
current_user=current_user,
)
return _viewer_response_from_workspace_response(response)