feat: add conversation-bound permission modes and tool approval flow

This commit is contained in:
JOJO 2026-04-11 04:07:28 +08:00
parent 3f40aa949f
commit e814e89e32
15 changed files with 692 additions and 7 deletions

View File

@ -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_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048"))
PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024 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__ = [ __all__ = [
"MAX_CONTEXT_SIZE", "MAX_CONTEXT_SIZE",
"MAX_FILE_SIZE", "MAX_FILE_SIZE",
@ -70,4 +109,7 @@ __all__ = [
"READ_TOOL_MAX_MATCHES", "READ_TOOL_MAX_MATCHES",
"PROJECT_MAX_STORAGE_MB", "PROJECT_MAX_STORAGE_MB",
"PROJECT_MAX_STORAGE_BYTES", "PROJECT_MAX_STORAGE_BYTES",
"READONLY_RUN_COMMAND_ALLOWED",
"READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS",
"READONLY_RUN_COMMAND_BLOCKED_TOKENS",
] ]

View File

@ -176,6 +176,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.skill_strict_sub_agent_enabled: bool = False self.skill_strict_sub_agent_enabled: bool = False
self.skill_strict_run_command_foreground_enabled: bool = False self.skill_strict_run_command_foreground_enabled: bool = False
self.skill_strict_run_command_background_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用于强约束判定 # 当前生效的启用 skills用于强约束判定
self.enabled_skill_ids: Set[str] = set() self.enabled_skill_ids: Set[str] = set()
self.apply_personalization_preferences() self.apply_personalization_preferences()
@ -283,6 +285,9 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
conversation_id = self.context_manager.start_new_conversation( conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path, project_path=self.project_path,
thinking_mode=self.thinking_mode, 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}") print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}")

View File

@ -19,6 +19,8 @@ try:
TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED, CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
) )
except ImportError: except ImportError:
import sys import sys
@ -39,6 +41,8 @@ except ImportError:
TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED, CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
) )
from modules.file_manager import FileManager from modules.file_manager import FileManager
@ -295,6 +299,43 @@ class MainTerminalContextMixin:
"role": "system", "role": "system",
"content": disabled_notice "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: if shallow_replace_enabled:
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}") print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}")
return messages return messages

View File

