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');
+};
@@ -138,6 +150,41 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
+
+
+ -
+
+
+
+ {{ getPermissionLabel(item.require_admin ? 'admin' : 'everyone') }}
+ mdi-chevron-down
+
+
+
+
+ {{ tmTool('functionTools.permission.everyone') }}
+
+
+ {{ tmTool('functionTools.permission.admin') }}
+
+
+
+
+
-
{
.tool-config-tooltip :deep(.font-weight-medium) {
color: inherit !important;
}
+
+.cursor-pointer {
+ cursor: pointer;
+}
diff --git a/dashboard/src/components/extension/componentPanel/index.vue b/dashboard/src/components/extension/componentPanel/index.vue
index 5e72751401..98345aec05 100644
--- a/dashboard/src/components/extension/componentPanel/index.vue
+++ b/dashboard/src/components/extension/componentPanel/index.vue
@@ -125,6 +125,26 @@ const handleToggleTool = async (tool: ToolItem) => {
}
};
+const handleUpdateToolPermission = async (tool: ToolItem, permission: 'admin' | 'everyone') => {
+ const previous = tool.require_admin;
+ tool.require_admin = permission === 'admin';
+ try {
+ const res = await axios.post('/api/tools/set-permission', {
+ name: tool.name,
+ require_admin: tool.require_admin
+ });
+ if (res.data.status === 'ok') {
+ toast(tmTool('messages.updatePermissionSuccess'));
+ } else {
+ tool.require_admin = previous;
+ toast(res.data.message || tmTool('messages.updatePermissionError', { error: '' }), 'error');
+ }
+ } catch (error: any) {
+ tool.require_admin = previous;
+ toast(error?.response?.data?.message || error?.message || tmTool('messages.updatePermissionError', { error: '' }), 'error');
+ }
+};
+
// 处理确认重命名
const handleConfirmRename = async () => {
await confirmRename(tm('messages.renameSuccess'), tm('messages.renameFailed'));
@@ -274,6 +294,7 @@ watch(viewMode, async (mode) => {
:items="filteredTools"
:loading="toolsLoading"
@toggle-tool="handleToggleTool"
+ @update-permission="handleUpdateToolPermission"
/>
diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts
index 4784cc8ee4..af05135210 100644
--- a/dashboard/src/components/extension/componentPanel/types.ts
+++ b/dashboard/src/components/extension/componentPanel/types.ts
@@ -112,6 +112,7 @@ export interface ToolItem {
description: string;
active: boolean;
readonly?: boolean;
+ require_admin?: boolean;
parameters?: {
properties?: Record;
};
diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json
index 858f502722..9a0adcd84b 100644
--- a/dashboard/src/i18n/locales/en-US/features/tool-use.json
+++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json
@@ -47,7 +47,8 @@
"origin": "Origin",
"originName": "Origin Name",
"readonly": "Read-only",
- "actions": "Actions"
+ "actions": "Actions",
+ "permission": "Permission"
},
"configTags": {
"tooltipTitle": "This tool is enabled in config file {config} because:",
@@ -57,6 +58,10 @@
"in": "{key} matched {expected}",
"fallback": "{key} is currently {actual}"
}
+ },
+ "permission": {
+ "everyone": "Everyone",
+ "admin": "Admin only"
}
},
"marketplace": {
@@ -167,6 +172,8 @@
"toggleToolSuccess": "Tool status toggled successfully!",
"toggleToolReadonly": "Builtin tools are read-only and cannot be enabled or disabled.",
"toggleToolError": "Failed to toggle tool status: {error}",
- "testError": "Test connection failed: {error}"
+ "testError": "Test connection failed: {error}",
+ "updatePermissionSuccess": "Permission updated successfully!",
+ "updatePermissionError": "Failed to update permission: {error}"
}
}
diff --git a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
index f945d53562..d7ba10ecf6 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
@@ -1,4 +1,4 @@
-{
+{
"title": "Инструменты и функции",
"subtitle": "Управление MCP-серверами и доступными функциями",
"tooltip": {
@@ -47,7 +47,8 @@
"origin": "Источник",
"originName": "Имя источника",
"readonly": "Только чтение",
- "actions": "Действия"
+ "actions": "Действия",
+ "permission": "Права доступа"
},
"configTags": {
"tooltipTitle": "Этот инструмент включен в файле конфигурации {config}, потому что:",
@@ -57,6 +58,10 @@
"in": "{key} соответствует {expected}",
"fallback": "Текущее значение {key}: {actual}"
}
+ },
+ "permission": {
+ "everyone": "Все",
+ "admin": "Только администратор"
}
},
"marketplace": {
@@ -167,7 +172,9 @@
"toggleToolSuccess": "Статус инструмента изменен!",
"toggleToolReadonly": "Встроенные инструменты доступны только для чтения и не могут быть включены или выключены.",
"toggleToolError": "Не удалось изменить статус: {error}",
- "testError": "Ошибка теста связи: {error}"
+ "testError": "Ошибка теста связи: {error}",
+ "updatePermissionSuccess": "Права доступа обновлены!",
+ "updatePermissionError": "Ошибка обновления прав: {error}"
},
"syncProvider": {
"title": "Синхронизация серверов MCP",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
index 7b717d06c7..83cba83356 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
@@ -47,7 +47,8 @@
"origin": "来源",
"originName": "来源名称",
"readonly": "只读",
- "actions": "操作"
+ "actions": "操作",
+ "permission": "权限"
},
"configTags": {
"tooltipTitle": "该工具在配置文件 {config} 中启用,因为:",
@@ -57,6 +58,10 @@
"in": "{key} 命中了 {expected}",
"fallback": "{key} 当前值为 {actual}"
}
+ },
+ "permission": {
+ "everyone": "所有人",
+ "admin": "管理员"
}
},
"marketplace": {
@@ -167,6 +172,8 @@
"toggleToolSuccess": "工具状态切换成功!",
"toggleToolReadonly": "内置工具为只读,无法进行启用或停用操作。",
"toggleToolError": "工具状态切换失败: {error}",
- "testError": "测试连接失败: {error}"
+ "testError": "测试连接失败: {error}",
+ "updatePermissionSuccess": "权限更新成功!",
+ "updatePermissionError": "权限更新失败: {error}"
}
}