Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
lingyun14beta marked this conversation as resolved.

valid_params = {} # 参数过滤:只传递函数实际需要的参数

# 获取实际的 handler 函数
Expand Down
5 changes: 5 additions & 0 deletions astrbot/core/agent/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment thread
lingyun14beta marked this conversation as resolved.
toolset.add_tool(registered_tool)
continue
runtime_tool = runtime_computer_tools.get(tool_name_or_obj)
Expand Down
24 changes: 24 additions & 0 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +973 to +979
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If event is None (e.g., in some unit tests or system-initiated runs), calling event.get_sender_id() will raise an AttributeError. We should use defensive programming to handle cases where event might be None.

Suggested change
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
if getattr(tool, "require_admin", False):
sender_id = event.get_sender_id() if event else "unknown"
logger.debug(
"Hiding tool '%s' from non-admin user (sender_id=%s).",
tool.name,
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:
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions astrbot/core/provider/func_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ async def init_mcp_clients(
if raise_on_all_failed:
raise MCPAllServicesFailedError(msg)
logger.error(msg)

self._restore_tool_permissions()
Comment thread
lingyun14beta marked this conversation as resolved.
return summary

async def _start_mcp_server(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new tool permission functionality (setting, restoring, and checking require_admin permissions) should be accompanied by corresponding unit tests to ensure correctness and prevent regressions.

References
  1. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.

"""设置某工具是否仅管理员可调用,并持久化。

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
Comment thread
lingyun14beta marked this conversation as resolved.

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()
Expand Down
2 changes: 2 additions & 0 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions astrbot/dashboard/routes/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Avoid using bool() coercion on require_admin to prevent accepting truthy strings

bool(require_admin) will treat any non-empty string (including 'false' or '0') as True, which can incorrectly grant or revoke admin permissions if a client sends a string. Instead, validate the type (e.g., isinstance(require_admin, bool)) or explicitly parse allowed values and reject anything else, rather than coercing.

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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ const props = defineProps<{

const emit = defineEmits<{
(e: 'toggle-tool', tool: ToolItem): void;
(e: 'update-permission', tool: ToolItem, permission: 'admin' | 'everyone'): void;
}>();

const toolHeaders = computed(() => [
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '320px' },
{ 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' }
]);

Expand Down Expand Up @@ -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');
};
</script>

<template>
Expand Down Expand Up @@ -138,6 +150,41 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
</div>
</template>

<!-- 权限列 -->
<template #item.permission="{ item }">
<span v-if="item.readonly" class="text-medium-emphasis">-</span>
<v-menu v-else location="bottom">
<template #activator="{ props: menuProps }">
<v-chip
v-bind="menuProps"
:color="getPermissionColor(item.require_admin ? 'admin' : 'everyone')"
size="small"
class="font-weight-medium cursor-pointer"
link
>
{{ getPermissionLabel(item.require_admin ? 'admin' : 'everyone') }}
<v-icon end size="14">mdi-chevron-down</v-icon>
</v-chip>
</template>
<v-list density="compact">
<v-list-item
value="everyone"
:active="!item.require_admin"
@click="emit('update-permission', item, 'everyone')"
>
<v-list-item-title>{{ tmTool('functionTools.permission.everyone') }}</v-list-item-title>
</v-list-item>
<v-list-item
value="admin"
:active="item.require_admin"
@click="emit('update-permission', item, 'admin')"
>
<v-list-item-title>{{ tmTool('functionTools.permission.admin') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>

<template #item.actions="{ item }">
<span v-if="item.readonly" class="text-medium-emphasis">-</span>
<v-switch
Expand Down Expand Up @@ -225,4 +272,8 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
.tool-config-tooltip :deep(.font-weight-medium) {
color: inherit !important;
}

.cursor-pointer {
cursor: pointer;
}
</style>
21 changes: 21 additions & 0 deletions dashboard/src/components/extension/componentPanel/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -274,6 +294,7 @@ watch(viewMode, async (mode) => {
:items="filteredTools"
:loading="toolsLoading"
@toggle-tool="handleToggleTool"
@update-permission="handleUpdateToolPermission"
/>
</div>
</v-card-text>
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/components/extension/componentPanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export interface ToolItem {
description: string;
active: boolean;
readonly?: boolean;
require_admin?: boolean;
parameters?: {
properties?: Record<string, ToolParameter>;
};
Expand Down
Loading
Loading