diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 3f74f0ec9b..0021dbf9b7 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1043,6 +1043,33 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: ) continue + # 权限拦截 + # 即使 LLM 意外获知了受限工具,执行前也会在此被阻断。 + if getattr(func_tool, "require_admin", False): + _caller_event = getattr(self.run_context.context, "event", None) + if getattr(_caller_event, "role", "member") != "admin": + sender_id = ( + _caller_event.get_sender_id() + if _caller_event + else "unknown" + ) + logger.warning( + "Tool '%s' requires admin, " + "caller role='%s', sender_id='%s'. Blocked.", + func_tool_name, + getattr(_caller_event, "role", "?"), + sender_id, + ) + _append_tool_call_result( + func_tool_id, + f"error: Permission denied. " + f"Tool '{func_tool_name}' requires admin privileges. " + f"Your user ID is {sender_id}. " + "Please ask your AstrBot administrator to grant " + "you access to this tool.", + ) + continue + valid_params = {} # 参数过滤:只传递函数实际需要的参数 # 获取实际的 handler 函数 diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index 4cee6ba6d1..278c220eec 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -58,6 +58,11 @@ class FunctionTool(ToolSchema, Generic[TContext]): Whether the tool is active. This field is a special field for AstrBot. You can ignore it when integrating with other frameworks. """ + require_admin: bool = False + """ + If True, this tool can only be called by admin users (event.role == 'admin'). + Default False keeps backward compatibility with existing tools. + """ is_background_task: bool = False """ Declare this tool as a background task. Background tasks return immediately diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index de5caad554..b30601d4ab 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -266,11 +266,15 @@ def _build_handoff_toolset( # "all tools", including runtime computer-use tools. if tools is None: toolset = ToolSet() + is_admin = getattr(event, "role", "member") == "admin" for registered_tool in llm_tools.func_list: if isinstance(registered_tool, HandoffTool): continue - if registered_tool.active: - toolset.add_tool(registered_tool) + if not registered_tool.active: + continue + if not is_admin and getattr(registered_tool, "require_admin", False): + continue + toolset.add_tool(registered_tool) for runtime_tool in runtime_computer_tools.values(): toolset.add_tool(runtime_tool) return None if toolset.empty() else toolset @@ -279,10 +283,15 @@ def _build_handoff_toolset( return None toolset = ToolSet() + is_admin = getattr(event, "role", "member") == "admin" for tool_name_or_obj in tools: if isinstance(tool_name_or_obj, str): registered_tool = llm_tools.get_func(tool_name_or_obj) if registered_tool and registered_tool.active: + if not is_admin and getattr( + registered_tool, "require_admin", False + ): + continue toolset.add_tool(registered_tool) continue runtime_tool = runtime_computer_tools.get(tool_name_or_obj) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 1c4fd400a0..dd024ba6b4 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -958,6 +958,29 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None: req.func_tool = new_tool_set +def _filter_tools_by_role(event: AstrMessageEvent, req: ProviderRequest) -> None: + """从 req.func_tool 中移除当前用户无权调用的工具。 + + require_admin=True 的工具对非管理员用户不可见, + LLM 不会感知到这些工具的存在。 + """ + if not req.func_tool: + return + if getattr(event, "role", "member") == "admin": + return + new_tool_set = ToolSet() + for tool in req.func_tool.tools: + if getattr(tool, "require_admin", False): + logger.debug( + "Hiding tool '%s' from non-admin user (sender_id=%s).", + tool.name, + event.get_sender_id(), + ) + continue + new_tool_set.add_tool(tool) + req.func_tool = new_tool_set + + async def _handle_webchat( event: AstrMessageEvent, req: ProviderRequest, prov: Provider ) -> None: @@ -1446,6 +1469,7 @@ async def build_main_agent( req.session_id = event.unified_msg_origin _plugin_tool_fix(event, req) + _filter_tools_by_role(event, req) await _apply_web_search_tools(event, req, plugin_context) if config.llm_safety_mode: diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index ab6dd037f4..40722fdac9 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -516,6 +516,8 @@ async def init_mcp_clients( if raise_on_all_failed: raise MCPAllServicesFailedError(msg) logger.error(msg) + + self._restore_tool_permissions() return summary async def _start_mcp_server( @@ -678,6 +680,7 @@ async def _init_mcp_client(self, name: str, config: dict) -> MCPClient: self.func_list.append(func_tool) logger.info(f"Connected to MCP server {name}, Tools: {tool_names}") + self._restore_tool_permissions() return mcp_client async def _terminate_mcp_client(self, name: str) -> None: @@ -887,6 +890,48 @@ def activate_llm_tool(self, name: str, star_map: dict) -> bool: return True return False + def set_tool_require_admin(self, name: str, require: bool) -> bool: + """设置某工具是否仅管理员可调用,并持久化。 + + Returns: + 如果没找到工具,会返回 False + """ + func_tool = self.get_func(name) + if func_tool is None: + return False + + func_tool.require_admin = require + + require_admin_map: dict = sp.get( + "tool_require_admin_map", + {}, + scope="global", + scope_id="global", + ) + require_admin_map[name] = require + sp.put( + "tool_require_admin_map", + require_admin_map, + scope="global", + scope_id="global", + ) + return True + + def _restore_tool_permissions(self) -> None: + """从持久化存储恢复 require_admin 状态到已注册的工具。 + + 应在 MCP 客户端初始化完成后、插件工具注册完成后调用。 + """ + require_admin_map: dict = sp.get( + "tool_require_admin_map", + {}, + scope="global", + scope_id="global", + ) + for tool in self.func_list: + if tool.name in require_admin_map: + tool.require_admin = require_admin_map[tool.name] + @property def mcp_config_path(self): data_dir = get_astrbot_data_path() diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 824c3b653b..3503d05360 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -1269,6 +1269,8 @@ async def load( logger.error(traceback.format_exc()) self._rebuild_failed_plugin_info() + # 插件工具注册完成后恢复权限配置 + llm_tools._restore_tool_permissions() if has_load_error: return False, self.failed_plugin_info return True, None diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 157b4d75bf..f288d843c7 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -55,6 +55,7 @@ def __init__( "/tools/mcp/test": ("POST", self.test_mcp_connection), "/tools/list": ("GET", self.get_tool_list), "/tools/toggle-tool": ("POST", self.toggle_tool), + "/tools/set-permission": ("POST", self.set_tool_permission), "/tools/mcp/sync-provider": ("POST", self.sync_provider), } self.register_routes() @@ -498,6 +499,7 @@ async def get_tool_list(self): "description": tool.description, "parameters": tool.parameters, "active": tool.active, + "require_admin": getattr(tool, "require_admin", False), "origin": origin, "origin_name": origin_name, "readonly": readonly, @@ -551,6 +553,42 @@ async def toggle_tool(self): logger.error(traceback.format_exc()) return Response().error(f"Failed to operate tool: {e!s}").__dict__ + async def set_tool_permission(self): + """Set require_admin flag for a specified tool.""" + try: + data = await request.json + tool_name = data.get("name") + require_admin = data.get("require_admin") + + if not tool_name or require_admin is None: + return ( + Response() + .error("Missing required parameters: name or require_admin") + .__dict__ + ) + + if self.tool_mgr.is_builtin_tool(tool_name): + return ( + Response() + .error( + f"Tool {tool_name} is a builtin tool and its permission cannot be changed." + ) + .__dict__ + ) + + ok = self.tool_mgr.set_tool_require_admin(tool_name, bool(require_admin)) + if ok: + return Response().ok(None, "Permission updated.").__dict__ + return ( + Response() + .error(f"Tool {tool_name} does not exist or the operation failed.") + .__dict__ + ) + + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"Failed to set tool permission: {e!s}").__dict__ + async def sync_provider(self): """Sync MCP provider configuration.""" try: diff --git a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue index 8a42f33fef..83bf8de54c 100644 --- a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue +++ b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue @@ -12,6 +12,7 @@ const props = defineProps<{ const emit = defineEmits<{ (e: 'toggle-tool', tool: ToolItem): void; + (e: 'update-permission', tool: ToolItem, permission: 'admin' | 'everyone'): void; }>(); const toolHeaders = computed(() => [ @@ -19,6 +20,7 @@ const toolHeaders = computed(() => [ { title: tmTool('functionTools.description'), key: 'description' }, { title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' }, { title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' }, + { title: tmTool('functionTools.table.permission'), key: 'permission', sortable: false, width: '120px' }, { title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' } ]); @@ -69,6 +71,16 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => { if (tool.origin !== 'builtin') return []; return (tool.builtin_config_tags || []).filter(tag => tag.enabled); }; + +const getPermissionColor = (permission: string): string => { + return permission === 'admin' ? 'error' : 'success'; +}; + +const getPermissionLabel = (permission: string): string => { + return permission === 'admin' + ? tmTool('functionTools.permission.admin') + : tmTool('functionTools.permission.everyone'); +}; + + +