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/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": "管理员"
diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py
index 4886194269..df85c27435 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"
@@ -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,7 +228,8 @@ 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"
)
diff --git a/tests/unit/test_python_tools.py b/tests/unit/test_python_tools.py
index ec74169e34..d4e41658e9 100644
--- a/tests/unit/test_python_tools.py
+++ b/tests/unit/test_python_tools.py
@@ -137,15 +137,22 @@ 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"])
+async def test_local_python_uses_sandbox_backend(
tmp_path,
monkeypatch,
+ role,
):
- """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": ""}}
+ return_value={
+ "data": {
+ "output": {"text": "ok", "images": []},
+ "error": "execution failed",
+ }
+ },
)
local_python = LocalPythonComponent()
local_python.exec = python_exec
@@ -161,7 +168,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 +186,10 @@ 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 = [part.text for part in result.content]
+ assert (computer_util.LOCAL_NETWORK_POLICY_NOTICE in output) is (role == "member")
+ assert output[-2:] == ["error: execution failed", "ok"]
python_exec.assert_awaited_once_with(
"print('ok')",
@@ -187,7 +197,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,