ForcePilot/backend/package/yuxi/agents/backends/sandbox/path_security.py

130 lines
4.1 KiB
Python

from __future__ import annotations
import re
import shlex
from .sandbox_config import (
LARGE_TOOL_RESULTS_DIR,
OUTPUTS_DIR,
UPLOADS_DIR,
USER_DATA_PATH,
WORKSPACE_DIR,
)
ALLOWED_PREFIXES = ("/mnt/user-data", "/mnt/skills")
SYSTEM_EXEC_ALLOWED_PREFIXES = ("/bin/", "/usr/bin/", "/usr/local/bin/")
_USER_DATA_ALIAS_PREFIXES = (
("/attachments", f"{USER_DATA_PATH}/{UPLOADS_DIR}/attachments"),
("/home", f"{USER_DATA_PATH}/{WORKSPACE_DIR}"),
("/workspace", f"{USER_DATA_PATH}/{WORKSPACE_DIR}"),
("/outputs", f"{USER_DATA_PATH}/{OUTPUTS_DIR}"),
("/uploads", f"{USER_DATA_PATH}/{UPLOADS_DIR}"),
("/large_tool_results", f"{USER_DATA_PATH}/{LARGE_TOOL_RESULTS_DIR}"),
)
_STATIC_ALIAS_PREFIXES = (("/skills", "/mnt/skills"),)
def _map_virtual_alias(raw: str, alias: str, target: str) -> str | None:
if raw == alias:
return target
prefix = f"{alias}/"
if raw.startswith(prefix):
return f"{target}/{raw[len(prefix) :]}".rstrip("/")
return None
def normalize_virtual_path(path: str, thread_id: str, *, allow_root: bool = False) -> str | None:
"""将输入路径标准化为 /mnt 命名空间路径。"""
raw = (path or "").strip()
if not raw:
return "Error: path is required"
if not raw.startswith("/"):
raw = f"/{raw}"
if raw in {"/", "/mnt", "/mnt/"}:
if allow_root:
return "/"
return "Error: Access denied: path '/' is not writable"
if raw.startswith("/mnt/"):
normalized = raw.rstrip("/") if raw != "/mnt/user-data/" else "/mnt/user-data"
error = ensure_path_allowed(normalized)
if error:
return error
return normalized
for alias, target in _STATIC_ALIAS_PREFIXES:
mapped = _map_virtual_alias(raw, alias, target)
if mapped is not None:
return mapped
for alias, target in _USER_DATA_ALIAS_PREFIXES:
thread_alias = f"{alias}/{thread_id}"
mapped = _map_virtual_alias(raw, thread_alias, target)
if mapped is not None:
return mapped
mapped = _map_virtual_alias(raw, alias, target)
if mapped is not None:
return mapped
return f"Error: Access denied: '{raw}' is outside /mnt namespace"
def ensure_path_allowed(path: str) -> str | None:
normalized = path.rstrip("/") if path != "/" else path
if normalized in {"/mnt", "/mnt/"}:
return
if not any(normalized == prefix or normalized.startswith(f"{prefix}/") for prefix in ALLOWED_PREFIXES):
return f"Error: Access denied: '{path}' is outside allowed namespaces"
if ".." in normalized.split("/"):
return "Error: Access denied: path traversal is not allowed"
def is_skills_path(path: str) -> bool:
p = path.rstrip("/")
return p == "/mnt/skills" or p.startswith("/mnt/skills/")
def validate_execute_command_paths(command: str) -> str | None:
"""限制命令中的绝对路径只允许 /mnt 或少量系统路径。"""
try:
tokens = shlex.split(command)
except ValueError as e:
return f"Error: Invalid shell command: {e}"
for token in tokens:
# 处理重定向符号后面的路径
candidate = token
if token.startswith((">", "<")) and len(token) > 1:
candidate = token[1:]
if not candidate.startswith("/"):
continue
if candidate.startswith("/mnt/"):
error = ensure_path_allowed(candidate)
if error:
return error
continue
if any(candidate.startswith(prefix) for prefix in SYSTEM_EXEC_ALLOWED_PREFIXES):
continue
# /usr/lib/python 等运行时依赖路径可能会出现在参数里,放行 /usr/lib
if candidate.startswith("/usr/lib/"):
continue
return f"Error: Access denied: absolute path '{candidate}' is not allowed"
def mask_host_paths(text: str, path_mappings: list[tuple[str, str]]) -> str:
masked = text
for host_path, virtual_path in path_mappings:
if not host_path:
continue
escaped = re.escape(host_path)
masked = re.sub(escaped, virtual_path, masked)
return masked