Skip to content

Commit ae44b91

Browse files
authored
feat(dashboard): add per-tool permission management for function tools (#8693)
* feat(func-tool): add per-tool permission management * feat(dashboard): add permission column to function tool table * refactor(dashboard): encapsulate tool actions into composable and unify permission UI * style: format code * fix: guard sync handler, simplify sp access, skip builtin instantiation
1 parent 0fb3f5e commit ae44b91

11 files changed

Lines changed: 936 additions & 46 deletions

File tree

astrbot/core/provider/func_tool_manager.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,65 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
210210
return False, f"{e!s}"
211211

212212

213+
class _PermissionGuardedTool(FunctionTool):
214+
"""Transparent proxy that checks per-tool permissions before delegating.
215+
216+
Only wraps non-builtin tools. Builtin tools are added to the tool set
217+
without wrapping, so their existing hardcoded permission logic
218+
(``check_admin_permission`` / ``_is_restricted_env``) is unaffected.
219+
220+
The ``handler`` field is intentionally kept ``None`` so that
221+
``FunctionToolExecutor._execute_local`` falls through to the
222+
``is_override_call`` branch and invokes our ``call()`` instead of
223+
calling the raw handler directly. This ensures the permission
224+
check runs for *every* invocation path.
225+
"""
226+
227+
def __init__(
228+
self,
229+
tool: FunctionTool,
230+
manager: FunctionToolManager,
231+
) -> None:
232+
# Do NOT pass handler to the parent — keep self.handler = None
233+
# so the tool executor always routes through our call().
234+
super().__init__(
235+
name=tool.name,
236+
description=tool.description,
237+
parameters=getattr(tool, "parameters", {}),
238+
)
239+
self._wrapped = tool
240+
self._mgr = manager
241+
# Mirror mutable state from the underlying tool
242+
self.active = getattr(tool, "active", True)
243+
self.handler_module_path = getattr(tool, "handler_module_path", None)
244+
245+
async def call(self, context: Any, **kwargs: Any) -> Any:
246+
import inspect as _inspect
247+
248+
error = self._mgr._check_tool_permission(self.name, context)
249+
if error is not None:
250+
return error
251+
252+
# Delegate to handler first (plugin tools).
253+
if self._wrapped.handler is not None:
254+
result = self._wrapped.handler(context, **kwargs)
255+
if _inspect.isasyncgen(result):
256+
last: Any = None
257+
async for item in result:
258+
last = item
259+
return last
260+
if _inspect.isawaitable(result):
261+
return await result
262+
return result
263+
264+
# Fall back to overridden call() on subclasses (e.g. MCPTool).
265+
call_override = getattr(type(self._wrapped), "call", None)
266+
if call_override is not None and call_override is not FunctionTool.call:
267+
return await self._wrapped.call(context, **kwargs)
268+
269+
return "error: tool has no callable handler"
270+
271+
213272
class FunctionToolManager:
214273
def __init__(self) -> None:
215274
self.func_list: list[FuncTool] = []
@@ -374,6 +433,51 @@ def is_builtin_tool(self, name: str) -> bool:
374433
ensure_builtin_tools_loaded()
375434
return get_builtin_tool_class(name) is not None
376435

436+
def _default_permission(self, tool_name: str) -> str:
437+
"""Compute the fallback permission for a non-builtin tool.
438+
439+
All non-builtin tools default to ``"member"`` (no restriction).
440+
Builtin tools are never routed through this method."""
441+
return "member"
442+
443+
def _check_tool_permission(
444+
self,
445+
tool_name: str,
446+
context: Any,
447+
) -> str | None:
448+
"""Return an error string if the caller lacks permission, or None.
449+
450+
Only non-builtin tools are guarded. Permission is resolved from
451+
``tool_permissions`` in SharedPreferences (``_default`` key). When
452+
no explicit entry exists the tool inherits the fallback
453+
``_default_permission``."""
454+
try:
455+
perms_raw = sp.get(
456+
"tool_permissions", {}, scope="global", scope_id="global"
457+
)
458+
except Exception:
459+
perms_raw = {}
460+
defaults = perms_raw.get("_default", {}) if isinstance(perms_raw, dict) else {}
461+
effective = defaults.get(tool_name)
462+
if effective is None:
463+
effective = self._default_permission(tool_name)
464+
465+
if effective != "admin":
466+
return None # member or unknown → pass
467+
468+
try:
469+
event = context.context.event
470+
except AttributeError:
471+
event = None
472+
if event is None or not event.is_admin():
473+
sender_id = getattr(event, "get_sender_id", lambda: "unknown")()
474+
return (
475+
f"error: Permission denied. The tool '{tool_name}' requires admin "
476+
f"privileges. Your ID: {sender_id}. "
477+
"Ask admin to configure in WebUI → Extension → Components."
478+
)
479+
return None
480+
377481
def get_full_tool_set(self) -> ToolSet:
378482
"""获取完整工具集
379483
@@ -383,10 +487,14 @@ def get_full_tool_set(self) -> ToolSet:
383487
384488
因此,后加载的 inactive 工具不会覆盖已激活的工具;
385489
同时,MCP 工具在需要时仍可覆盖被禁用的内置工具。
490+
491+
Non-builtin tools are wrapped with ``_PermissionGuardedTool`` so that
492+
every invocation checks the per-tool permission configured via the
493+
dashboard.
386494
"""
387495
tool_set = ToolSet()
388496
for tool in self.func_list:
389-
tool_set.add_tool(tool)
497+
tool_set.add_tool(_PermissionGuardedTool(tool, self))
390498
return tool_set
391499

392500
@staticmethod

astrbot/dashboard/routes/tools.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from quart import request
44

5-
from astrbot.core import logger
5+
from astrbot.core import logger, sp
66
from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config
77
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
88
from astrbot.core.star import star_map
@@ -55,11 +55,27 @@ def __init__(
5555
"/tools/mcp/test": ("POST", self.test_mcp_connection),
5656
"/tools/list": ("GET", self.get_tool_list),
5757
"/tools/toggle-tool": ("POST", self.toggle_tool),
58+
"/tools/permission": ("POST", self.update_tool_permission),
5859
"/tools/mcp/sync-provider": ("POST", self.sync_provider),
5960
}
6061
self.register_routes()
6162
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
6263

64+
@staticmethod
65+
def _get_tool_permission(tool_name: str) -> tuple[str, bool]:
66+
"""Return (effective_permission, configured) for a tool.
67+
68+
``configured`` is True when the permission was explicitly set via the
69+
dashboard rather than being a fallback default.
70+
"""
71+
perms_store = sp.get("tool_permissions", {}, scope="global", scope_id="global")
72+
defaults = (
73+
perms_store.get("_default", {}) if isinstance(perms_store, dict) else {}
74+
)
75+
if tool_name in defaults:
76+
return defaults[tool_name], True
77+
return "member", False
78+
6379
def _rollback_mcp_server(self, name: str) -> bool:
6480
try:
6581
rollback_config = self.tool_mgr.load_mcp_config()
@@ -504,6 +520,10 @@ async def get_tool_list(self):
504520
"builtin_config_statuses": builtin_config_statuses,
505521
"builtin_config_tags": builtin_config_tags,
506522
}
523+
if not readonly:
524+
perm, configured = self._get_tool_permission(tool.name)
525+
tool_info["permission"] = perm
526+
tool_info["permission_configured"] = configured
507527
tools_dict.append(tool_info)
508528
return Response().ok(data=tools_dict).__dict__
509529
except Exception as e:
@@ -569,3 +589,56 @@ async def sync_provider(self):
569589
except Exception as e:
570590
logger.error(traceback.format_exc())
571591
return Response().error(f"Sync failed: {e!s}").__dict__
592+
593+
async def update_tool_permission(self):
594+
"""Set or remove the permission level of a registered tool."""
595+
try:
596+
data = await request.json
597+
tool_name = data.get("name")
598+
permission = data.get("permission") # "admin" | "member"
599+
600+
if not tool_name or permission not in ("admin", "member"):
601+
return (
602+
Response()
603+
.error("name and permission (admin or member) are required")
604+
.__dict__
605+
)
606+
607+
if self.tool_mgr.is_builtin_tool(tool_name):
608+
return (
609+
Response()
610+
.error(
611+
"Builtin tools do not support per-tool permission configuration."
612+
)
613+
.__dict__
614+
)
615+
616+
# Verify the tool is known
617+
if not any(t.name == tool_name for t in self.tool_mgr.func_list):
618+
return Response().error(f"Tool '{tool_name}' not found").__dict__
619+
620+
perms_store = sp.get(
621+
"tool_permissions", {}, scope="global", scope_id="global"
622+
)
623+
if not isinstance(perms_store, dict):
624+
perms_store = {}
625+
defaults = perms_store.get("_default", {})
626+
if not isinstance(defaults, dict):
627+
defaults = {}
628+
defaults[tool_name] = permission
629+
perms_store["_default"] = defaults
630+
sp.put(
631+
"tool_permissions",
632+
perms_store,
633+
scope="global",
634+
scope_id="global",
635+
)
636+
637+
return (
638+
Response()
639+
.ok(None, f"Tool '{tool_name}' permission set to {permission}")
640+
.__dict__
641+
)
642+
except Exception as e:
643+
logger.error(traceback.format_exc())
644+
return Response().error(f"Failed to update tool permission: {e!s}").__dict__