@ -20,6 +20,9 @@ try:
TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED, CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
READONLY_RUN_COMMAND_BLOCKED_TOKENS,
) )
except ImportError: except ImportError:
import sys import sys
@ -40,6 +43,9 @@ except ImportError:
TERMINAL_SANDBOX_MEMORY, TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED, CUSTOM_TOOLS_ENABLED,
READONLY_RUN_COMMAND_ALLOWED,
READONLY_RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
READONLY_RUN_COMMAND_BLOCKED_TOKENS,
) )
from modules.file_manager import FileManager from modules.file_manager import FileManager
@ -98,6 +104,33 @@ class MainTerminalToolsExecutionMixin:
"terminate_sub_agent", "terminate_sub_agent",
"get_sub_agent_status", "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): def _record_sub_agent_message(self, message: Optional[str], task_id: Optional[str] = None, inline: bool = False):
"""以 system 消息记录子智能体状态。""" """以 system 消息记录子智能体状态。"""
@ -119,6 +152,79 @@ class MainTerminalToolsExecutionMixin:
self.context_manager.add_conversation("system", message, metadata=metadata) self.context_manager.add_conversation("system", message, metadata=metadata)
print(f"{OUTPUT_FORMATS['info']} {message}") 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 @staticmethod
def _skill_meta_key(skill_id: str) -> str: def _skill_meta_key(skill_id: str) -> str:
return f"skill_read::{skill_id}" return f"skill_read::{skill_id}"
@ -664,6 +770,10 @@ class MainTerminalToolsExecutionMixin:
path=arguments["path"], path=arguments["path"],
file_type=arguments["file_type"] 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"): if result["success"] and arguments.get("annotation"):
self.context_manager.update_annotation( self.context_manager.update_annotation(

View File

@ -83,6 +83,7 @@ from config.model_profiles import (
logger = setup_logger(__name__) logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True DISABLE_LENGTH_CHECK = True
PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
class MainTerminalToolsPolicyMixin: class MainTerminalToolsPolicyMixin:
def apply_personalization_preferences(self, config: Optional[Dict[str, Any]] = None): 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")) 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: def set_tool_category_enabled(self, category: str, enabled: bool) -> None:
"""设置工具类别的启用状态 / Toggle tool category enablement.""" """设置工具类别的启用状态 / Toggle tool category enablement."""

View File

@ -130,6 +130,7 @@ class WebTerminal(MainTerminal):
logger.warning("忽略无效默认模型 %s: %s", preferred_model, exc) logger.warning("忽略无效默认模型 %s: %s", preferred_model, exc)
preferred_mode = prefs.get("default_run_mode") 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"}: if isinstance(preferred_mode, str) and preferred_mode.lower() in {"fast", "thinking", "deep"}:
try: try:
self.set_run_mode(preferred_mode.lower()) self.set_run_mode(preferred_mode.lower())
@ -138,6 +139,10 @@ class WebTerminal(MainTerminal):
else: else:
# 未配置默认模式时回到快速模式 # 未配置默认模式时回到快速模式
self.set_run_mode("fast") 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 thinking_mode = self.thinking_mode
if isinstance(run_mode, str): if isinstance(run_mode, str):
@ -160,7 +165,10 @@ class WebTerminal(MainTerminal):
conversation_id = self.context_manager.start_new_conversation( conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path, project_path=self.project_path,
thinking_mode=thinking_mode, 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(), "thinking_status": self.get_thinking_mode_status(),
"run_mode": self.run_mode, "run_mode": self.run_mode,
"model_key": getattr(self, "model_key", None), "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_images": getattr(self.context_manager, "has_images", False),
"has_videos": getattr(self.context_manager, "has_videos", False), "has_videos": getattr(self.context_manager, "has_videos", False),
"context": { "context": {

View File

@ -16,6 +16,7 @@ from core.tool_config import TOOL_CATEGORIES
from config.model_profiles import get_registered_model_keys from config.model_profiles import get_registered_model_keys
ALLOWED_RUN_MODES = {"fast", "thinking", "deep"} ALLOWED_RUN_MODES = {"fast", "thinking", "deep"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
PERSONALIZATION_FILENAME = "personalization.json" PERSONALIZATION_FILENAME = "personalization.json"
MAX_SHORT_FIELD_LENGTH = 20 MAX_SHORT_FIELD_LENGTH = 20
@ -51,6 +52,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"enabled_skills": None, "enabled_skills": None,
"skills_catalog_snapshot": None, "skills_catalog_snapshot": None,
"default_run_mode": None, "default_run_mode": None,
"default_permission_mode": "unrestricted",
"auto_generate_title": True, "auto_generate_title": True,
"tool_intent_enabled": True, "tool_intent_enabled": True,
"skill_hints_enabled": False, # Skill 提示系统开关(默认关闭) "skill_hints_enabled": False, # Skill 提示系统开关(默认关闭)
@ -83,6 +85,7 @@ __all__ = [
"sanitize_personalization_payload", "sanitize_personalization_payload",
"resolve_context_compression_settings", "resolve_context_compression_settings",
"validate_context_compression_settings", "validate_context_compression_settings",
"ALLOWED_PERMISSION_MODES",
] ]
@ -224,6 +227,12 @@ def sanitize_personalization_payload(
else: else:
base["default_run_mode"] = _sanitize_run_mode(base.get("default_run_mode")) 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")) chosen_model = data.get("default_model", base.get("default_model"))
if isinstance(chosen_model, str) and chosen_model in allowed_models: if isinstance(chosen_model, str) and chosen_model in allowed_models:

View File

@ -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)

View File

@ -0,0 +1,4 @@
## 权限模式约束(始终生效)
- 当前权限模式:{permission_mode_label}{permission_mode}
- 执行规则:{permission_rules}
- 注意:工具始终可见;若超出权限,调用会返回拒绝或等待批准结果。

View File

@ -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 .security import rate_limited, prune_socket_tokens
from .utils_common import debug_log 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 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 .extensions import socketio
from .monitor import get_cached_monitor_snapshot from .monitor import get_cached_monitor_snapshot
from .files import sanitize_filename_preserve_unicode from .files import sanitize_filename_preserve_unicode
@ -41,6 +42,8 @@ UPLOAD_FOLDER_NAME = "user_upload"
chat_bp = Blueprint('chat', __name__) chat_bp = Blueprint('chat', __name__)
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"]
@chat_bp.route('/api/thinking-mode', methods=['POST']) @chat_bp.route('/api/thinking-mode', methods=['POST'])
@api_login_required @api_login_required
@with_terminal @with_terminal
@ -584,6 +587,94 @@ def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str
"error": str(exc) "error": str(exc)
}), 400 }), 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/<approval_id>/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') @chat_bp.route('/api/terminals')
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -1102,6 +1102,14 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
if tool_loop_result.get("stopped"): if tool_loop_result.get("stopped"):
finalize_user_work_timer() finalize_user_work_timer()
return 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"): if tool_loop_result.get("deep_compressed"):
deep_result = tool_loop_result.get("deep_result") or {} deep_result = tool_loop_result.get("deep_result") or {}
guide_message = (deep_result.get("guide_message") or "").strip() guide_message = (deep_result.get("guide_message") or "").strip()

View File

@ -3,10 +3,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import time 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 .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 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 .monitor import cache_monitor_snapshot
from .security import compact_web_search_result from .security import compact_web_search_result
from .chat_flow_helpers import detect_tool_failure 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 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): 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) previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False)
web_terminal._tool_loop_active = True 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})") 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()}" tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}"
monitor_snapshot = None monitor_snapshot = None

