-
+
{
clearable
/>
+
+
+
+ mdi-function-variant
+ {{ tmTool('functionTools.summary.total') }}:
+ {{ toolSummary.total }}
+
+
+
+ mdi-check-circle-outline
+ {{ tmTool('functionTools.summary.active') }}:
+ {{ toolSummary.active }}
+
+
+
+ mdi-close-circle-outline
+ {{ tmTool('functionTools.summary.inactive') }}:
+ {{ toolSummary.inactive }}
+
+
+
+
+
@@ -306,3 +318,13 @@ watch(viewMode, async (mode) => {
{{ snackbar.message }}
+
+
diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts
index 4784cc8ee..cb96f33a7 100644
--- a/dashboard/src/components/extension/componentPanel/types.ts
+++ b/dashboard/src/components/extension/componentPanel/types.ts
@@ -37,6 +37,13 @@ export interface CommandSummary {
conflicts: number;
}
+/** 工具摘要统计 */
+export interface ToolSummary {
+ total: number;
+ active: number;
+ inactive: number;
+}
+
/** 过滤器状态 */
export interface FilterState {
searchQuery: string;
@@ -119,4 +126,8 @@ export interface ToolItem {
origin_name?: string;
builtin_config_statuses?: BuiltinToolConfigTag[];
builtin_config_tags?: BuiltinToolConfigTag[];
+ /** Per-tool permission level ("admin" | "member"). Builtin tools omit this. */
+ permission?: 'admin' | 'member';
+ /** True when permission was explicitly configured rather than a fallback default. */
+ permission_configured?: boolean;
}
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 858f50272..52c27bbfe 100644
--- a/dashboard/src/i18n/locales/en-US/features/tool-use.json
+++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json
@@ -39,6 +39,9 @@
"description": "Function Description",
"parameters": "Parameter List",
"noParameters": "This tool has no parameters",
+ "filter": {
+ "showBuiltin": "Show builtin tools"
+ },
"table": {
"paramName": "Parameter Name",
"type": "Type",
@@ -47,6 +50,11 @@
"origin": "Origin",
"originName": "Origin Name",
"readonly": "Read-only",
+ "permission": "Permission",
+ "permissionAdmin": "Admin",
+ "permissionMember": "All Users",
+ "permissionEveryone": "All Users",
+ "permissionBuiltin": "System Builtin",
"actions": "Actions"
},
"configTags": {
@@ -57,6 +65,11 @@
"in": "{key} matched {expected}",
"fallback": "{key} is currently {actual}"
}
+ },
+ "summary": {
+ "total": "Total",
+ "active": "Active",
+ "inactive": "Inactive"
}
},
"marketplace": {
@@ -167,6 +180,9 @@
"toggleToolSuccess": "Tool status toggled successfully!",
"toggleToolReadonly": "Builtin tools are read-only and cannot be enabled or disabled.",
"toggleToolError": "Failed to toggle tool status: {error}",
+ "updateToolPermissionSuccess": "Permission for {name} updated",
+ "updateToolPermissionFailed": "Failed to update tool permission",
+ "updateToolPermissionBuiltin": "Builtin tools do not support per-tool permission configuration.",
"testError": "Test connection failed: {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 f945d5356..a4e2a583e 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
@@ -39,6 +39,9 @@
"description": "Описание функции",
"parameters": "Параметры",
"noParameters": "У этого инструмента нет параметров",
+ "filter": {
+ "showBuiltin": "Показать встроенные"
+ },
"table": {
"paramName": "Параметр",
"type": "Тип",
@@ -47,6 +50,11 @@
"origin": "Источник",
"originName": "Имя источника",
"readonly": "Только чтение",
+ "permission": "Доступ",
+ "permissionAdmin": "Админ",
+ "permissionMember": "Все",
+ "permissionEveryone": "Все",
+ "permissionBuiltin": "Встроенный",
"actions": "Действия"
},
"configTags": {
@@ -57,6 +65,11 @@
"in": "{key} соответствует {expected}",
"fallback": "Текущее значение {key}: {actual}"
}
+ },
+ "summary": {
+ "total": "Всего",
+ "active": "Активно",
+ "inactive": "Неактивно"
}
},
"marketplace": {
@@ -167,6 +180,9 @@
"toggleToolSuccess": "Статус инструмента изменен!",
"toggleToolReadonly": "Встроенные инструменты доступны только для чтения и не могут быть включены или выключены.",
"toggleToolError": "Не удалось изменить статус: {error}",
+ "updateToolPermissionSuccess": "Доступ к {name} обновлён",
+ "updateToolPermissionFailed": "Не удалось обновить доступ к инструменту",
+ "updateToolPermissionBuiltin": "Встроенные инструменты не поддерживают настройку доступа.",
"testError": "Ошибка теста связи: {error}"
},
"syncProvider": {
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 7b717d06c..a2f140261 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
@@ -39,6 +39,9 @@
"description": "功能描述",
"parameters": "参数列表",
"noParameters": "此工具没有参数",
+ "filter": {
+ "showBuiltin": "显示内置工具"
+ },
"table": {
"paramName": "参数名",
"type": "类型",
@@ -47,6 +50,11 @@
"origin": "来源",
"originName": "来源名称",
"readonly": "只读",
+ "permission": "权限",
+ "permissionAdmin": "管理员",
+ "permissionMember": "全部用户",
+ "permissionEveryone": "全部用户",
+ "permissionBuiltin": "系统内置",
"actions": "操作"
},
"configTags": {
@@ -57,6 +65,11 @@
"in": "{key} 命中了 {expected}",
"fallback": "{key} 当前值为 {actual}"
}
+ },
+ "summary": {
+ "total": "总数",
+ "active": "已启用",
+ "inactive": "未启用"
}
},
"marketplace": {
@@ -167,6 +180,9 @@
"toggleToolSuccess": "工具状态切换成功!",
"toggleToolReadonly": "内置工具为只读,无法进行启用或停用操作。",
"toggleToolError": "工具状态切换失败: {error}",
+ "updateToolPermissionSuccess": "工具 {name} 权限已更新",
+ "updateToolPermissionFailed": "工具权限更新失败",
+ "updateToolPermissionBuiltin": "内置工具不支持单独配置权限",
"testError": "测试连接失败: {error}"
}
}
diff --git a/tests/unit/test_tool_conflict_resolution.py b/tests/unit/test_tool_conflict_resolution.py
index 7fd04d448..8146ad887 100644
--- a/tests/unit/test_tool_conflict_resolution.py
+++ b/tests/unit/test_tool_conflict_resolution.py
@@ -180,16 +180,20 @@ class TestFunctionToolManagerGetFullToolSet:
assert web_search is not None
assert web_search.active is True
- def test_no_deepcopy_preserves_identity(self):
- """Should not deep copy tools, preserving object identity."""
+ def test_wrapping_preserves_tool_name_and_description(self):
+ """_PermissionGuardedTool wrapping should preserve name and description."""
+ from astrbot.core.provider.func_tool_manager import _PermissionGuardedTool
+
manager = FunctionToolManager()
tool = make_tool("web_search")
manager.func_list = [tool]
toolset = manager.get_full_tool_set()
- # Same object reference (no deepcopy)
- assert toolset.tools[0] is tool
+ wrapped = toolset.tools[0]
+ assert wrapped.name == "web_search"
+ assert wrapped.description == "Test tool web_search"
+ assert isinstance(wrapped, _PermissionGuardedTool)
def test_mcp_tool_overrides_disabled_builtin(self):
"""
diff --git a/tests/unit/test_tool_permission.py b/tests/unit/test_tool_permission.py
new file mode 100644
index 000000000..1a3a8a376
--- /dev/null
+++ b/tests/unit/test_tool_permission.py
@@ -0,0 +1,434 @@
+"""Tests for per-tool permission management."""
+
+import asyncio
+import json
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from astrbot.core import sp
+from astrbot.core.agent.tool import FunctionTool, ToolSet
+from astrbot.core.provider.func_tool_manager import (
+ FunctionToolManager,
+ _PermissionGuardedTool,
+)
+from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
+
+
+# ── helpers ──────────────────────────────────────────────────────────
+
+
+def _make_coro(value: object):
+ """Return a fresh coroutine object that resolves to ``value``.
+
+ Used to mock Quart's ``await request.json`` where ``json`` is an
+ async property — assigning a coroutine lets ``await`` work directly."""
+
+ async def _inner():
+ return value
+
+ return _inner()
+
+
+def _make_context(role: str = "member", sender_id: str = "user_123"):
+ """Return a mock context object suitable for tool permission checks."""
+
+ class FakeEvent:
+ unified_msg_origin = "aiocqhttp:GroupMessage:g1"
+
+ def is_admin(self) -> bool:
+ return role == "admin"
+
+ def get_sender_id(self) -> str:
+ return sender_id
+
+ class FakeConfig:
+ def get_config(self, umo: str | None = None):
+ return {}
+
+ class FakeAstrContext:
+ context = FakeConfig()
+ event = FakeEvent()
+
+ class FakeWrapper:
+ context = FakeAstrContext()
+
+ return FakeWrapper()
+
+
+def _dummy_tool(name: str = "test_tool") -> FunctionTool:
+ return FunctionTool(
+ name=name,
+ description="A test tool",
+ parameters={"type": "object", "properties": {}},
+ handler=None,
+ )
+
+
+def _clear_tool_permissions() -> None:
+ sp.put("tool_permissions", {}, scope="global", scope_id="global")
+
+
+# ── _default_permission ──────────────────────────────────────────────
+
+
+def test_default_permission_is_member():
+ mgr = FunctionToolManager()
+ assert mgr._default_permission("any_mcp_tool") == "member"
+
+
+# ── _check_tool_permission ───────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_check_permission_passes_when_no_config():
+ _clear_tool_permissions()
+ mgr = FunctionToolManager()
+ context = _make_context(role="member")
+
+ error = mgr._check_tool_permission("no_such_tool", context)
+ assert error is None
+
+
+@pytest.mark.asyncio
+async def test_check_permission_passes_for_admin_with_admin_tool():
+ sp.put(
+ "tool_permissions",
+ {"_default": {"dangerous_tool": "admin"}},
+ scope="global",
+ scope_id="global",
+ )
+ try:
+ mgr = FunctionToolManager()
+ context = _make_context(role="admin", sender_id="admin_001")
+ error = mgr._check_tool_permission("dangerous_tool", context)
+ assert error is None
+ finally:
+ _clear_tool_permissions()
+
+
+@pytest.mark.asyncio
+async def test_check_permission_denies_member_for_admin_tool():
+ sp.put(
+ "tool_permissions",
+ {"_default": {"dangerous_tool": "admin"}},
+ scope="global",
+ scope_id="global",
+ )
+ try:
+ mgr = FunctionToolManager()
+ context = _make_context(role="member", sender_id="user_999")
+ error = mgr._check_tool_permission("dangerous_tool", context)
+ assert error is not None
+ assert "dangerous_tool" in str(error)
+ assert "admin" in str(error).lower()
+ assert "user_999" in str(error)
+ finally:
+ _clear_tool_permissions()
+
+
+@pytest.mark.asyncio
+async def test_check_permission_denies_when_no_event():
+ sp.put(
+ "tool_permissions",
+ {"_default": {"dangerous_tool": "admin"}},
+ scope="global",
+ scope_id="global",
+ )
+ try:
+ mgr = FunctionToolManager()
+
+ class FakeWrapper:
+ pass # no .context.event
+
+ error = mgr._check_tool_permission("dangerous_tool", FakeWrapper())
+ assert error is not None
+ assert "admin" in str(error).lower()
+ finally:
+ _clear_tool_permissions()
+
+
+@pytest.mark.asyncio
+async def test_check_permission_passes_for_member_when_configured_member():
+ sp.put(
+ "tool_permissions",
+ {"_default": {"safe_tool": "member"}},
+ scope="global",
+ scope_id="global",
+ )
+ try:
+ mgr = FunctionToolManager()
+ context = _make_context(role="member")
+ error = mgr._check_tool_permission("safe_tool", context)
+ assert error is None
+ finally:
+ _clear_tool_permissions()
+
+
+# ── _PermissionGuardedTool ───────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_guarded_tool_delegates_when_permission_passes():
+ _clear_tool_permissions()
+ mgr = FunctionToolManager()
+
+ called = False
+
+ async def handler(ctx, **kw):
+ nonlocal called
+ called = True
+ return "ok"
+
+ wrapped = FunctionTool(
+ name="delegated",
+ description="desc",
+ parameters={},
+ handler=handler,
+ )
+ guarded = _PermissionGuardedTool(wrapped, mgr)
+ context = _make_context(role="member")
+
+ result = await guarded.call(context)
+ assert called
+ assert result == "ok"
+
+
+@pytest.mark.asyncio
+async def test_guarded_tool_blocks_when_permission_denied():
+ sp.put(
+ "tool_permissions",
+ {"_default": {"blocked_tool": "admin"}},
+ scope="global",
+ scope_id="global",
+ )
+ try:
+ mgr = FunctionToolManager()
+ called = False
+
+ async def handler(ctx, **kw):
+ nonlocal called
+ called = True
+ return "should not reach"
+
+ wrapped = FunctionTool(
+ name="blocked_tool",
+ description="desc",
+ parameters={},
+ handler=handler,
+ )
+ guarded = _PermissionGuardedTool(wrapped, mgr)
+ context = _make_context(role="member")
+
+ result = await guarded.call(context)
+ assert not called
+ assert isinstance(result, str)
+ assert "Permission denied" in result
+ finally:
+ _clear_tool_permissions()
+
+
+@pytest.mark.asyncio
+async def test_guarded_tool_delegates_to_wrapped_call():
+ _clear_tool_permissions()
+ mgr = FunctionToolManager()
+
+ class CallableTool(FunctionTool):
+ async def call(self, context, **kwargs):
+ return "from call()"
+
+ wrapped = CallableTool(
+ name="has_call",
+ description="desc",
+ parameters={},
+ )
+ guarded = _PermissionGuardedTool(wrapped, mgr)
+ context = _make_context()
+
+ result = await guarded.call(context)
+ assert result == "from call()"
+
+
+@pytest.mark.asyncio
+async def test_guarded_tool_handles_async_generator_handler():
+ _clear_tool_permissions()
+ mgr = FunctionToolManager()
+
+ async def gen_handler(ctx, **kw): # type: ignore[misc]
+ yield "A"
+ yield "B"
+ yield "C"
+
+ wrapped = FunctionTool(
+ name="gen_tool",
+ description="desc",
+ parameters={},
+ handler=gen_handler,
+ )
+ guarded = _PermissionGuardedTool(wrapped, mgr)
+ context = _make_context()
+
+ result = await guarded.call(context)
+ # should return the last yielded value
+ assert result == "C"
+
+
+# ── get_full_tool_set ────────────────────────────────────────────────
+
+
+def test_get_full_tool_set_excludes_builtin_tools():
+ """Builtin tools are added separately by astr_main_agent.py, not through
+ get_full_tool_set()."""
+ mgr = FunctionToolManager()
+ tool_set = mgr.get_full_tool_set()
+
+ names = {t.name for t in tool_set.tools}
+ # Builtin tools are injected individually by the agent builder —
+ # they must NOT appear in the generic tool set.
+ assert "astrbot_execute_shell" not in names
+
+
+def test_get_full_tool_set_wraps_non_builtin():
+ mgr = FunctionToolManager()
+ _clear_tool_permissions()
+
+ mgr.func_list.append(_dummy_tool("my_plugin_tool"))
+ tool_set = mgr.get_full_tool_set()
+
+ plugin_tools = [t for t in tool_set.tools if t.name == "my_plugin_tool"]
+ assert plugin_tools
+ assert isinstance(
+ plugin_tools[0], _PermissionGuardedTool
+ ), "non-builtin tools must be wrapped"
+
+
+# ── API: get_tool_list permission fields ──────────────────────────────
+
+
+class TestGetToolListPermission:
+ @pytest.mark.asyncio
+ async def test_list_includes_permission_fields_for_non_builtin(self):
+ from astrbot.dashboard.routes.tools import ToolsRoute
+
+ # Minimal stubs to avoid full core lifecycle init
+ route = ToolsRoute.__new__(ToolsRoute)
+ route.core_lifecycle = MagicMock()
+ route.core_lifecycle.astrbot_config_mgr = MagicMock()
+ route.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = []
+ route.core_lifecycle.astrbot_config_mgr.confs = {}
+ route.tool_mgr = FunctionToolManager()
+
+ sp.put(
+ "tool_permissions",
+ {"_default": {"my_plugin_tool": "admin"}},
+ scope="global",
+ scope_id="global",
+ )
+ try:
+ route.tool_mgr.func_list.append(_dummy_tool("my_plugin_tool"))
+ resp = await route.get_tool_list()
+ data = json.loads(json.dumps(resp)) # simulate json serialisation
+ tools = data["data"]
+
+ target = next(t for t in tools if t["name"] == "my_plugin_tool")
+ assert target["permission"] == "admin"
+ assert target["permission_configured"] is True
+ assert target["readonly"] is False
+ finally:
+ _clear_tool_permissions()
+
+ @pytest.mark.asyncio
+ async def test_list_no_permission_fields_for_builtin(self):
+ from astrbot.dashboard.routes.tools import ToolsRoute
+
+ route = ToolsRoute.__new__(ToolsRoute)
+ route.core_lifecycle = MagicMock()
+ route.core_lifecycle.astrbot_config_mgr = MagicMock()
+ route.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = []
+ route.core_lifecycle.astrbot_config_mgr.confs = {}
+ route.tool_mgr = FunctionToolManager()
+
+ resp = await route.get_tool_list()
+ data = json.loads(json.dumps(resp))
+ tools = data["data"]
+
+ target = next(t for t in tools if t["name"] == "astrbot_execute_shell")
+ assert "permission" not in target
+ assert "permission_configured" not in target
+ assert target["readonly"] is True
+
+
+# ── API: update_tool_permission ──────────────────────────────────────
+
+
+class TestUpdateToolPermission:
+ @pytest.mark.asyncio
+ async def test_set_admin_permission(self):
+ from astrbot.dashboard.routes.tools import ToolsRoute
+
+ route = ToolsRoute.__new__(ToolsRoute)
+ route.core_lifecycle = MagicMock()
+ route.tool_mgr = FunctionToolManager()
+ route.tool_mgr.func_list.append(_dummy_tool("target_tool"))
+ _clear_tool_permissions()
+
+ mock_req = MagicMock()
+ mock_req.json = _make_coro({"name": "target_tool", "permission": "admin"})
+ with patch("astrbot.dashboard.routes.tools.request", mock_req):
+ resp = await route.update_tool_permission()
+ data = json.loads(json.dumps(resp))
+ assert data["status"] == "ok"
+
+ stored = sp.get(
+ "tool_permissions", {}, scope="global", scope_id="global"
+ )
+ assert stored["_default"]["target_tool"] == "admin"
+
+ @pytest.mark.asyncio
+ async def test_reject_builtin_tool(self):
+ from astrbot.dashboard.routes.tools import ToolsRoute
+
+ route = ToolsRoute.__new__(ToolsRoute)
+ route.core_lifecycle = MagicMock()
+ route.tool_mgr = FunctionToolManager()
+
+ mock_req = MagicMock()
+ mock_req.json = _make_coro({"name": "astrbot_execute_shell", "permission": "admin"})
+ with patch("astrbot.dashboard.routes.tools.request", mock_req):
+ resp = await route.update_tool_permission()
+ data = json.loads(json.dumps(resp))
+ assert data["status"] == "error"
+ assert "builtin" in str(data["message"]).lower()
+
+ @pytest.mark.asyncio
+ async def test_reject_unknown_tool(self):
+ from astrbot.dashboard.routes.tools import ToolsRoute
+
+ route = ToolsRoute.__new__(ToolsRoute)
+ route.core_lifecycle = MagicMock()
+ route.tool_mgr = FunctionToolManager()
+
+ mock_req = MagicMock()
+ mock_req.json = _make_coro({"name": "ghost_tool", "permission": "admin"})
+ with patch("astrbot.dashboard.routes.tools.request", mock_req):
+ resp = await route.update_tool_permission()
+ data = json.loads(json.dumps(resp))
+ assert data["status"] == "error"
+ assert "not found" in str(data["message"]).lower()
+
+ @pytest.mark.asyncio
+ async def test_reject_invalid_permission_value(self):
+ from astrbot.dashboard.routes.tools import ToolsRoute
+
+ route = ToolsRoute.__new__(ToolsRoute)
+ route.core_lifecycle = MagicMock()
+ route.tool_mgr = FunctionToolManager()
+ route.tool_mgr.func_list.append(_dummy_tool("target_tool"))
+
+ mock_req = MagicMock()
+ mock_req.json = _make_coro({"name": "target_tool", "permission": "everyone"})
+ with patch("astrbot.dashboard.routes.tools.request", mock_req):
+ resp = await route.update_tool_permission()
+ data = json.loads(json.dumps(resp))
+ assert data["status"] == "error"