Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions astrbot/core/tools/computer_tools/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", "")
Expand Down Expand Up @@ -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)}"
12 changes: 10 additions & 2 deletions astrbot/core/tools/computer_tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions astrbot/core/tools/computer_tools/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/components/shared/LocalPermissionMatrix.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@
</tbody>
</v-table>

<div class="text-caption text-medium-emphasis">
<p v-if="!unsupported">{{ tm('scopeHints.workspace') }}</p>
<p>{{ tm('scopeHints.host') }}</p>
</div>

<v-alert v-if="memberHasElevatedAccess" type="warning" variant="tonal" density="compact">
{{ tm('memberWarning') }}
</v-alert>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,10 @@
"workspace": "ワークスペース",
"host": "ホストのファイル"
},
"scopeHints": {
"workspace": "ワークスペース:セッションのワークスペース、一時ディレクトリ、スキルファイルに制限付きでアクセスします。",
"host": "ホストのファイル:AstrBot の実行アカウントに権限があるファイルにアクセスできます。Docker 環境ではコンテナ内およびマウントされたファイルを指し、Docker ホストの全ファイルではありません。"
},
"roles": {
"member": "一般メンバー",
"admin": "管理者"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,10 @@
"workspace": "Рабочая область",
"host": "Файлы компьютера"
},
"scopeHints": {
"workspace": "Рабочая область: ограниченный доступ к рабочей области сеанса, временным каталогам и файлам навыков.",
"host": "Файлы компьютера: доступ к файлам в пределах прав учётной записи, запускающей AstrBot. В Docker это файлы внутри контейнера и подключённые файлы, а не все файлы хоста Docker."
},
"roles": {
"member": "Участник",
"admin": "Администратор"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,10 @@
"workspace": "工作区",
"host": "主机文件"
},
"scopeHints": {
"workspace": "工作区:在受限范围内访问会话工作区、临时目录和技能文件。",
"host": "主机文件:可访问 AstrBot 运行账户有权限访问的文件;Docker 部署时指容器内及挂载的文件,并非宿主机全部文件。"
},
"roles": {
"member": "普通成员",
"admin": "管理员"
Expand Down
20 changes: 17 additions & 3 deletions tests/unit/test_func_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -177,14 +186,18 @@ 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",
creator_id="admin-user",
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,
Expand Down Expand Up @@ -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"
)

Expand Down
22 changes: 16 additions & 6 deletions tests/unit/test_python_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -179,15 +186,18 @@ 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')",
timeout=30,
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,
Expand Down
Loading