diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index 4cee6ba6d1..0a8e932680 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -63,6 +63,19 @@ class FunctionTool(ToolSchema, Generic[TContext]): Declare this tool as a background task. Background tasks return immediately with a task identifier while the real work continues asynchronously. """ + declared_permission_type: str | None = None + """ + The permission level declared by the tool author via ``@llm_tool(permission_type=...)``. + One of ``"admin"`` / ``"member"`` / ``None``. + + This is only a *default*: it is used when the dashboard has no explicit + per-tool permission override configured for this tool. It lets plugin + authors ship a sane default permission requirement (e.g. ADMIN for a + dangerous tool) without requiring the bot owner to visit the WebUI panel. + An explicit override saved via the dashboard always takes precedence. + This field is a special field for AstrBot; you can ignore it when + integrating with other frameworks. + """ def __repr__(self) -> str: return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})" diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 8a2565c41d..0727ff67ed 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -344,6 +344,7 @@ def spec_to_func( func_args: list[dict], desc: str, handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]], + declared_permission_type: str | None = None, ) -> FuncTool: params = { "type": "object", # hard-coded here @@ -358,6 +359,7 @@ def spec_to_func( parameters=params, description=desc, handler=handler, + declared_permission_type=declared_permission_type, ) def add_func( @@ -366,6 +368,7 @@ def add_func( func_args: list, desc: str, handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]], + declared_permission_type: str | None = None, ) -> None: """添加函数调用工具 @@ -373,6 +376,9 @@ def add_func( @param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...] @param desc: 函数描述 @param func_obj: 处理函数 + @param declared_permission_type: 插件作者通过 ``@llm_tool(permission_type=...)`` + 声明的默认权限(``"admin"`` / ``"member"`` / ``None``)。仅在该工具尚未 + 在 WebUI 面板中被显式配置过权限时生效,作为默认值使用。 """ # check if the tool has been added before self.remove_func(name) @@ -383,6 +389,7 @@ def add_func( func_args=func_args, desc=desc, handler=handler, + declared_permission_type=declared_permission_type, ), ) logger.info(f"Added llm tool: {name}") @@ -396,14 +403,9 @@ def remove_func(self, name: str) -> None: def get_func(self, name) -> FuncTool | None: # 优先返回已激活的工具(后加载的覆盖前面的,与 ToolSet.add_tool 保持一致) - # 使用 getattr(..., True) 与 ToolSet.add_tool 保持一致:没有 active 属性的工具视为已激活 - for f in reversed(self.func_list): - if f.name == name and getattr(f, "active", True): - return f - # 退化则拿最后一个同名工具 - for f in reversed(self.func_list): - if f.name == name: - return f + tool = self._lookup_in_func_list(name) + if tool is not None: + return tool if isinstance(name, str): try: builtin_tool = self.get_builtin_tool(name) @@ -414,6 +416,25 @@ def get_func(self, name) -> FuncTool | None: return builtin_tool return None + def _lookup_in_func_list(self, name) -> FuncTool | None: + """Find a tool in ``func_list`` by name, preferring the active copy. + + Shared by :meth:`get_func` and :meth:`_default_permission` so the + precedence rule (active copy wins; otherwise fall back to the + most-recently-added inactive copy) only lives in one place. Never + falls through to builtin tools -- callers that need that do it + themselves, since loading builtins is a side effect that not every + caller wants (notably ``_default_permission``, which must never + trigger builtin-tool loading).""" + fallback = None + for f in reversed(self.func_list): + if f.name == name: + if getattr(f, "active", True): + return f + if fallback is None: + fallback = f + return fallback + def get_builtin_tool(self, tool: str | type[FuncTool]) -> FuncTool: ensure_builtin_tools_loaded() @@ -451,8 +472,20 @@ def is_builtin_tool(self, name: str) -> bool: def _default_permission(self, tool_name: str) -> str: """Compute the fallback permission for a non-builtin tool. - All non-builtin tools default to ``"member"`` (no restriction). - Builtin tools are never routed through this method.""" + If the tool's author declared a default via + ``@llm_tool(permission_type=...)``, that value is used. Otherwise + non-builtin tools default to ``"member"`` (no restriction). + Builtin tools are never routed through this method. + + Uses ``getattr`` rather than direct attribute access: third-party + tools registered via ``add_llm_tools()`` are only type-hinted as + ``FunctionTool`` but not enforced at runtime, so an older or + custom tool object that doesn't inherit ``FunctionTool`` may not + carry this attribute at all.""" + tool = self._lookup_in_func_list(tool_name) + declared = getattr(tool, "declared_permission_type", None) + if declared in ("admin", "member"): + return declared return "member" def _check_tool_permission( diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 2e50237d58..74c794ffaf 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -577,7 +577,59 @@ def decorator(awaitable): return decorator -def register_llm_tool(name: str | None = None, **kwargs): +def _normalize_permission_declaration( + name: object, + permission_type: PermissionType | None, + registering_agent: object, +) -> str | None: + """Validate the ``name`` / ``permission_type`` combination passed to + ``register_llm_tool`` and map ``permission_type`` to the raw string + stored on ``FunctionTool.declared_permission_type``. + + Returns ``None`` when no permission was declared. Raises ``ValueError`` + on any of the three misuse patterns this feature has to guard against: + forgetting ``name=``/``permission_type=`` and passing a ``PermissionType`` + member positionally (it would otherwise silently bind to ``name``), + passing something that isn't a ``PermissionType`` member, or declaring a + permission on a tool registered via ``Agent.llm_tool`` (which never goes + through the panel-configurable permission system).""" + if isinstance(name, PermissionType): + raise ValueError( + "看起来你把 PermissionType 作为第一个位置参数传给了 name(很可能是忘了写" + "name= 或 permission_type=)。正确写法:" + '@llm_tool(name="xxx", permission_type=filter.PermissionType.ADMIN)。' + "如果不需要自定义工具名,直接用 @llm_tool(permission_type=...) 即可" + "(permission_type 现在是仅限关键字参数)。", + ) + if permission_type is not None and not isinstance(permission_type, PermissionType): + raise ValueError( + "permission_type 必须为 astrbot.api.event.filter.PermissionType 的成员(ADMIN / MEMBER)。", + ) + if registering_agent is not None and permission_type is not None: + raise ValueError( + "通过 Agent.llm_tool 注册的工具不支持 permission_type 声明,因为它们不经过" + "面板可配置的工具管理系统(不会被写入 func_list,也不受" + "_default_permission / 面板权限覆盖的约束)。请改用" + "@filter.llm_tool(不经过 Agent)来声明默认权限,或者在工具内部自行" + "实现权限校验。", + ) + if permission_type is None: + return None + if permission_type == PermissionType.ADMIN: + return "admin" + # PermissionType.MEMBER (or any non-ADMIN flag) means "no restriction", + # which is already the implicit default, but we still record it + # explicitly so a dashboard can distinguish "declared member" from + # "never declared anything". + return "member" + + +def register_llm_tool( + name: str | None = None, + *, + permission_type: PermissionType | None = None, + **kwargs, +): """为函数调用(function-calling / tools-use)添加工具。 请务必按照以下格式编写一个工具(包括函数注释,AstrBot 会尝试解析该函数注释) @@ -609,11 +661,42 @@ async def get_weather(event: AstrMessageEvent, location: str): yield ``` + 权限声明 / Permission declaration: + 可以通过 ``permission_type`` 参数为该工具声明一个默认权限要求,效果类似于 + ``@filter.command()`` 配合 ``@filter.permission_type()`` 的用法。这样即便 + 机器人主人从未在 WebUI 面板里手动配置过该工具的权限,工具也会默认拥有这层 + 防护: + + ``` + @llm_tool(name="restart_server", permission_type=filter.PermissionType.ADMIN) + async def restart_server(event: AstrMessageEvent): + \'\'\'重启服务器。\'\'\' + # 处理逻辑 + ``` + + - 该参数只是一个**默认值**:如果机器人主人之后在 WebUI 面板里手动为该工具 + 设置了权限,面板的设置会覆盖这里声明的默认值。 + - 不传或传 ``None`` 时行为与之前完全一致(默认所有人可用)。 + - ``permission_type`` 是仅限关键字参数,必须写成 + ``permission_type=filter.PermissionType.ADMIN``,不能位置传参。如果你 + 忘了写 ``name=`` 或 ``permission_type=``、直接把 ``PermissionType`` 成员 + 当作第一个位置参数传入(例如错写成 + ``@llm_tool(filter.PermissionType.ADMIN)``),会抛出 ``ValueError``, + 而不是被静默当成工具名。 + - 通过 ``registering_agent``(即 ``Agent.llm_tool``)注册的工具不支持权限 + 声明,因为它们不会被写入 ``func_list``,不经过面板可配置的工具管理系统, + 也不受 ``_default_permission`` / 面板权限覆盖的约束。同时传入 + ``registering_agent`` 和 ``permission_type`` 会抛出 ``ValueError``, + 而不是静默忽略你的权限声明。 + """ name_ = name - registering_agent = None - if kwargs.get("registering_agent"): - registering_agent = kwargs["registering_agent"] + registering_agent = kwargs.get("registering_agent") + declared_permission = _normalize_permission_declaration( + name, + permission_type, + registering_agent, + ) def decorator( awaitable: Callable[ @@ -661,7 +744,13 @@ def decorator( if not registering_agent: doc_desc = docstring.description.strip() if docstring.description else "" md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent) - llm_tools.add_func(llm_tool_name, args, doc_desc, md.handler) + llm_tools.add_func( + llm_tool_name, + args, + doc_desc, + md.handler, + declared_permission_type=declared_permission, + ) else: assert isinstance(registering_agent, RegisteringAgent) # print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name) @@ -669,7 +758,13 @@ def decorator( registering_agent._agent.tools = [] desc = docstring.description.strip() if docstring.description else "" - tool = llm_tools.spec_to_func(llm_tool_name, args, desc, awaitable) + tool = llm_tools.spec_to_func( + llm_tool_name, + args, + desc, + awaitable, + declared_permission_type=declared_permission, + ) registering_agent._agent.tools.append(tool) return awaitable diff --git a/astrbot/dashboard/services/tools_service.py b/astrbot/dashboard/services/tools_service.py index efd487185d..d889365222 100644 --- a/astrbot/dashboard/services/tools_service.py +++ b/astrbot/dashboard/services/tools_service.py @@ -571,7 +571,14 @@ def _serialize_tool(self, tool, config_entries: list[dict]) -> dict: perms_store.get("_default", {}) if isinstance(perms_store, dict) else {} ) configured = tool.name in defaults - permission = defaults[tool.name] if configured else "member" + permission = ( + defaults[tool.name] + if configured + else self.tool_mgr._default_permission(tool.name) + ) tool_info["permission"] = permission tool_info["permission_configured"] = configured + tool_info["declared_permission_type"] = getattr( + tool, "declared_permission_type", None + ) return tool_info diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts index cb96f33a7a..049d51da83 100644 --- a/dashboard/src/components/extension/componentPanel/types.ts +++ b/dashboard/src/components/extension/componentPanel/types.ts @@ -130,4 +130,11 @@ export interface ToolItem { permission?: 'admin' | 'member'; /** True when permission was explicitly configured rather than a fallback default. */ permission_configured?: boolean; + /** + * Default permission declared by the plugin author via + * `@llm_tool(permission_type=...)`. `null` when the tool declared no + * default. Distinct from `permission_configured`, which reflects an + * explicit WebUI override that always takes precedence over this value. + */ + declared_permission_type?: 'admin' | 'member' | null; } diff --git a/docs/en/dev/star/guides/ai.md b/docs/en/dev/star/guides/ai.md index 0014b25bbf..95b8b0f47f 100644 --- a/docs/en/dev/star/guides/ai.md +++ b/docs/en/dev/star/guides/ai.md @@ -118,6 +118,51 @@ Supported types: `string`, `number`, `object`, `boolean`, `array`. Since v4.5.7, > > Additionally, passing `parameters=...` directly to the decorator is **not supported** and will be silently ignored. If you need manual control over the schema, use the `@dataclass` + `add_llm_tools()` approach above. +### Declaring a Default Permission for Tools + +> [!TIP] +> Added in v4.X.X + +Just as `@filter.command()` can be restricted to admins with `@filter.permission_type(filter.PermissionType.ADMIN)`, `@filter.llm_tool()` supports the same idea through a `permission_type` parameter, letting you declare a default permission for the tool: + +```py +from astrbot.api.event import filter, AstrMessageEvent + +@filter.llm_tool(name="restart_server", permission_type=filter.PermissionType.ADMIN) +async def restart_server(self, event: AstrMessageEvent): + '''Restart the server.''' + # handler logic +``` + +`permission_type` accepts `filter.PermissionType.ADMIN` or `filter.PermissionType.MEMBER`. If omitted, the previous behavior is unchanged (the tool is available to everyone). + +If you define a tool via `@dataclass` + `FunctionTool` (see [Defining Tools](#defining-tools) above), you can declare a default permission the same way by adding a `declared_permission_type` field to the dataclass: + +```py +from pydantic import Field +from pydantic.dataclasses import dataclass + +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import FunctionTool, ToolExecResult +from astrbot.core.astr_agent_context import AstrAgentContext + + +@dataclass +class RestartServerTool(FunctionTool[AstrAgentContext]): + name: str = "restart_server" + description: str = "Restart the server." + parameters: dict = Field(default_factory=lambda: {"type": "object", "properties": {}}) + declared_permission_type: str | None = "admin" # "admin" / "member" / None + + async def call(self, context: ContextWrapper[AstrAgentContext], **kwargs) -> ToolExecResult: + # handler logic + return "ok" +``` + +> [!WARNING] +> - `permission_type` / `declared_permission_type` only sets the tool's **default permission**. If the bot owner has explicitly configured a permission for this tool in the WebUI panel (Extensions -> Components -> Tool Management), that configuration **overrides** the default declared in the plugin's code. +> - The point of this mechanism is that plugin authors can ship a sane default safeguard for dangerous tools (e.g. restarting a service, running shell commands) without relying on the bot owner to ever open the WebUI panel. + ## Invoking Agents > [!TIP] diff --git a/docs/zh/dev/star/guides/ai.md b/docs/zh/dev/star/guides/ai.md index 9de4b498a3..01d7e66a0b 100644 --- a/docs/zh/dev/star/guides/ai.md +++ b/docs/zh/dev/star/guides/ai.md @@ -117,6 +117,51 @@ async def get_weather(self, event: AstrMessageEvent, location: str) -> MessageEv > > 此外,装饰器**不支持**通过 `parameters=...` 显式传入参数 schema,该写法会被忽略。如需手动控制 schema,请使用上方的 `@dataclass` + `add_llm_tools()` 方式。 +### 为 Tool 声明默认权限 + +> [!TIP] +> 在 v4.X.X 时加入 + +`@filter.command()` 可以通过 `@filter.permission_type(filter.PermissionType.ADMIN)` 限制指令仅管理员可用,`@filter.llm_tool()` 也支持类似的写法,通过 `permission_type` 参数为工具声明一个默认权限: + +```py +from astrbot.api.event import filter, AstrMessageEvent + +@filter.llm_tool(name="restart_server", permission_type=filter.PermissionType.ADMIN) +async def restart_server(self, event: AstrMessageEvent): + '''重启服务器。''' + # 处理逻辑 +``` + +`permission_type` 可选 `filter.PermissionType.ADMIN` 或 `filter.PermissionType.MEMBER`,不传则保持原有行为(所有人可用)。 + +如果你是通过 `@dataclass` + `FunctionTool` 的方式定义 Tool(见上方[定义 Tool](#定义-tool)一节),也可以用同样的方式声明默认权限,只需要在 dataclass 里加上 `declared_permission_type` 字段: + +```py +from pydantic import Field +from pydantic.dataclasses import dataclass + +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import FunctionTool, ToolExecResult +from astrbot.core.astr_agent_context import AstrAgentContext + + +@dataclass +class RestartServerTool(FunctionTool[AstrAgentContext]): + name: str = "restart_server" + description: str = "Restart the server." + parameters: dict = Field(default_factory=lambda: {"type": "object", "properties": {}}) + declared_permission_type: str | None = "admin" # 可选 "admin" / "member" / None + + async def call(self, context: ContextWrapper[AstrAgentContext], **kwargs) -> ToolExecResult: + # 处理逻辑 + return "ok" +``` + +> [!WARNING] +> - `permission_type` / `declared_permission_type` 只是工具的**默认权限**。如果机器人主人在 WebUI 面板(扩展 -> 组件 -> 工具管理)里为该工具手动配置过权限,面板上的配置会**覆盖**插件代码里声明的默认值。 +> - 这个机制的意义在于:即便机器人主人从未打开过 WebUI 面板配置任何东西,插件作者依然可以为自己写的危险工具(例如重启服务、执行 shell 命令等)提供一层默认的安全防护,而不必依赖用户主动去配置。 + ## 调用 Agent > [!TIP] diff --git a/tests/unit/test_tool_permission.py b/tests/unit/test_tool_permission.py index bb7626aaa6..06410e3939 100644 --- a/tests/unit/test_tool_permission.py +++ b/tests/unit/test_tool_permission.py @@ -82,6 +82,300 @@ def test_default_permission_is_member(): assert mgr._default_permission("any_mcp_tool") == "member" +def test_default_permission_uses_tool_declared_admin(): + """A tool registered with @llm_tool(permission_type=ADMIN) should + default to admin when no dashboard override exists.""" + mgr = FunctionToolManager() + mgr.func_list.append(_dummy_tool("declared_admin_tool")) + mgr.func_list[-1].declared_permission_type = "admin" + assert mgr._default_permission("declared_admin_tool") == "admin" + + +def test_default_permission_uses_tool_declared_member(): + mgr = FunctionToolManager() + mgr.func_list.append(_dummy_tool("declared_member_tool")) + mgr.func_list[-1].declared_permission_type = "member" + assert mgr._default_permission("declared_member_tool") == "member" + + +def test_default_permission_falls_back_when_undeclared(): + mgr = FunctionToolManager() + mgr.func_list.append(_dummy_tool("undeclared_tool")) + assert mgr._default_permission("undeclared_tool") == "member" + + +def test_default_permission_falls_back_on_unexpected_value(): + """An unrecognized declared_permission_type (e.g. a typo like "admim", + or any future value outside the current "admin"/"member" whitelist) + must not be treated as valid -- it should fall back to "member" just + like an undeclared tool, never accidentally granting admin.""" + mgr = FunctionToolManager() + tool = _dummy_tool("typo_tool") + tool.declared_permission_type = "admim" + mgr.func_list.append(tool) + assert mgr._default_permission("typo_tool") == "member" + + +def test_default_permission_does_not_crash_on_foreign_tool_object(): + """Regression test: third-party tools registered via add_llm_tools() + are only type-hinted as FunctionTool, not enforced at runtime. A tool + object that doesn't inherit FunctionTool (and so lacks + declared_permission_type entirely) must not crash permission + resolution with an AttributeError.""" + + class HomemadeTool: + def __init__(self): + self.name = "homemade_tool" + self.description = "d" + self.parameters = {"type": "object", "properties": {}} + self.active = True + + async def call(self, context, **kwargs): + return "ok" + + mgr = FunctionToolManager() + mgr.func_list.append(HomemadeTool()) + assert mgr._default_permission("homemade_tool") == "member" + + +def test_default_permission_ignores_unknown_tool_name(): + """Tool name not present in func_list (e.g. MCP/builtin) -> 'member'.""" + mgr = FunctionToolManager() + assert mgr._default_permission("not_in_func_list") == "member" + + +# ── @llm_tool(permission_type=...) decorator ───────────────────────── + + +class TestLLMToolPermissionTypeDecorator: + """End-to-end tests for the @llm_tool permission_type parameter.""" + + def setup_method(self): + _clear_tool_permissions() + + def teardown_method(self): + _clear_tool_permissions() + + def test_declares_admin_permission_on_tool(self): + from astrbot.api.event import filter + from astrbot.core.provider.register import llm_tools + + @filter.llm_tool( + name="t8947_admin_tool", permission_type=filter.PermissionType.ADMIN + ) + async def _admin_tool(event): + """A dangerous admin-only tool.""" + return "ok" + + try: + tool = llm_tools.get_func("t8947_admin_tool") + assert tool is not None + assert tool.declared_permission_type == "admin" + assert llm_tools._default_permission("t8947_admin_tool") == "admin" + finally: + llm_tools.remove_func("t8947_admin_tool") + + def test_declares_member_permission_on_tool(self): + from astrbot.api.event import filter + from astrbot.core.provider.register import llm_tools + + @filter.llm_tool( + name="t8947_member_tool", permission_type=filter.PermissionType.MEMBER + ) + async def _member_tool(event): + """An explicitly unrestricted tool.""" + return "ok" + + try: + tool = llm_tools.get_func("t8947_member_tool") + assert tool is not None + assert tool.declared_permission_type == "member" + assert llm_tools._default_permission("t8947_member_tool") == "member" + finally: + llm_tools.remove_func("t8947_member_tool") + + def test_no_permission_type_keeps_legacy_behavior(self): + """Omitting permission_type must not change any existing behavior.""" + from astrbot.api.event import filter + from astrbot.core.provider.register import llm_tools + + @filter.llm_tool(name="t8947_default_tool") + async def _default_tool(event): + """A tool with no declared permission.""" + return "ok" + + try: + tool = llm_tools.get_func("t8947_default_tool") + assert tool is not None + assert tool.declared_permission_type is None + assert llm_tools._default_permission("t8947_default_tool") == "member" + finally: + llm_tools.remove_func("t8947_default_tool") + + def test_invalid_permission_type_raises(self): + from astrbot.api.event import filter + + with pytest.raises(ValueError, match="permission_type"): + @filter.llm_tool(name="t8947_bad_tool", permission_type="admin") + async def _bad_tool(event): + """Bad declaration using a raw string instead of the enum.""" + return "ok" + + def test_permission_type_via_agent_llm_tool_raises(self): + """Regression test: tools registered via Agent.llm_tool (i.e. through + RegisteringAgent) never get written to func_list, so they don't go + through _default_permission or the dashboard's permission override. + Declaring permission_type there used to be silently dropped -- + the tool would look like it had no permission protection at all, + even though the plugin author explicitly asked for one. It must + raise instead of silently ignoring the declaration.""" + from astrbot.api.event import filter + from astrbot.core.agent.agent import Agent + from astrbot.core.star.register.star_handler import RegisteringAgent + + agent = Agent(name="t8947_test_agent", instructions="test", tools=[]) + registering_agent = RegisteringAgent(agent) + + with pytest.raises(ValueError, match="Agent"): + + @registering_agent.llm_tool( + name="t8947_agent_tool", permission_type=filter.PermissionType.ADMIN + ) + async def _agent_tool(event): + """A tool that should not be registerable with a permission.""" + return "ok" + + def test_agent_llm_tool_without_permission_type_still_works(self): + """Omitting permission_type on an Agent-registered tool must + continue to work exactly as before this feature was added.""" + from astrbot.core.agent.agent import Agent + from astrbot.core.star.register.star_handler import RegisteringAgent + + agent = Agent(name="t8947_test_agent_ok", instructions="test", tools=[]) + registering_agent = RegisteringAgent(agent) + + @registering_agent.llm_tool(name="t8947_agent_tool_ok") + async def _agent_tool_ok(event): + """A normal tool with no permission declaration.""" + return "ok" + + assert len(agent.tools) == 1 + assert agent.tools[0].name == "t8947_agent_tool_ok" + assert agent.tools[0].declared_permission_type is None + + def test_permission_type_passed_positionally_as_name_raises(self): + """Regression test: forgetting name= / permission_type= and passing + a PermissionType member as the sole positional argument used to be + silently accepted -- Python binds it to `name`, and pydantic's str + coercion mangled it into a near-meaningless tool name (e.g. "1"), + with no permission protection at all and no indication anything + went wrong. This must raise a clear error instead.""" + from astrbot.api.event import filter + + with pytest.raises(ValueError, match="PermissionType"): + + @filter.llm_tool(filter.PermissionType.ADMIN) + async def _typo_tool(event): + """A tool registered with a common typo.""" + return "ok" + + def test_permission_type_passed_positionally_as_second_arg_raises(self): + """Regression test: permission_type is keyword-only. Passing it as + a second positional argument must raise TypeError rather than + silently relying on parameter-order coincidence (which would break + the moment the signature gains another positional parameter).""" + from astrbot.api.event import filter + + with pytest.raises(TypeError): + + @filter.llm_tool("t8947_positional_tool", filter.PermissionType.ADMIN) + async def _positional_tool(event): + """A tool registered with permission_type passed positionally.""" + return "ok" + + def test_declared_admin_is_enforced_without_dashboard_config(self): + """The whole point of the feature: a plugin author's declared + ADMIN default protects the tool even if the bot owner never + opens the WebUI panel to configure it.""" + from astrbot.api.event import filter + from astrbot.core.provider.register import llm_tools + + @filter.llm_tool( + name="t8947_enforced_tool", permission_type=filter.PermissionType.ADMIN + ) + async def _enforced_tool(event): + """Restart the server.""" + return "ok" + + try: + member_ctx = _make_context(role="member", sender_id="user_999") + error = llm_tools._check_tool_permission( + "t8947_enforced_tool", member_ctx + ) + assert error is not None + assert "admin" in error.lower() + + admin_ctx = _make_context(role="admin", sender_id="admin_1") + error = llm_tools._check_tool_permission( + "t8947_enforced_tool", admin_ctx + ) + assert error is None + finally: + llm_tools.remove_func("t8947_enforced_tool") + + def test_dashboard_override_takes_precedence_over_declared_default(self): + """An explicit WebUI-configured permission always wins over the + plugin-declared default.""" + from astrbot.api.event import filter + from astrbot.core.provider.register import llm_tools + + @filter.llm_tool( + name="t8947_overridden_tool", permission_type=filter.PermissionType.ADMIN + ) + async def _overridden_tool(event): + """Declared admin, but the dashboard demotes it to member.""" + return "ok" + + try: + sp.put( + "tool_permissions", + {"_default": {"t8947_overridden_tool": "member"}}, + scope="global", + scope_id="global", + ) + member_ctx = _make_context(role="member", sender_id="user_42") + error = llm_tools._check_tool_permission( + "t8947_overridden_tool", member_ctx + ) + assert error is None + finally: + llm_tools.remove_func("t8947_overridden_tool") + + def test_dashboard_serialization_surfaces_declared_default(self): + """get_tool_list() should expose the plugin-declared default so the + WebUI can show it (e.g. as a badge) even before any admin override + has been configured.""" + from astrbot.api.event import filter + from astrbot.core.provider.register import llm_tools + + @filter.llm_tool( + name="t8947_listed_tool", permission_type=filter.PermissionType.ADMIN + ) + async def _listed_tool(event): + """Listed in the dashboard with a declared admin default.""" + return "ok" + + try: + service = _make_tools_service(tool_mgr=llm_tools) + tools = service.get_tool_list() + target = next(t for t in tools if t["name"] == "t8947_listed_tool") + assert target["permission"] == "admin" + assert target["permission_configured"] is False + assert target["declared_permission_type"] == "admin" + finally: + llm_tools.remove_func("t8947_listed_tool") + + # ── _check_tool_permission ───────────────────────────────────────────