- Add default deny_read_paths/deny_read_regexes to host_sandbox_policy.json - Generate deny rules for ~/.ssh, ~/.aws, ~/.kube, keychains, .env, etc. - Readonly sandbox: global read + deny sensitive + write only /dev/null - Workspace-write sandbox: global read + deny sensitive + writable paths - Keep workspace files readable by re-allowing workspace subpath after deny - Backend API validates path authorization against deny list - Path auth dialog hint explains readable paths behavior
404 lines
18 KiB
Python
404 lines
18 KiB
Python
from __future__ import annotations
|
|
from server.chat import chat_bp
|
|
from server.chat.settings import _dispatch_runtime_mode_notice
|
|
import json, time
|
|
|
|
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
|
|
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
|
|
NETWORK_PERMISSION_OPTIONS = ["restricted", "full", "none"]
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional
|
|
from pathlib import Path
|
|
from io import BytesIO
|
|
import zipfile
|
|
import os
|
|
|
|
from flask import Blueprint, jsonify, request, session, send_file
|
|
from werkzeug.utils import secure_filename
|
|
from werkzeug.exceptions import RequestEntityTooLarge
|
|
import secrets
|
|
|
|
from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS
|
|
from modules.personalization_manager import (
|
|
load_personalization_config,
|
|
resolve_context_compression_settings,
|
|
save_personalization_config,
|
|
THINKING_INTERVAL_MIN,
|
|
THINKING_INTERVAL_MAX,
|
|
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
|
|
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
|
|
)
|
|
from modules.skills_manager import (
|
|
get_skills_catalog,
|
|
infer_private_skills_dir,
|
|
merge_enabled_skills,
|
|
sync_workspace_skills,
|
|
)
|
|
from modules.upload_security import UploadSecurityError
|
|
from modules.host_sandbox_policy import (
|
|
load_policy,
|
|
save_policy,
|
|
get_macos_deny_read_paths,
|
|
get_macos_deny_read_regexes,
|
|
)
|
|
from modules.user_manager import UserWorkspace
|
|
from core.web_terminal import WebTerminal
|
|
from config.model_profiles import get_model_context_window
|
|
|
|
from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
|
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
|
|
from server.security import rate_limited, prune_socket_tokens
|
|
from server.utils_common import debug_log
|
|
from server.state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS
|
|
from server.state import tool_approval_manager, user_question_manager
|
|
from server.extensions import socketio
|
|
from server.monitor import get_cached_monitor_snapshot
|
|
from server.files import sanitize_filename_preserve_unicode
|
|
import os
|
|
import re
|
|
|
|
UPLOAD_FOLDER_NAME = ".agents/user_upload"
|
|
|
|
|
|
def _path_conflicts_with_deny_list(path: str) -> Optional[str]:
|
|
"""检查用户授权路径是否与内置 deny 列表冲突,返回错误信息或 None。"""
|
|
if not path:
|
|
return None
|
|
expanded = os.path.abspath(os.path.expanduser(path))
|
|
for deny_path in get_macos_deny_read_paths():
|
|
deny_expanded = os.path.abspath(os.path.expanduser(deny_path))
|
|
deny_lower = deny_expanded.lower().rstrip("/")
|
|
expanded_lower = expanded.lower().rstrip("/")
|
|
if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + "/"):
|
|
return f"禁止授权敏感路径: {path}"
|
|
for pattern in get_macos_deny_read_regexes():
|
|
try:
|
|
if re.search(pattern, expanded) or re.search(pattern, path):
|
|
return f"禁止授权敏感文件: {path}"
|
|
except re.error:
|
|
continue
|
|
return None
|
|
@chat_bp.route('/api/permission-mode', methods=['GET'])
|
|
@api_login_required
|
|
@with_terminal
|
|
def get_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
"""获取当前权限模式。"""
|
|
current_conversation_id = getattr(terminal.context_manager, "current_conversation_id", None)
|
|
return jsonify({
|
|
"success": True,
|
|
"mode": terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else "unrestricted",
|
|
"pending_mode": (terminal.get_pending_runtime_modes().get("permission_mode") if hasattr(terminal, "get_pending_runtime_modes") else None),
|
|
"options": PERMISSION_MODE_OPTIONS,
|
|
"conversation_id": current_conversation_id,
|
|
})
|
|
|
|
@chat_bp.route('/api/permission-mode', methods=['POST'])
|
|
@api_login_required
|
|
@with_terminal
|
|
@rate_limited("permission_mode_switch", 30, 60, scope="user")
|
|
def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
"""更新当前对话权限模式。"""
|
|
data = request.get_json() or {}
|
|
target_mode = str(data.get("mode") or "").strip().lower()
|
|
if target_mode not in PERMISSION_MODE_OPTIONS:
|
|
return jsonify({
|
|
"success": False,
|
|
"error": "无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted"
|
|
}), 400
|
|
|
|
# 判断当前是否在对话运行期间
|
|
is_running = False
|
|
try:
|
|
from server.tasks import task_manager
|
|
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
|
|
running_tasks = [
|
|
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
|
|
]
|
|
if current_conv:
|
|
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
|
|
is_running = bool(running_tasks)
|
|
except Exception:
|
|
pass
|
|
|
|
if is_running:
|
|
# 运行期间:使用 pending 机制延迟到工具循环中统一处理,
|
|
# 避免「A→B→A」来回切换时中间值被覆盖后仍插入多余消息。
|
|
try:
|
|
terminal.queue_permission_mode_change(target_mode)
|
|
except Exception as exc:
|
|
return jsonify({
|
|
"success": False,
|
|
"error": str(exc),
|
|
"message": "更新权限模式失败"
|
|
}), 500
|
|
status = terminal.get_status()
|
|
socketio.emit('status_update', status, room=f"user_{username}")
|
|
return jsonify({
|
|
"success": True,
|
|
"mode": target_mode,
|
|
"pending_mode": target_mode,
|
|
"options": PERMISSION_MODE_OPTIONS,
|
|
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
|
|
"message": "权限模式将在当前工具执行完成后生效",
|
|
})
|
|
|
|
# 空闲期间:直接生效
|
|
previous_mode = terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None
|
|
try:
|
|
applied_mode = terminal.set_permission_mode(target_mode)
|
|
if hasattr(terminal, "pending_permission_mode"):
|
|
terminal.pending_permission_mode = None
|
|
if hasattr(terminal, "_persist_runtime_mode_metadata"):
|
|
terminal._persist_runtime_mode_metadata({
|
|
"permission_mode": applied_mode,
|
|
"pending_permission_mode": None,
|
|
})
|
|
except Exception as exc:
|
|
return jsonify({
|
|
"success": False,
|
|
"error": str(exc),
|
|
"message": "更新权限模式失败"
|
|
}), 500
|
|
|
|
session["permission_mode"] = applied_mode
|
|
if applied_mode != previous_mode:
|
|
permission_label = {
|
|
"readonly": "只读",
|
|
"approval": "批准",
|
|
"auto_approval": "自动审核",
|
|
"unrestricted": "无限制",
|
|
}.get(applied_mode, applied_mode)
|
|
_dispatch_runtime_mode_notice(terminal, username, f"权限模式被用户修改为 {permission_label}", source="权限变更")
|
|
status = terminal.get_status()
|
|
socketio.emit('status_update', status, room=f"user_{username}")
|
|
return jsonify({
|
|
"success": True,
|
|
"mode": applied_mode,
|
|
"pending_mode": None,
|
|
"options": PERMISSION_MODE_OPTIONS,
|
|
"conversation_id": getattr(terminal.context_manager, "current_conversation_id", None),
|
|
"message": "权限模式已更新并立即生效",
|
|
})
|
|
|
|
@chat_bp.route('/api/execution-mode', methods=['GET'])
|
|
@api_login_required
|
|
@with_terminal
|
|
def get_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
|
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
|
state = terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else {"mode": "sandbox"}
|
|
return jsonify({
|
|
"success": True,
|
|
"enabled": can_manage,
|
|
"state": state,
|
|
"pending_mode": (terminal.get_pending_runtime_modes().get("execution_mode") if hasattr(terminal, "get_pending_runtime_modes") else None),
|
|
"options": EXECUTION_MODE_OPTIONS,
|
|
})
|
|
|
|
@chat_bp.route('/api/execution-mode', methods=['POST'])
|
|
@api_login_required
|
|
@with_terminal
|
|
@rate_limited("execution_mode_switch", 20, 60, scope="user")
|
|
def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
|
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
|
if not can_manage:
|
|
return jsonify({"success": False, "error": "仅宿主机管理员可切换执行环境"}), 403
|
|
data = request.get_json() or {}
|
|
target_mode = str(data.get("mode") or "").strip().lower()
|
|
if target_mode not in EXECUTION_MODE_OPTIONS:
|
|
return jsonify({"success": False, "error": "无效执行环境,仅支持 sandbox / direct"}), 400
|
|
|
|
# 判断当前是否在对话运行期间
|
|
is_running = False
|
|
try:
|
|
from server.tasks import task_manager
|
|
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
|
|
running_tasks = [
|
|
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
|
|
]
|
|
if current_conv:
|
|
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
|
|
is_running = bool(running_tasks)
|
|
except Exception:
|
|
pass
|
|
|
|
if is_running:
|
|
# 运行期间:使用 pending 机制延迟到工具循环中统一处理,
|
|
# 避免「A→B→A」来回切换时中间值被覆盖后仍插入多余消息。
|
|
try:
|
|
terminal.queue_execution_mode_change(target_mode)
|
|
except Exception as exc:
|
|
return jsonify({"success": False, "error": str(exc), "message": "更新执行环境失败"}), 500
|
|
status = terminal.get_status()
|
|
socketio.emit('status_update', status, room=f"user_{username}")
|
|
return jsonify({
|
|
"success": True,
|
|
"state": {
|
|
**(terminal.get_execution_mode_state() if hasattr(terminal, "get_execution_mode_state") else {"mode": target_mode}),
|
|
"mode": target_mode,
|
|
},
|
|
"pending_mode": target_mode,
|
|
"options": EXECUTION_MODE_OPTIONS,
|
|
"message": "执行环境将在当前工具执行完成后生效",
|
|
})
|
|
|
|
# 空闲期间:直接生效
|
|
previous_mode = terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None
|
|
try:
|
|
state = terminal.set_execution_mode(target_mode)
|
|
if hasattr(terminal, "pending_execution_mode"):
|
|
terminal.pending_execution_mode = None
|
|
if hasattr(terminal, "_persist_runtime_mode_metadata"):
|
|
terminal._persist_runtime_mode_metadata({
|
|
"execution_mode": state.get("mode", target_mode),
|
|
"pending_execution_mode": None,
|
|
})
|
|
except Exception as exc:
|
|
return jsonify({"success": False, "error": str(exc), "message": "更新执行环境失败"}), 500
|
|
status = terminal.get_status()
|
|
current_mode = state.get("mode", target_mode)
|
|
if current_mode != previous_mode:
|
|
execution_label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(current_mode, current_mode)
|
|
_dispatch_runtime_mode_notice(terminal, username, f"执行环境被用户修改为 {execution_label}", source="执行环境变更")
|
|
socketio.emit('status_update', status, room=f"user_{username}")
|
|
return jsonify({
|
|
"success": True,
|
|
"state": state,
|
|
"pending_mode": None,
|
|
"options": EXECUTION_MODE_OPTIONS,
|
|
"message": "执行环境已更新并立即生效",
|
|
})
|
|
|
|
@chat_bp.route('/api/network-permission', methods=['GET'])
|
|
@api_login_required
|
|
@with_terminal
|
|
def get_network_permission(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
|
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
|
current = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else "restricted"
|
|
return jsonify({
|
|
"success": True,
|
|
"enabled": can_manage,
|
|
"mode": current,
|
|
"pending_mode": (terminal.get_pending_runtime_modes().get("network_permission") if hasattr(terminal, "get_pending_runtime_modes") else None),
|
|
"options": NETWORK_PERMISSION_OPTIONS,
|
|
})
|
|
|
|
@chat_bp.route('/api/network-permission', methods=['POST'])
|
|
@api_login_required
|
|
@with_terminal
|
|
@rate_limited("network_permission_switch", 20, 60, scope="user")
|
|
def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
|
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
|
if not can_manage:
|
|
return jsonify({"success": False, "error": "仅宿主机管理员可切换网络权限"}), 403
|
|
data = request.get_json() or {}
|
|
target_mode = str(data.get("mode") or "").strip().lower()
|
|
if target_mode not in NETWORK_PERMISSION_OPTIONS:
|
|
return jsonify({"success": False, "error": "无效网络权限,仅支持 restricted / full"}), 400
|
|
|
|
is_running = False
|
|
try:
|
|
from server.tasks import task_manager
|
|
current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None)
|
|
running_tasks = [
|
|
r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"}
|
|
]
|
|
if current_conv:
|
|
running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1)
|
|
is_running = bool(running_tasks)
|
|
except Exception:
|
|
pass
|
|
|
|
if is_running:
|
|
try:
|
|
terminal.queue_network_permission_change(target_mode)
|
|
except Exception as exc:
|
|
return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500
|
|
status = terminal.get_status()
|
|
socketio.emit('status_update', status, room=f"user_{username}")
|
|
return jsonify({
|
|
"success": True,
|
|
"mode": target_mode,
|
|
"pending_mode": target_mode,
|
|
"options": NETWORK_PERMISSION_OPTIONS,
|
|
"message": "网络权限将在当前工具执行完成后生效",
|
|
})
|
|
|
|
previous_mode = terminal.get_network_permission() if hasattr(terminal, "get_network_permission") else None
|
|
try:
|
|
applied = terminal.set_network_permission(target_mode)
|
|
if hasattr(terminal, "pending_network_permission"):
|
|
terminal.pending_network_permission = None
|
|
if hasattr(terminal, "_persist_runtime_mode_metadata"):
|
|
terminal._persist_runtime_mode_metadata({
|
|
"network_permission": applied,
|
|
"pending_network_permission": None,
|
|
})
|
|
except Exception as exc:
|
|
return jsonify({"success": False, "error": str(exc), "message": "更新网络权限失败"}), 500
|
|
status = terminal.get_status()
|
|
if applied != previous_mode:
|
|
label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(applied, applied)
|
|
_dispatch_runtime_mode_notice(terminal, username, f"网络权限被用户修改为 {label}", source="网络权限变更")
|
|
socketio.emit('status_update', status, room=f"user_{username}")
|
|
return jsonify({
|
|
"success": True,
|
|
"mode": applied,
|
|
"pending_mode": None,
|
|
"options": NETWORK_PERMISSION_OPTIONS,
|
|
"message": "网络权限已更新并立即生效",
|
|
})
|
|
|
|
@chat_bp.route('/api/path-authorization', methods=['GET'])
|
|
@api_login_required
|
|
@with_terminal
|
|
def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
|
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
|
data = load_policy()
|
|
return jsonify({
|
|
"success": True,
|
|
"enabled": can_manage,
|
|
"writable_paths": data.get("macos_writable_paths", []),
|
|
"readable_extra_paths": data.get("macos_readable_extra_paths", []),
|
|
"deny_read_paths": data.get("macos_deny_read_paths", []),
|
|
"deny_read_regexes": data.get("macos_deny_read_regexes", []),
|
|
})
|
|
|
|
@chat_bp.route('/api/path-authorization', methods=['POST'])
|
|
@api_login_required
|
|
@with_terminal
|
|
@rate_limited("path_authorization_update", 20, 60, scope="user")
|
|
def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
|
|
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
|
|
if not can_manage:
|
|
return jsonify({"success": False, "error": "仅宿主机管理员可管理路径授权"}), 403
|
|
data = request.get_json() or {}
|
|
writable_items = data.get("writable_paths")
|
|
readable_items = data.get("readable_extra_paths")
|
|
if not isinstance(writable_items, list) or not isinstance(readable_items, list):
|
|
return jsonify({"success": False, "error": "writable_paths/readable_extra_paths 必须为数组"}), 400
|
|
writable = [str(x).strip() for x in writable_items if str(x).strip()]
|
|
readable_extra = [str(x).strip() for x in readable_items if str(x).strip()]
|
|
if "/" in writable or "/" in readable_extra:
|
|
return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400
|
|
for p in writable + readable_extra:
|
|
conflict = _path_conflicts_with_deny_list(p)
|
|
if conflict:
|
|
return jsonify({"success": False, "error": conflict}), 400
|
|
payload = save_policy({
|
|
"macos_writable_paths": writable,
|
|
"macos_readable_extra_paths": readable_extra
|
|
})
|
|
return jsonify({
|
|
"success": True,
|
|
"writable_paths": payload.get("macos_writable_paths", []),
|
|
"readable_extra_paths": payload.get("macos_readable_extra_paths", []),
|
|
"deny_read_paths": payload.get("macos_deny_read_paths", []),
|
|
"deny_read_regexes": payload.get("macos_deny_read_regexes", []),
|
|
})
|