feat: add conversation-bound permission modes and tool approval flow
This commit is contained in:
parent
3f40aa949f
commit
e814e89e32
@ -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",
|
||||
]
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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:
|
||||
|
||||
79
modules/tool_approval_manager.py
Normal file
79
modules/tool_approval_manager.py
Normal 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)
|
||||
|
||||
4
prompts/permission_mode.txt
Normal file
4
prompts/permission_mode.txt
Normal file
@ -0,0 +1,4 @@
|
||||
## 权限模式约束(始终生效)
|
||||
- 当前权限模式:{permission_mode_label}({permission_mode})
|
||||
- 执行规则:{permission_rules}
|
||||
- 注意:工具始终可见;若超出权限,调用会返回拒绝或等待批准结果。
|
||||
@ -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/<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')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
# 首次对话尚未生成文件树快照,待首次用户消息时填充
|
||||
|
||||
Loading…
Reference in New Issue
Block a user