From b6c9f717b0ef5deb43580066a5f58ce4137902f8 Mon Sep 17 00:00:00 2001 From: RuochenPan Date: Tue, 15 Sep 2026 16:44:45 +0800 Subject: [PATCH 1/3] docs(webui): clarify local file access scopes --- dashboard/src/components/shared/LocalPermissionMatrix.vue | 5 +++++ .../src/i18n/locales/en-US/features/config-metadata.json | 4 ++++ .../src/i18n/locales/ja-JP/features/config-metadata.json | 4 ++++ .../src/i18n/locales/ru-RU/features/config-metadata.json | 4 ++++ .../src/i18n/locales/zh-CN/features/config-metadata.json | 4 ++++ 5 files changed, 21 insertions(+) diff --git a/dashboard/src/components/shared/LocalPermissionMatrix.vue b/dashboard/src/components/shared/LocalPermissionMatrix.vue index bf3dd191cd..5df3bdd395 100644 --- a/dashboard/src/components/shared/LocalPermissionMatrix.vue +++ b/dashboard/src/components/shared/LocalPermissionMatrix.vue @@ -132,6 +132,11 @@ +
+

{{ tm('scopeHints.workspace') }}

+

{{ tm('scopeHints.host') }}

+
+ {{ tm('memberWarning') }} diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 1b3c0d0e04..72ebcf9147 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -310,6 +310,10 @@ "workspace": "Workspace", "host": "Host files" }, + "scopeHints": { + "workspace": "Workspace: restricted access to the session workspace, temporary directories, and skill files.", + "host": "Host files: access files permitted by the account running AstrBot. In Docker, this means files inside the container and mounted files, not all files on the Docker host." + }, "roles": { "member": "Member", "admin": "Administrator" diff --git a/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json b/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json index 74b2f89147..861d9eec5a 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json @@ -398,6 +398,10 @@ "workspace": "ワークスペース", "host": "ホストのファイル" }, + "scopeHints": { + "workspace": "ワークスペース:セッションのワークスペース、一時ディレクトリ、スキルファイルに制限付きでアクセスします。", + "host": "ホストのファイル:AstrBot の実行アカウントに権限があるファイルにアクセスできます。Docker 環境ではコンテナ内およびマウントされたファイルを指し、Docker ホストの全ファイルではありません。" + }, "roles": { "member": "一般メンバー", "admin": "管理者" diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index a8c5cd6640..6f9e482d43 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -437,6 +437,10 @@ "workspace": "Рабочая область", "host": "Файлы компьютера" }, + "scopeHints": { + "workspace": "Рабочая область: ограниченный доступ к рабочей области сеанса, временным каталогам и файлам навыков.", + "host": "Файлы компьютера: доступ к файлам в пределах прав учётной записи, запускающей AstrBot. В Docker это файлы внутри контейнера и подключённые файлы, а не все файлы хоста Docker." + }, "roles": { "member": "Участник", "admin": "Администратор" diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 176d5f0ad2..d2eb224e5f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -326,6 +326,10 @@ "workspace": "工作区", "host": "主机文件" }, + "scopeHints": { + "workspace": "工作区:在受限范围内访问会话工作区、临时目录和技能文件。", + "host": "主机文件:可访问 AstrBot 运行账户有权限访问的文件;Docker 部署时指容器内及挂载的文件,并非宿主机全部文件。" + }, "roles": { "member": "普通成员", "admin": "管理员" From 0d4b7e6ee93f7b8e8dd3a22db3a4181ec4128015 Mon Sep 17 00:00:00 2001 From: RuochenPan Date: Tue, 15 Sep 2026 16:49:04 +0800 Subject: [PATCH 2/3] fix: report network restrictions in local tool results --- astrbot/core/tools/computer_tools/python.py | 22 ++++++++++++++--- astrbot/core/tools/computer_tools/shell.py | 12 +++++++-- astrbot/core/tools/computer_tools/util.py | 7 ++++++ tests/unit/test_func_tool_manager.py | 27 ++++++++++++++++++--- tests/unit/test_python_tools.py | 25 +++++++++++++++---- 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/astrbot/core/tools/computer_tools/python.py b/astrbot/core/tools/computer_tools/python.py index 0f8a759dc5..52cb5239ad 100644 --- a/astrbot/core/tools/computer_tools/python.py +++ b/astrbot/core/tools/computer_tools/python.py @@ -14,6 +14,7 @@ from ..registry import builtin_tool from .fs import _read_allowed_roots, _write_allowed_roots from .util import ( + LOCAL_NETWORK_POLICY_NOTICE, check_admin_permission, check_local_execution_permission, workspace_root_for_context, @@ -49,7 +50,9 @@ } -async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult: +async def handle_result( + result: dict, event: AstrMessageEvent +) -> mcp.types.CallToolResult: data = result.get("data", {}) output = data.get("output", {}) error = data.get("error", "") @@ -178,6 +181,19 @@ async def call( filesystem_scope=local_policy.filesystem_scope, **sandbox_roots, ) - return await handle_result(result, context.context.event) + response = await handle_result(result, context.context.event) + if not local_policy.allow_network: + response.content.insert( + 0, + mcp.types.TextContent( + type="text", text=LOCAL_NETWORK_POLICY_NOTICE + ), + ) + return response except Exception as e: - return f"Error executing code: {str(e)}" + policy_notice = ( + f"{LOCAL_NETWORK_POLICY_NOTICE}\n" + if not local_policy.allow_network + else "" + ) + return f"{policy_notice}Error executing code: {str(e)}" diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index ac1d17126a..c21ff6ff34 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -18,6 +18,7 @@ from ..registry import builtin_tool from .fs import _read_allowed_roots, _write_allowed_roots from .util import ( + LOCAL_NETWORK_POLICY_NOTICE, check_local_execution_permission, get_local_permission_policy, is_local_runtime, @@ -108,6 +109,11 @@ async def call( if permission_error: return permission_error sandboxed = bool(local_policy and local_policy.requires_sandbox) + policy_notice = ( + f"{LOCAL_NETWORK_POLICY_NOTICE}\n" + if local_policy and not local_policy.allow_network + else "" + ) sb = await get_booter( context.context.context, @@ -186,7 +192,9 @@ async def call( f"(wall time: {elapsed_seconds:.2f}s)." ) output = f"{result['stdout']}{result['stderr']}" - return f"{message}\nOutput:\n{output}" + return f"{policy_notice}{message}\nOutput:\n{output}" + if policy_notice: + result["policy_notice"] = LOCAL_NETWORK_POLICY_NOTICE return json.dumps(result, ensure_ascii=False) effective_background = background and not _is_self_detached_command(command) @@ -217,7 +225,7 @@ async def call( return json.dumps(result, ensure_ascii=False) except Exception as e: detail = str(e) or type(e).__name__ - return f"Error executing command: {detail}" + return f"{policy_notice}Error executing command: {detail}" @dataclass diff --git a/astrbot/core/tools/computer_tools/util.py b/astrbot/core/tools/computer_tools/util.py index 0fdfc20094..bfaa54ad16 100644 --- a/astrbot/core/tools/computer_tools/util.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -13,6 +13,13 @@ resolve_workspace_root_for_umo, ) +LOCAL_NETWORK_POLICY_NOTICE = ( + "Sandbox policy: Network access is disabled for local Shell/Python execution. " + "Do not retry the same network operation with another command, Python, " + "HTTP/HTTPS, or disabled certificate verification; these do not change the policy. " + "Local offline operations are still allowed." +) + @dataclass(frozen=True) class LocalPermissionPolicy: diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py index 4886194269..58eeca0cdc 100644 --- a/tests/unit/test_func_tool_manager.py +++ b/tests/unit/test_func_tool_manager.py @@ -116,9 +116,11 @@ def test_shell_session_schema_supports_line_writes(): @pytest.mark.asyncio @pytest.mark.skipif(os.name == "nt", reason="Restricted execution needs POSIX.") +@pytest.mark.parametrize("allow_network", [False, True]) async def test_local_execute_shell_manages_running_and_closed_results( monkeypatch, tmp_path, + allow_network, ): from astrbot.core.tools.computer_tools import shell as shell_tools @@ -141,7 +143,14 @@ class FakeBooter: class FakeConfig: def get_config(self, umo): - return {"provider_settings": {"computer_use_runtime": "local"}} + return { + "provider_settings": { + "computer_use_runtime": "local", + "computer_use_local_permissions": { + "admin": {"allow_network": allow_network} + }, + } + } class FakeEvent: unified_msg_origin = "umo" @@ -167,7 +176,7 @@ async def fake_get_booter(context, session_id): "workspace_root_for_context", AsyncMock(return_value=tmp_path), ) - monotonic_values = iter((10.0, 10.5, 20.0, 21.234, 30.0, 32.346)) + monotonic_values = iter((10.0, 10.5, 20.0, 21.234, 30.0, 32.346, 40.0)) monkeypatch.setattr(shell_tools, "monotonic", lambda: next(monotonic_values)) result = await LocalExecuteShellTool().call( @@ -177,6 +186,10 @@ async def fake_get_booter(context, session_id): ) assert json.loads(result)["session_id"] == "sh_test" + notice = shell_tools.LOCAL_NETWORK_POLICY_NOTICE + assert json.loads(result).get("policy_notice") == ( + None if allow_network else notice + ) shell.exec_managed.assert_awaited_once_with( "python server.py", owner_id="umo", @@ -184,7 +197,7 @@ async def fake_get_booter(context, session_id): creator_is_admin=True, sandboxed=True, permission_check=ANY, - allow_network=True, + allow_network=allow_network, filesystem_scope="workspace", readable_roots=ANY, writable_roots=ANY, @@ -215,10 +228,16 @@ async def fake_get_booter(context, session_id): ) assert result == ( - f"Command completed with exit code {exit_code} " + ("" if allow_network else f"{notice}\n") + + f"Command completed with exit code {exit_code} " f"(wall time: {wall_time}s).\nOutput:\ndone\n" ) + shell.exec_managed.side_effect = RuntimeError("execution failed") + result = await LocalExecuteShellTool().call(FakeWrapper(), command="echo done") + assert result.endswith("Error executing command: execution failed") + assert (notice in result) is (not allow_network) + @pytest.mark.asyncio @pytest.mark.skipif(os.name == "nt", reason="Restricted execution needs POSIX.") diff --git a/tests/unit/test_python_tools.py b/tests/unit/test_python_tools.py index ec74169e34..6531ddaee2 100644 --- a/tests/unit/test_python_tools.py +++ b/tests/unit/test_python_tools.py @@ -137,15 +137,25 @@ async def fake_workspace_root_for_context(context): @pytest.mark.asyncio @pytest.mark.skipif(os.name == "nt", reason="Restricted execution needs POSIX.") -async def test_local_member_python_uses_sandbox_backend( +@pytest.mark.parametrize("role", ["member", "admin"]) +@pytest.mark.parametrize("outcome", ["success", "error", "exception"]) +async def test_local_python_uses_sandbox_backend( tmp_path, monkeypatch, + role, + outcome, ): """Local member Python execution should require an OS sandbox.""" from astrbot.core.tools.computer_tools import util as computer_util python_exec = AsyncMock( - return_value={"data": {"output": {"text": "ok", "images": []}, "error": ""}} + return_value={ + "data": { + "output": {"text": "ok", "images": []}, + "error": "execution failed" if outcome == "error" else "", + } + }, + side_effect=RuntimeError("execution failed") if outcome == "exception" else None, ) local_python = LocalPythonComponent() local_python.exec = python_exec @@ -161,7 +171,7 @@ async def test_local_member_python_uses_sandbox_backend( event = SimpleNamespace( unified_msg_origin="onebot:GroupMessage:12345", - role="member", + role=role, get_platform_name=lambda: "onebot", ) context = ContextWrapper( @@ -179,7 +189,12 @@ async def test_local_member_python_uses_sandbox_backend( tool_call_timeout=60, ) - await LocalPythonTool().call(context, code="print('ok')", timeout=30) + result = await LocalPythonTool().call(context, code="print('ok')", timeout=30) + output = result if isinstance(result, str) else "\n".join(p.text for p in result.content) + assert (computer_util.LOCAL_NETWORK_POLICY_NOTICE in output) is (role == "member") + assert ("execution failed" in output) is (outcome != "success") + if outcome != "exception": + assert result.content[-1].text == "ok" python_exec.assert_awaited_once_with( "print('ok')", @@ -187,7 +202,7 @@ async def test_local_member_python_uses_sandbox_backend( silent=False, cwd=str(tmp_path.resolve(strict=False)), sandboxed=True, - allow_network=False, + allow_network=role == "admin", filesystem_scope="workspace", readable_roots=ANY, writable_roots=ANY, From 3d7618adafc7641660a596835b72e0fef3baba87 Mon Sep 17 00:00:00 2001 From: RuochenPan Date: Tue, 15 Sep 2026 16:58:26 +0800 Subject: [PATCH 3/3] test: simplify local network policy feedback coverage --- tests/unit/test_func_tool_manager.py | 7 +------ tests/unit/test_python_tools.py | 13 ++++--------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py index 58eeca0cdc..df85c27435 100644 --- a/tests/unit/test_func_tool_manager.py +++ b/tests/unit/test_func_tool_manager.py @@ -176,7 +176,7 @@ async def fake_get_booter(context, session_id): "workspace_root_for_context", AsyncMock(return_value=tmp_path), ) - monotonic_values = iter((10.0, 10.5, 20.0, 21.234, 30.0, 32.346, 40.0)) + monotonic_values = iter((10.0, 10.5, 20.0, 21.234, 30.0, 32.346)) monkeypatch.setattr(shell_tools, "monotonic", lambda: next(monotonic_values)) result = await LocalExecuteShellTool().call( @@ -233,11 +233,6 @@ async def fake_get_booter(context, session_id): f"(wall time: {wall_time}s).\nOutput:\ndone\n" ) - shell.exec_managed.side_effect = RuntimeError("execution failed") - result = await LocalExecuteShellTool().call(FakeWrapper(), command="echo done") - assert result.endswith("Error executing command: execution failed") - assert (notice in result) is (not allow_network) - @pytest.mark.asyncio @pytest.mark.skipif(os.name == "nt", reason="Restricted execution needs POSIX.") diff --git a/tests/unit/test_python_tools.py b/tests/unit/test_python_tools.py index 6531ddaee2..d4e41658e9 100644 --- a/tests/unit/test_python_tools.py +++ b/tests/unit/test_python_tools.py @@ -138,24 +138,21 @@ async def fake_workspace_root_for_context(context): @pytest.mark.asyncio @pytest.mark.skipif(os.name == "nt", reason="Restricted execution needs POSIX.") @pytest.mark.parametrize("role", ["member", "admin"]) -@pytest.mark.parametrize("outcome", ["success", "error", "exception"]) async def test_local_python_uses_sandbox_backend( tmp_path, monkeypatch, role, - outcome, ): - """Local member Python execution should require an OS sandbox.""" + """Preserve Python output and errors while reporting the active network policy.""" from astrbot.core.tools.computer_tools import util as computer_util python_exec = AsyncMock( return_value={ "data": { "output": {"text": "ok", "images": []}, - "error": "execution failed" if outcome == "error" else "", + "error": "execution failed", } }, - side_effect=RuntimeError("execution failed") if outcome == "exception" else None, ) local_python = LocalPythonComponent() local_python.exec = python_exec @@ -190,11 +187,9 @@ async def test_local_python_uses_sandbox_backend( ) result = await LocalPythonTool().call(context, code="print('ok')", timeout=30) - output = result if isinstance(result, str) else "\n".join(p.text for p in result.content) + output = [part.text for part in result.content] assert (computer_util.LOCAL_NETWORK_POLICY_NOTICE in output) is (role == "member") - assert ("execution failed" in output) is (outcome != "success") - if outcome != "exception": - assert result.content[-1].text == "ok" + assert output[-2:] == ["error: execution failed", "ok"] python_exec.assert_awaited_once_with( "print('ok')",