130 lines
4.1 KiB
Python
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
|