dashboard/src/components/extension/componentPanel/components/ToolTable.vue

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ const props = defineProps<{
1212
1313
const emit = defineEmits<{
1414
(e: 'toggle-tool', tool: ToolItem): void;
15+
(e: 'update-permission', tool: ToolItem, permission: 'admin' | 'member'): void;
1516
}>();
1617
1718
const toolHeaders = computed(() => [
1819
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '320px' },
1920
{ title: tmTool('functionTools.description'), key: 'description' },
20-
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' },
21-
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' },
22-
{ title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' }
21+
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '100px' },
22+
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '140px' },
23+
{ title: tmTool('functionTools.table.permission'), key: 'permission', sortable: false, width: '110px' },
24+
{ title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '100px' }
2325
]);
2426
2527
const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {});
@@ -69,6 +71,24 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
6971
if (tool.origin !== 'builtin') return [];
7072
return (tool.builtin_config_tags || []).filter(tag => tag.enabled);
7173
};
74+
75+
const getPermissionColor = (permission?: string): string => {
76+
switch (permission) {
77+
case 'admin':
78+
return 'error';
79+
default:
80+
return 'success';
81+
}
82+
};
83+
84+
const getPermissionLabel = (permission?: string): string => {
85+
switch (permission) {
86+
case 'admin':
87+
return tmTool('functionTools.table.permissionAdmin');
88+
default:
89+
return tmTool('functionTools.table.permissionEveryone');
90+
}
91+
};
7292
</script>
7393

