From e814e89e323c4f8cadcedacc2e1c0808e516b13c Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 11 Apr 2026 04:07:28 +0800 Subject: [PATCH] feat: add conversation-bound permission modes and tool approval flow --- config/limits.py | 42 ++++ core/main_terminal.py | 7 +- core/main_terminal_parts/context.py | 41 ++++ core/main_terminal_parts/tools_execution.py | 110 ++++++++++ core/main_terminal_parts/tools_policy.py | 35 +++ core/web_terminal.py | 11 +- modules/personalization_manager.py | 9 + modules/tool_approval_manager.py | 79 +++++++ prompts/permission_mode.txt | 4 + server/chat.py | 91 ++++++++ server/chat_flow_task_main.py | 8 + server/chat_flow_tool_loop.py | 231 +++++++++++++++++++- server/state.py | 3 + utils/context_manager.py | 27 ++- utils/conversation_manager.py | 1 + 15 files changed, 692 insertions(+), 7 deletions(-) create mode 100644 modules/tool_approval_manager.py create mode 100644 prompts/permission_mode.txt diff --git a/config/limits.py b/config/limits.py index c2ac9d0..dce988a 100644 --- a/config/limits.py +++ b/config/limits.py @@ -41,6 +41,45 @@ READ_TOOL_MAX_MATCHES = 50 PROJECT_MAX_STORAGE_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048")) PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024 +# 只读权限模式:run_command 白名单配置 +READONLY_RUN_COMMAND_ALLOWED = ( + "grep", + "find", + "ls", + "pwd", + "tree", + "cat", + "head", + "tail", + "less", + "rg", + "wc", + "du", + "stat", + "file", + "sed", + "awk", + "git", +) +READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS = ( + "status", + "log", + "diff", + "show", + "branch", + "rev-parse", +) +READONLY_RUN_COMMAND_BLOCKED_TOKENS = ( + "&&", + "||", + ";", + "|", + ">", + "<", + "$(", + "`", +) + __all__ = [ "MAX_CONTEXT_SIZE", "MAX_FILE_SIZE", @@ -70,4 +109,7 @@ __all__ = [ "READ_TOOL_MAX_MATCHES", "PROJECT_MAX_STORAGE_MB", "PROJECT_MAX_STORAGE_BYTES", + "READONLY_RUN_COMMAND_ALLOWED", + "READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS", + "READONLY_RUN_COMMAND_BLOCKED_TOKENS", ] diff --git a/core/main_terminal.py b/core/main_terminal.py index cd66c73..3a3aa95 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -176,6 +176,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.skill_strict_sub_agent_enabled: bool = False self.skill_strict_run_command_foreground_enabled: bool = False self.skill_strict_run_command_background_enabled: bool = False + self.default_permission_mode: str = "unrestricted" + self.current_permission_mode: str = "unrestricted" # 当前生效的启用 skills(用于强约束判定) self.enabled_skill_ids: Set[str] = set() self.apply_personalization_preferences() @@ -283,6 +285,9 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi conversation_id = self.context_manager.start_new_conversation( project_path=self.project_path, thinking_mode=self.thinking_mode, - run_mode=self.run_mode + run_mode=self.run_mode, + metadata_overrides={ + "permission_mode": self.default_permission_mode or "unrestricted" + }, ) print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}") diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index 7e6ad0a..c87db11 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -19,6 +19,8 @@ try: TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, + READONLY_RUN_COMMAND_ALLOWED, + READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, ) except ImportError: import sys @@ -39,6 +41,8 @@ except ImportError: TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, + READONLY_RUN_COMMAND_ALLOWED, + READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, ) from modules.file_manager import FileManager @@ -295,6 +299,43 @@ class MainTerminalContextMixin: "role": "system", "content": disabled_notice }) + permission_mode = "unrestricted" + try: + permission_mode = self.get_permission_mode() + except Exception: + permission_mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted") + permission_label_map = { + "readonly": "只读", + "approval": "批准", + "unrestricted": "无限制", + } + permission_rules_map = { + "readonly": "", + "approval": "可调用全部工具,但对工作区文件进行修改的工具需经用户批准后执行;若 run_command/terminal_input 属于只读白名单命令则可直接执行;若用户拒绝,工具循环会停止。", + "unrestricted": "工具按常规流程执行,不额外拦截。", + } + readonly_cmds = [cmd for cmd in (READONLY_RUN_COMMAND_ALLOWED or ()) if cmd not in {"git"}] + readonly_cmds_text = "/".join(readonly_cmds) if readonly_cmds else "grep/find" + git_readonly_text = "/".join(READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS or ()) + permission_rules_map["readonly"] = ( + "只允许读取/检索类工具;修改工作区的工具将被拒绝。" + f"run_command 仅允许只读命令({readonly_cmds_text}," + f"以及 git {git_readonly_text}),且禁止多指令拼接。" + ) + permission_prompt = self.load_prompt("permission_mode") + if permission_prompt: + try: + rendered_permission = permission_prompt.format( + permission_mode=permission_mode, + permission_mode_label=permission_label_map.get(permission_mode, permission_mode), + permission_rules=permission_rules_map.get(permission_mode, permission_rules_map["unrestricted"]), + ) + messages.append({ + "role": "system", + "content": rendered_permission, + }) + except Exception: + pass if shallow_replace_enabled: print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count} 条") return messages diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 546b291..f19e512 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -20,6 +20,9 @@ try: TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, + READONLY_RUN_COMMAND_ALLOWED, + READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, + READONLY_RUN_COMMAND_BLOCKED_TOKENS, ) except ImportError: import sys @@ -40,6 +43,9 @@ except ImportError: TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, + READONLY_RUN_COMMAND_ALLOWED, + READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS, + READONLY_RUN_COMMAND_BLOCKED_TOKENS, ) from modules.file_manager import FileManager @@ -98,6 +104,33 @@ class MainTerminalToolsExecutionMixin: "terminate_sub_agent", "get_sub_agent_status", } + _READONLY_ALLOWED_TOOLS = { + "web_search", + "extract_webpage", + "read_file", + "view_image", + "view_video", + "vlm_analyze", + "ocr_image", + "update_memory", + "todo_create", + "todo_update_task", + "todo_get", + "sleep", + } + _APPROVAL_REQUIRED_TOOLS = { + "run_command", + "run_python", + "terminal_input", + "write_file", + "edit_file", + "create_file", + "create_folder", + "delete_file", + "rename_file", + "save_webpage", + "terminal_session", + } def _record_sub_agent_message(self, message: Optional[str], task_id: Optional[str] = None, inline: bool = False): """以 system 消息记录子智能体状态。""" @@ -119,6 +152,79 @@ class MainTerminalToolsExecutionMixin: self.context_manager.add_conversation("system", message, metadata=metadata) print(f"{OUTPUT_FORMATS['info']} {message}") + def _is_readonly_run_command_allowed(self, command: Any) -> bool: + cmd = str(command or "").strip() + if not cmd: + return False + lowered = cmd.lower() + forbidden = list(READONLY_RUN_COMMAND_BLOCKED_TOKENS) + if any(token in cmd for token in forbidden): + return False + parts = cmd.split() + if not parts: + return False + + executable = parts[0].lower() + basic_allowed = set(READONLY_RUN_COMMAND_ALLOWED or ()) + if executable in basic_allowed and executable not in {"git", "sed", "awk"}: + return True + + if executable == "git": + if len(parts) < 2: + return False + git_sub = parts[1].lower() + return git_sub in set(READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS or ()) + + if executable == "sed": + return len(parts) >= 2 and parts[1] == "-n" + + if executable == "awk": + # 只读模式下允许基础文本处理,但拒绝 awk 的命令执行能力 + return "system(" not in lowered + + return False + + def evaluate_tool_permission(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + mode = "unrestricted" + try: + mode = self.get_permission_mode() + except Exception: + mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted") + args = arguments or {} + + if mode == "readonly": + if tool_name == "run_command": + if self._is_readonly_run_command_allowed(args.get("command")): + return {"allowed": True, "mode": mode} + return { + "allowed": False, + "mode": mode, + "code": "readonly_denied", + "message": ( + "当前处于只读模式:run_command 仅允许只读命令" + "(grep/find/ls/cat/rg/git status 等)且禁止多指令拼接。" + ) + } + if tool_name not in self._READONLY_ALLOWED_TOOLS: + return { + "allowed": False, + "mode": mode, + "code": "readonly_denied", + "message": "当前处于只读模式,已拒绝会修改工作区或执行高风险操作的工具调用。" + } + return {"allowed": True, "mode": mode} + + if mode == "approval": + if tool_name in {"run_command", "terminal_input"}: + command_text = args.get("command") or args.get("input") + if self._is_readonly_run_command_allowed(command_text): + return {"allowed": True, "mode": mode, "requires_approval": False} + if tool_name in self._APPROVAL_REQUIRED_TOOLS: + return {"allowed": True, "mode": mode, "requires_approval": True} + return {"allowed": True, "mode": mode} + + return {"allowed": True, "mode": mode} + @staticmethod def _skill_meta_key(skill_id: str) -> str: return f"skill_read::{skill_id}" @@ -664,6 +770,10 @@ class MainTerminalToolsExecutionMixin: path=arguments["path"], file_type=arguments["file_type"] ) + if isinstance(result, dict) and result.get("success"): + # create_file 新建文件后,视为当前会话已“接触”该文件, + # 避免后续 write_file/edit_file 被“先 read_file 再编辑”误拦截。 + self._mark_file_as_read_visited(result.get("path") or arguments.get("path")) # 添加备注 if result["success"] and arguments.get("annotation"): self.context_manager.update_annotation( diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index 7364130..d8d0b04 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -83,6 +83,7 @@ from config.model_profiles import ( logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True +PERMISSION_MODES = {"readonly", "approval", "unrestricted"} class MainTerminalToolsPolicyMixin: def apply_personalization_preferences(self, config: Optional[Dict[str, Any]] = None): @@ -160,6 +161,40 @@ class MainTerminalToolsPolicyMixin: # 静默禁用工具提示 self.silent_tool_disable = bool(effective_config.get("silent_tool_disable")) + permission_mode = effective_config.get("default_permission_mode") + if isinstance(permission_mode, str) and permission_mode in PERMISSION_MODES: + self.default_permission_mode = permission_mode + else: + self.default_permission_mode = "unrestricted" + if not getattr(self, "current_permission_mode", None): + self.current_permission_mode = self.default_permission_mode + + def get_permission_mode(self) -> str: + mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted") + if mode not in PERMISSION_MODES: + return "unrestricted" + return mode + + def set_permission_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> str: + normalized = str(mode or "").strip().lower() + if normalized not in PERMISSION_MODES: + raise ValueError("无效权限模式,仅支持 readonly / approval / unrestricted") + self.current_permission_mode = normalized + if not persist: + return normalized + + conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) + if conv_id and getattr(self, "context_manager", None): + try: + self.context_manager.conversation_manager.update_conversation_metadata( + conv_id, + {"permission_mode": normalized}, + ) + if self.context_manager.current_conversation_id == conv_id: + self.context_manager.conversation_metadata["permission_mode"] = normalized + except Exception: + pass + return normalized def set_tool_category_enabled(self, category: str, enabled: bool) -> None: """设置工具类别的启用状态 / Toggle tool category enablement.""" diff --git a/core/web_terminal.py b/core/web_terminal.py index af5d22f..e20f776 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -130,6 +130,7 @@ class WebTerminal(MainTerminal): logger.warning("忽略无效默认模型 %s: %s", preferred_model, exc) preferred_mode = prefs.get("default_run_mode") + preferred_permission_mode = prefs.get("default_permission_mode") or "unrestricted" if isinstance(preferred_mode, str) and preferred_mode.lower() in {"fast", "thinking", "deep"}: try: self.set_run_mode(preferred_mode.lower()) @@ -138,6 +139,10 @@ class WebTerminal(MainTerminal): else: # 未配置默认模式时回到快速模式 self.set_run_mode("fast") + try: + self.set_permission_mode(preferred_permission_mode, persist=False) + except Exception: + self.set_permission_mode("unrestricted", persist=False) thinking_mode = self.thinking_mode if isinstance(run_mode, str): @@ -160,7 +165,10 @@ class WebTerminal(MainTerminal): conversation_id = self.context_manager.start_new_conversation( project_path=self.project_path, thinking_mode=thinking_mode, - run_mode=self.run_mode + run_mode=self.run_mode, + metadata_overrides={ + "permission_mode": self.get_permission_mode(), + }, ) # 重置相关状态 @@ -320,6 +328,7 @@ class WebTerminal(MainTerminal): "thinking_status": self.get_thinking_mode_status(), "run_mode": self.run_mode, "model_key": getattr(self, "model_key", None), + "permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted", "has_images": getattr(self.context_manager, "has_images", False), "has_videos": getattr(self.context_manager, "has_videos", False), "context": { diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 6c547cc..c70bf4b 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -16,6 +16,7 @@ from core.tool_config import TOOL_CATEGORIES from config.model_profiles import get_registered_model_keys ALLOWED_RUN_MODES = {"fast", "thinking", "deep"} +ALLOWED_PERMISSION_MODES = {"readonly", "approval", "unrestricted"} PERSONALIZATION_FILENAME = "personalization.json" MAX_SHORT_FIELD_LENGTH = 20 @@ -51,6 +52,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "enabled_skills": None, "skills_catalog_snapshot": None, "default_run_mode": None, + "default_permission_mode": "unrestricted", "auto_generate_title": True, "tool_intent_enabled": True, "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) @@ -83,6 +85,7 @@ __all__ = [ "sanitize_personalization_payload", "resolve_context_compression_settings", "validate_context_compression_settings", + "ALLOWED_PERMISSION_MODES", ] @@ -224,6 +227,12 @@ def sanitize_personalization_payload( else: base["default_run_mode"] = _sanitize_run_mode(base.get("default_run_mode")) + permission_mode = data.get("default_permission_mode", base.get("default_permission_mode")) + if isinstance(permission_mode, str) and permission_mode in ALLOWED_PERMISSION_MODES: + base["default_permission_mode"] = permission_mode + elif base.get("default_permission_mode") not in ALLOWED_PERMISSION_MODES: + base["default_permission_mode"] = "unrestricted" + # 默认模型 chosen_model = data.get("default_model", base.get("default_model")) if isinstance(chosen_model, str) and chosen_model in allowed_models: diff --git a/modules/tool_approval_manager.py b/modules/tool_approval_manager.py new file mode 100644 index 0000000..6153f4d --- /dev/null +++ b/modules/tool_approval_manager.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import threading +import time +import uuid +from typing import Any, Dict, List, Optional + + +class ToolApprovalManager: + def __init__(self): + self._items: Dict[str, Dict[str, Any]] = {} + self._lock = threading.Lock() + + def create_request( + self, + *, + username: str, + conversation_id: Optional[str], + task_id: Optional[str], + tool_call_id: Optional[str], + tool_name: str, + arguments: Dict[str, Any], + preview: Dict[str, Any], + ) -> Dict[str, Any]: + approval_id = f"approval_{uuid.uuid4().hex}" + item = { + "approval_id": approval_id, + "username": username, + "conversation_id": conversation_id, + "task_id": task_id, + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "arguments": arguments or {}, + "preview": preview or {}, + "status": "pending", + "created_at": time.time(), + "decided_at": None, + "decision": None, + } + with self._lock: + self._items[approval_id] = item + return dict(item) + + def get(self, approval_id: str) -> Optional[Dict[str, Any]]: + with self._lock: + item = self._items.get(approval_id) + return dict(item) if item else None + + def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: + with self._lock: + rows = [] + for item in self._items.values(): + if item.get("username") != username: + continue + if item.get("status") != "pending": + continue + if conversation_id and item.get("conversation_id") != conversation_id: + continue + rows.append(dict(item)) + rows.sort(key=lambda x: x.get("created_at", 0.0)) + return rows + + def decide(self, approval_id: str, username: str, decision: str) -> Dict[str, Any]: + normalized = str(decision or "").strip().lower() + if normalized not in {"approved", "rejected"}: + raise ValueError("decision 仅支持 approved / rejected") + with self._lock: + item = self._items.get(approval_id) + if not item: + raise KeyError("审批请求不存在") + if item.get("username") != username: + raise PermissionError("无权限操作该审批请求") + if item.get("status") != "pending": + return dict(item) + item["status"] = normalized + item["decision"] = normalized + item["decided_at"] = time.time() + return dict(item) + diff --git a/prompts/permission_mode.txt b/prompts/permission_mode.txt new file mode 100644 index 0000000..2d28e20 --- /dev/null +++ b/prompts/permission_mode.txt @@ -0,0 +1,4 @@ +## 权限模式约束(始终生效) +- 当前权限模式:{permission_mode_label}({permission_mode}) +- 执行规则:{permission_rules} +- 注意:工具始终可见;若超出权限,调用会返回拒绝或等待批准结果。 diff --git a/server/chat.py b/server/chat.py index 4b87b45..20c3e62 100644 --- a/server/chat.py +++ b/server/chat.py @@ -33,6 +33,7 @@ from .context import with_terminal, get_gui_manager, get_upload_guard, build_upl from .security import rate_limited, prune_socket_tokens from .utils_common import debug_log from .state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS +from .state import tool_approval_manager from .extensions import socketio from .monitor import get_cached_monitor_snapshot from .files import sanitize_filename_preserve_unicode @@ -41,6 +42,8 @@ UPLOAD_FOLDER_NAME = "user_upload" chat_bp = Blueprint('chat', __name__) +PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"] + @chat_bp.route('/api/thinking-mode', methods=['POST']) @api_login_required @with_terminal @@ -584,6 +587,94 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str "error": str(exc) }), 400 + +@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", + "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 / unrestricted" + }), 400 + try: + applied_mode = terminal.set_permission_mode(target_mode, persist=True) + except Exception as exc: + return jsonify({ + "success": False, + "error": str(exc), + "message": "切换权限模式失败" + }), 500 + + session["permission_mode"] = applied_mode + status = terminal.get_status() + socketio.emit('status_update', status, room=f"user_{username}") + return jsonify({ + "success": True, + "mode": applied_mode, + "options": PERMISSION_MODE_OPTIONS, + "conversation_id": getattr(terminal.context_manager, "current_conversation_id", None), + }) + + +@chat_bp.route('/api/tool-approvals/pending', methods=['GET']) +@api_login_required +@with_terminal +def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """获取当前用户待审批工具列表。""" + requested_conv_id = (request.args.get("conversation_id") or "").strip() or None + if requested_conv_id is None: + requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None) + items = tool_approval_manager.list_pending(username=username, conversation_id=requested_conv_id) + return jsonify({ + "success": True, + "items": items, + "conversation_id": requested_conv_id, + }) + + +@chat_bp.route('/api/tool-approvals//decision', methods=['POST']) +@api_login_required +@with_terminal +@rate_limited("tool_approval_decision", 60, 60, scope="user") +def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str): + """提交工具审批决策。""" + data = request.get_json() or {} + decision = str(data.get("decision") or "").strip().lower() + try: + item = tool_approval_manager.decide(approval_id=approval_id, username=username, decision=decision) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except KeyError: + return jsonify({"success": False, "error": "审批请求不存在"}), 404 + except PermissionError as exc: + return jsonify({"success": False, "error": str(exc)}), 403 + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + return jsonify({ + "success": True, + "item": item, + }) + @chat_bp.route('/api/terminals') @api_login_required @with_terminal diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 054e3e2..1eea603 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -1102,6 +1102,14 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac if tool_loop_result.get("stopped"): finalize_user_work_timer() return + if tool_loop_result.get("approval_rejected"): + sender('task_stopped', { + 'message': tool_loop_result.get("approval_message") or '操作被用户拒绝', + 'reason': 'approval_rejected', + 'conversation_id': conversation_id + }) + finalize_user_work_timer() + return if tool_loop_result.get("deep_compressed"): deep_result = tool_loop_result.get("deep_result") or {} guide_message = (deep_result.get("guide_message") or "").strip() diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 83be749..b8b6db9 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio import json import time -from typing import Optional +from pathlib import Path +from typing import Optional, Dict, Any, List from .utils_common import debug_log, brief_log from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT +from .state import tool_approval_manager from .monitor import cache_monitor_snapshot from .security import compact_web_search_result from .chat_flow_helpers import detect_tool_failure @@ -18,6 +20,124 @@ from modules.personalization_manager import load_personalization_config, resolve from .deep_compression import run_deep_compression +def _format_numbered_lines(lines: List[str], start_line_no: int) -> List[Dict[str, Any]]: + return [ + { + "line_no": start_line_no + idx, + "content": line.rstrip("\n"), + } + for idx, line in enumerate(lines) + ] + + +def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + args = arguments or {} + preview: Dict[str, Any] = { + "type": function_name, + "tool_name": function_name, + "arguments": args, + } + + if function_name == "edit_file": + file_path = args.get("file_path") + old_string = args.get("old_string") + new_string = args.get("new_string") + preview["file_path"] = file_path + if not file_path: + preview["summary"] = "缺少 file_path" + return preview + try: + valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path)) + if not valid or full_path is None: + preview["summary"] = err or "路径校验失败" + return preview + resolved_path = str(Path(full_path)) + preview["resolved_path"] = resolved_path + if not full_path.exists() or not full_path.is_file(): + preview["summary"] = "目标文件不存在,无法生成上下文预览" + return preview + content = full_path.read_text(encoding="utf-8", errors="ignore") + old_text = str(old_string or "") + new_text = str(new_string or "") + old_lines = old_text.splitlines() + new_lines = new_text.splitlines() + idx = content.find(old_text) if old_text else -1 + if idx >= 0: + prefix = content[:idx] + start_line_no = prefix.count("\n") + 1 + end_line_no = start_line_no + max(1, len(old_lines)) - 1 + all_lines = content.splitlines() + before_start = max(1, start_line_no - 3) + before = all_lines[before_start - 1:start_line_no - 1] + after_end = min(len(all_lines), end_line_no + 3) + after = all_lines[end_line_no:after_end] + preview["edit_context"] = { + "before": _format_numbered_lines(before, before_start), + "old": _format_numbered_lines(old_lines or [""], start_line_no), + "new": _format_numbered_lines(new_lines or [""], start_line_no), + "after": _format_numbered_lines(after, end_line_no + 1), + "old_start_line": start_line_no, + "old_end_line": end_line_no, + } + preview["summary"] = f"编辑 {file_path} 第 {start_line_no}-{end_line_no} 行" + else: + preview["edit_context"] = { + "before": [], + "old": _format_numbered_lines(old_lines or [""], 1), + "new": _format_numbered_lines(new_lines or [""], 1), + "after": [], + "old_start_line": None, + "old_end_line": None, + } + preview["summary"] = "未在文件中定位到 old_string,显示原始替换内容" + except Exception as exc: + preview["summary"] = f"生成编辑预览失败: {exc}" + return preview + + if function_name in {"run_command", "terminal_input"}: + preview["command"] = args.get("command") + preview["summary"] = f"执行命令: {args.get('command') or ''}" + return preview + + if function_name in {"create_file", "create_folder", "delete_file"}: + preview["path"] = args.get("path") + preview["summary"] = f"{function_name}: {args.get('path') or ''}" + return preview + + if function_name == "rename_file": + preview["old_path"] = args.get("old_path") + preview["new_path"] = args.get("new_path") + preview["summary"] = f"rename_file: {args.get('old_path') or ''} -> {args.get('new_path') or ''}" + return preview + + if function_name == "write_file": + content = str(args.get("content") or "") + preview["file_path"] = args.get("file_path") + preview["append"] = bool(args.get("append", False)) + preview["content_preview"] = content[:4000] + preview["content_length"] = len(content) + preview["summary"] = f"write_file: {args.get('file_path') or ''} ({'append' if preview['append'] else 'overwrite'})" + return preview + + return preview + + +async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]: + started = time.time() + while True: + row = tool_approval_manager.get(approval_id) + if not row: + return {"decision": "rejected", "reason": "审批请求不存在"} + if row.get("username") != username: + return {"decision": "rejected", "reason": "审批请求用户不匹配"} + status = row.get("status") + if status in {"approved", "rejected"}: + return {"decision": status, "item": row} + if (time.time() - started) >= timeout_seconds: + return {"decision": "rejected", "reason": "审批超时"} + await asyncio.sleep(0.2) + + async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None): previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False) web_terminal._tool_loop_active = True @@ -182,6 +302,115 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie debug_log(f"执行工具: {function_name} (ID: {tool_call_id})") + permission_eval = web_terminal.evaluate_tool_permission(function_name, arguments) + if not permission_eval.get("allowed", True): + denied_message = permission_eval.get("message") or "当前权限模式不允许执行该工具。" + denied_payload = { + "success": False, + "status": "denied", + "code": permission_eval.get("code") or "permission_denied", + "tool": function_name, + "mode": permission_eval.get("mode"), + "message": denied_message, + } + sender('update_action', { + 'preparing_id': tool_call_id, + 'status': 'completed', + 'result': denied_payload, + 'message': denied_message, + 'conversation_id': conversation_id + }) + denied_content = json.dumps(denied_payload, ensure_ascii=False) + web_terminal.context_manager.add_conversation( + "tool", + denied_content, + tool_call_id=tool_call_id, + name=function_name + ) + messages.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "name": function_name, + "content": denied_content + }) + continue + + if permission_eval.get("requires_approval"): + approval_preview = _build_tool_approval_preview(web_terminal, function_name, arguments) + approval_item = tool_approval_manager.create_request( + username=username, + conversation_id=conversation_id, + task_id=getattr(web_terminal, "task_id", None), + tool_call_id=tool_call_id, + tool_name=function_name, + arguments=arguments, + preview=approval_preview, + ) + sender('tool_approval_required', { + 'approval': approval_item, + 'conversation_id': conversation_id, + }) + sender('update_action', { + 'preparing_id': tool_call_id, + 'status': 'awaiting_approval', + 'result': { + "success": False, + "status": "awaiting_approval", + "approval_id": approval_item.get("approval_id"), + "message": "等待用户审批" + }, + 'message': '等待用户审批', + 'conversation_id': conversation_id + }) + wait_result = await _wait_for_tool_approval( + approval_id=approval_item.get("approval_id"), + username=username, + ) + sender('tool_approval_resolved', { + 'approval_id': approval_item.get("approval_id"), + 'decision': wait_result.get("decision"), + 'conversation_id': conversation_id, + }) + if wait_result.get("decision") != "approved": + reject_message = "操作被用户拒绝" + if wait_result.get("reason") == "审批超时": + reject_message = "审批超时,操作未执行" + reject_payload = { + "success": False, + "status": "rejected", + "code": "approval_rejected", + "tool": function_name, + "message": reject_message, + "approval_id": approval_item.get("approval_id"), + } + sender('update_action', { + 'preparing_id': tool_call_id, + 'status': 'completed', + 'result': reject_payload, + 'message': reject_message, + 'conversation_id': conversation_id + }) + reject_content = json.dumps(reject_payload, ensure_ascii=False) + web_terminal.context_manager.add_conversation( + "tool", + reject_content, + tool_call_id=tool_call_id, + name=function_name + ) + messages.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "name": function_name, + "content": reject_content + }) + web_terminal._tool_loop_active = previous_tool_loop_active + return { + "stopped": False, + "approval_rejected": True, + "approval_message": reject_message, + "last_tool_call_time": last_tool_call_time + } + # 发送工具开始事件 tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}" monitor_snapshot = None diff --git a/server/state.py b/server/state.py index bb0c9a9..e4a59a6 100644 --- a/server/state.py +++ b/server/state.py @@ -13,6 +13,7 @@ from modules.usage_tracker import UsageTracker from modules.user_container_manager import UserContainerManager from modules.user_manager import UserManager from modules.api_user_manager import ApiUserManager +from modules.tool_approval_manager import ToolApprovalManager # 全局实例 user_manager = UserManager() @@ -26,6 +27,7 @@ RECENT_UPLOAD_EVENT_LIMIT = 150 RECENT_UPLOAD_FEED_LIMIT = 60 stop_flags: Dict[str, Dict[str, Any]] = {} active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling +tool_approval_manager = ToolApprovalManager() # 监控/限流/用量 MONITOR_FILE_TOOLS = {'write_file', 'edit_file'} @@ -88,6 +90,7 @@ __all__ = [ "pending_socket_tokens", "usage_trackers", "active_login_nonces", + "tool_approval_manager", "MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE_LIMIT", "PROJECT_STORAGE_CACHE", diff --git a/utils/context_manager.py b/utils/context_manager.py index cd7cb38..d22f58c 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -683,7 +683,13 @@ class ContextManager: # 新增:对话持久化相关方法 # =========================================== - def start_new_conversation(self, project_path: str = None, thinking_mode: bool = False, run_mode: Optional[str] = None) -> str: + def start_new_conversation( + self, + project_path: str = None, + thinking_mode: bool = False, + run_mode: Optional[str] = None, + metadata_overrides: Optional[Dict[str, Any]] = None, + ) -> str: """ 开始新对话 @@ -721,7 +727,8 @@ class ContextManager: initial_messages=[], model_key=getattr(self.main_terminal, "model_key", None), has_images=False, - has_videos=False + has_videos=False, + metadata_overrides=metadata_overrides, ) # 重置当前状态 @@ -791,6 +798,7 @@ class ContextManager: self.project_path = resolved_project_path run_mode = metadata.get("run_mode") + permission_mode = metadata.get("permission_mode") model_key = metadata.get("model_key") self.has_images = metadata.get("has_images", False) self.has_videos = metadata.get("has_videos", False) @@ -822,6 +830,11 @@ class ContextManager: self.main_terminal.set_run_mode("fast") except Exception: pass + try: + fallback_mode = getattr(self.main_terminal, "default_permission_mode", "unrestricted") + self.main_terminal.set_permission_mode(permission_mode or fallback_mode, persist=False) + except Exception: + pass print(f"📖 加载对话: {conversation_id} - {conversation_data.get('title', '未知标题')}") print(f"📊 包含 {len(self.conversation_history)} 条消息") @@ -1153,7 +1166,10 @@ class ContextManager: run_mode=run_mode, initial_messages=[system_message], model_key=model_key, - has_images=has_images + has_images=has_images, + metadata_overrides={ + "permission_mode": metadata.get("permission_mode", "unrestricted"), + }, ) # 设置压缩后的对话标题 @@ -1197,7 +1213,10 @@ class ContextManager: run_mode=run_mode, initial_messages=original_messages, model_key=model_key, - has_images=has_images + has_images=has_images, + metadata_overrides={ + "permission_mode": metadata.get("permission_mode", "unrestricted"), + }, ) token_stats = conversation_data.get("token_statistics") diff --git a/utils/conversation_manager.py b/utils/conversation_manager.py index a3ad417..c84bc13 100644 --- a/utils/conversation_manager.py +++ b/utils/conversation_manager.py @@ -374,6 +374,7 @@ class ConversationManager: "thinking_mode": thinking_mode, "run_mode": normalized_mode, "model_key": model_key, + "permission_mode": "unrestricted", "has_images": has_images, "has_videos": has_videos, # 首次对话尚未生成文件树快照,待首次用户消息时填充