View File

@ -13,6 +13,7 @@ from modules.usage_tracker import UsageTracker
from modules.user_container_manager import UserContainerManager from modules.user_container_manager import UserContainerManager
from modules.user_manager import UserManager from modules.user_manager import UserManager
from modules.api_user_manager import ApiUserManager from modules.api_user_manager import ApiUserManager
from modules.tool_approval_manager import ToolApprovalManager
# 全局实例 # 全局实例
user_manager = UserManager() user_manager = UserManager()
@ -26,6 +27,7 @@ RECENT_UPLOAD_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60 RECENT_UPLOAD_FEED_LIMIT = 60
stop_flags: Dict[str, Dict[str, Any]] = {} stop_flags: Dict[str, Dict[str, Any]] = {}
active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling
tool_approval_manager = ToolApprovalManager()
# 监控/限流/用量 # 监控/限流/用量
MONITOR_FILE_TOOLS = {'write_file', 'edit_file'} MONITOR_FILE_TOOLS = {'write_file', 'edit_file'}
@ -88,6 +90,7 @@ __all__ = [
"pending_socket_tokens", "pending_socket_tokens",
"usage_trackers", "usage_trackers",
"active_login_nonces", "active_login_nonces",
"tool_approval_manager",
"MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE",
"MONITOR_SNAPSHOT_CACHE_LIMIT", "MONITOR_SNAPSHOT_CACHE_LIMIT",
"PROJECT_STORAGE_CACHE", "PROJECT_STORAGE_CACHE",

View File

@ -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=[], initial_messages=[],
model_key=getattr(self.main_terminal, "model_key", None), model_key=getattr(self.main_terminal, "model_key", None),
has_images=False, 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 self.project_path = resolved_project_path
run_mode = metadata.get("run_mode") run_mode = metadata.get("run_mode")
permission_mode = metadata.get("permission_mode")
model_key = metadata.get("model_key") model_key = metadata.get("model_key")
self.has_images = metadata.get("has_images", False) self.has_images = metadata.get("has_images", False)
self.has_videos = metadata.get("has_videos", False) self.has_videos = metadata.get("has_videos", False)
@ -822,6 +830,11 @@ class ContextManager:
self.main_terminal.set_run_mode("fast") self.main_terminal.set_run_mode("fast")
except Exception: except Exception:
pass 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"📖 加载对话: {conversation_id} - {conversation_data.get('title', '未知标题')}")
print(f"📊 包含 {len(self.conversation_history)} 条消息") print(f"📊 包含 {len(self.conversation_history)} 条消息")
@ -1153,7 +1166,10 @@ class ContextManager:
run_mode=run_mode, run_mode=run_mode,
initial_messages=[system_message], initial_messages=[system_message],
model_key=model_key, 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, run_mode=run_mode,
initial_messages=original_messages, initial_messages=original_messages,
model_key=model_key, 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") token_stats = conversation_data.get("token_statistics")

View File

@ -374,6 +374,7 @@ class ConversationManager:
"thinking_mode": thinking_mode, "thinking_mode": thinking_mode,
"run_mode": normalized_mode, "run_mode": normalized_mode,
"model_key": model_key, "model_key": model_key,
"permission_mode": "unrestricted",
"has_images": has_images, "has_images": has_images,
"has_videos": has_videos, "has_videos": has_videos,
# 首次对话尚未生成文件树快照,待首次用户消息时填充 # 首次对话尚未生成文件树快照,待首次用户消息时填充