7494
<template>
@@ -133,11 +153,54 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
133153
</template>
134154

135155
<template #item.origin_name="{ item }">
136-
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="item.origin_name">
156+
<div class="text-body-2 text-medium-emphasis" style="max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="item.origin_name">
137157
{{ item.origin_name || '-' }}
138158
</div>
139159
</template>
140160

161+
<template #item.permission="{ item }">
162+
<!-- Builtin tools: non-clickable badge -->
163+
<v-chip
164+
v-if="item.origin === 'builtin'"
165+
size="small"
166+
variant="tonal"
167+
class="font-weight-medium text-medium-emphasis"
168+
>
169+
{{ tmTool('functionTools.table.permissionBuiltin') }}
170+
</v-chip>
171+
<!-- Other tools: clickable dropdown -->
172+
<v-menu v-else location="bottom">
173+
<template v-slot:activator="{ props: menuProps }">
174+
<v-chip
175+
v-bind="menuProps"
176+
:color="getPermissionColor(item.permission)"
177+
size="small"
178+
class="font-weight-medium cursor-pointer"
179+
link
180+
>
181+
{{ getPermissionLabel(item.permission) }}
182+
<v-icon end size="14">mdi-chevron-down</v-icon>
183+
</v-chip>
184+
</template>
185+
<v-list density="compact">
186+
<v-list-item
187+
:value="'member'"
188+
@click="emit('update-permission', item, 'member')"
189+
:active="item.permission !== 'admin'"
190+
>
191+
<v-list-item-title>{{ tmTool('functionTools.table.permissionEveryone') }}</v-list-item-title>
192+
</v-list-item>
193+
<v-list-item
194+
:value="'admin'"
195+
@click="emit('update-permission', item, 'admin')"
196+
:active="item.permission === 'admin'"
197+
>
198+
<v-list-item-title>{{ tmTool('functionTools.table.permissionAdmin') }}</v-list-item-title>
199+
</v-list-item>
200+
</v-list>
201+
</v-menu>
202+
</template>
203+
141204
<template #item.actions="{ item }">
142205
<span v-if="item.readonly" class="text-medium-emphasis">-</span>
143206
<v-switch

0 commit comments

Comments
 (0)