fix: use supported local permission defaults on Windows

This commit is contained in:
RuochenPan
2026-09-08 09:41:25 +08:00
parent a6e207aeea
commit 8ceb6a4f64
11 changed files with 193 additions and 82 deletions
+10 -17
View File
@@ -97,24 +97,17 @@ class AstrBotConfig(dict):
and "computer_use_local_permissions" in default_provider_settings
and "computer_use_local_permissions" not in provider_settings
):
# Preserve the legacy Local meaning while replacing its use of the
# shared admin switch with explicit per-role permissions.
member_execution = not provider_settings.get(
"computer_use_require_admin",
True,
# Preserve legacy POSIX access; Windows uses its supported defaults.
permissions = copy.deepcopy(
default_provider_settings["computer_use_local_permissions"]
)
provider_settings["computer_use_local_permissions"] = {
"member": {
"allow_execution": member_execution,
"allow_network": False,
"filesystem_scope": "workspace",
},
"admin": {
"allow_execution": True,
"allow_network": True,
"filesystem_scope": "host",
},
}
permissions["member"]["allow_execution"] = permissions["member"][
"filesystem_scope"
] != "none" and not provider_settings.get(
"computer_use_require_admin", True
)
permissions["admin"]["filesystem_scope"] = "host"
provider_settings["computer_use_local_permissions"] = permissions
config_migrated = True
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf, schema=schema)
+28 -12
View File
@@ -1,6 +1,7 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os
import platform
from astrbot import __version__
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
@@ -8,6 +9,32 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .agent_runner import get_agent_runner_config_default
def get_local_permission_defaults(system: str | None = None) -> dict:
"""Return fresh Local permission defaults for the operating system.
Args:
system: Operating system name, or None to use the current system.
Returns:
Per-role policies. Windows disables member access and gives admins
unrestricted access because workspace isolation is unavailable.
"""
windows = (system or platform.system()).lower() == "windows"
return {
"member": {
"allow_execution": False,
"allow_network": False,
"filesystem_scope": "none" if windows else "workspace",
},
"admin": {
"allow_execution": True,
"allow_network": True,
"filesystem_scope": "host" if windows else "workspace",
},
}
VERSION = __version__
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
@@ -152,18 +179,7 @@ DEFAULT_CONFIG = {
"add_cron_tools": True,
},
"computer_use_runtime": "none",
"computer_use_local_permissions": {
"member": {
"allow_execution": False,
"allow_network": False,
"filesystem_scope": "workspace",
},
"admin": {
"allow_execution": True,
"allow_network": True,
"filesystem_scope": "workspace",
},
},
"computer_use_local_permissions": get_local_permission_defaults(),
"computer_use_require_admin": True,
"sandbox": {
"booter": "shipyard_neo",
+8 -8
View File
@@ -5,6 +5,7 @@ from typing import Literal
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.process_sandbox import create_process_sandbox
from astrbot.core.config.default import get_local_permission_defaults
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
from astrbot.core.workspace import (
@@ -93,29 +94,28 @@ def get_local_permission_policy(
)
provider_settings = cfg.get("provider_settings", {})
role = "admin" if context.context.event.role == "admin" else "member"
default_execution = role == "admin"
default_network = role == "admin"
default_filesystem = "workspace"
defaults = get_local_permission_defaults()[role]
permissions = provider_settings.get("computer_use_local_permissions")
role_policy = permissions.get(role) if isinstance(permissions, dict) else None
if not isinstance(role_policy, dict):
role_policy = {}
if role == "member" and not isinstance(permissions, dict):
default_execution = not provider_settings.get(
defaults["allow_execution"] = not provider_settings.get(
"computer_use_require_admin",
True,
)
filesystem_scope = role_policy.get("filesystem_scope", default_filesystem)
filesystem_scope = role_policy.get("filesystem_scope", defaults["filesystem_scope"])
if filesystem_scope not in {"none", "workspace", "host"}:
filesystem_scope = default_filesystem
filesystem_scope = defaults["filesystem_scope"]
allow_execution = (
filesystem_scope != "none"
and role_policy.get("allow_execution", default_execution) is True
and role_policy.get("allow_execution", defaults["allow_execution"]) is True
)
allow_network = (
allow_execution and role_policy.get("allow_network", default_network) is True
allow_execution
and role_policy.get("allow_network", defaults["allow_network"]) is True
)
return LocalPermissionPolicy(
allow_execution=allow_execution,
+9 -5
View File
@@ -19,6 +19,7 @@ from astrbot.core.config.default import (
CONFIG_METADATA_3_SYSTEM,
DEFAULT_CONFIG,
DEFAULT_VALUE_MAP,
get_local_permission_defaults,
)
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -323,6 +324,7 @@ def validate_config(
}
validate(data, meta_all)
provider_settings = data.get("provider_settings", {})
defaults = get_local_permission_defaults(runtime.get("os") if runtime else None)
permissions = (
provider_settings.get("computer_use_local_permissions", {})
if isinstance(provider_settings, dict)
@@ -346,7 +348,9 @@ def validate_config(
errors.append(
f"Local permission {role}.{key} must be a boolean."
)
scope = policy.get("filesystem_scope", "workspace")
scope = policy.get(
"filesystem_scope", defaults[role]["filesystem_scope"]
)
if scope not in ("none", "workspace", "host"):
errors.append(
f"Invalid local filesystem scope for {role}: {scope}."
@@ -354,7 +358,10 @@ def validate_config(
if scope == "none":
policy["allow_execution"] = False
policy["allow_network"] = False
elif policy.get("allow_execution", role == "admin") is False:
elif (
policy.get("allow_execution", defaults[role]["allow_execution"])
is False
):
policy["allow_network"] = False
if (
@@ -367,9 +374,6 @@ def validate_config(
old_settings = (current_config or {}).get("provider_settings", {})
old_permissions = old_settings.get("computer_use_local_permissions", {})
was_local = old_settings.get("computer_use_runtime") == "local"
defaults = DEFAULT_CONFIG["provider_settings"][
"computer_use_local_permissions"
]
for role in ("member", "admin"):
# Keep unchanged legacy policies, but check both roles when
# activating Local access or creating a profile.
@@ -257,7 +257,8 @@ import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import DashboardTotpManager from './DashboardTotpManager.vue'
import LocalPermissionMatrix from './LocalPermissionMatrix.vue'
import LocalPermissionMatrix, { windowsPermissionDefaults } from './LocalPermissionMatrix.vue'
import { statsApi } from '@/api/v1'
import { computed, ref } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { usePluginI18n } from '@/utils/pluginI18n'
@@ -306,7 +307,9 @@ const { t } = useI18n()
const { getRaw } = useModuleI18n('features/config-metadata')
const { configText } = usePluginI18n()
function emitUpdate(val) {
async function emitUpdate(val) {
const enablingLocal = props.configKey === 'provider_settings.computer_use_runtime'
&& (props.modelValue === 'none' || props.modelValue == null) && val === 'local'
if (
props.itemMeta?._special === 'agent_runner_type'
&& props.configRoot?.agent_runner
@@ -317,6 +320,22 @@ function emitUpdate(val) {
)
}
emit('update:modelValue', val)
if (enablingLocal && props.configRoot?.provider_settings) {
const settings = props.configRoot.provider_settings
try {
const response = await statsApi.version()
if (response.data?.data?.runtime?.os === 'windows' && settings.computer_use_runtime === 'local') {
const permissions = { ...settings.computer_use_local_permissions }
for (const [role, defaults] of Object.entries(windowsPermissionDefaults)) {
const policy = permissions[role]
if (!policy || policy.filesystem_scope === 'workspace') permissions[role] = { ...defaults }
}
settings.computer_use_local_permissions = permissions
}
} catch (error) {
console.warn('Failed to initialize local permissions:', error)
}
}
}
const listSelectItems = computed(() =>
@@ -133,6 +133,13 @@
</div>
</template>
<script>
export const windowsPermissionDefaults = {
member: { filesystem_scope: 'none', allow_execution: false, allow_network: false },
admin: { filesystem_scope: 'host', allow_execution: true, allow_network: true }
}
</script>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Cpu, FolderOpen, LockKeyhole, Monitor, Shield, ShieldAlert, ShieldOff } from '@lucide/vue'
@@ -153,7 +160,7 @@ const runtimeLoading = ref(true)
const unsupported = computed(() => runtime.value?.sandbox?.status === 'unsupported')
const sandboxMissing = computed(() => runtime.value?.sandbox?.status === 'missing')
const roles = ['member', 'admin']
const defaults = {
const defaults = computed(() => runtime.value?.os === 'windows' ? windowsPermissionDefaults : {
member: {
allow_execution: false,
allow_network: false,
@@ -164,7 +171,7 @@ const defaults = {
allow_network: true,
filesystem_scope: 'workspace'
}
}
})
onMounted(async () => {
try {
@@ -179,11 +186,11 @@ onMounted(async () => {
function policy(role) {
const resolved = {
...defaults[role],
...defaults.value[role],
...(props.modelValue?.[role] || {})
}
if (!['none', 'workspace', 'host'].includes(resolved.filesystem_scope)) {
resolved.filesystem_scope = defaults[role].filesystem_scope
resolved.filesystem_scope = defaults.value[role].filesystem_scope
}
resolved.allow_execution = resolved.filesystem_scope !== 'none' && resolved.allow_execution === true
resolved.allow_network = resolved.allow_execution && resolved.allow_network === true
@@ -215,9 +222,9 @@ function updatePermission(role, changes) {
}
const accessPolicies = {
none: { filesystem_scope: 'none', allow_execution: false, allow_network: false },
none: windowsPermissionDefaults.member,
files: { filesystem_scope: 'host', allow_execution: false, allow_network: false },
full: { filesystem_scope: 'host', allow_execution: true, allow_network: true }
full: windowsPermissionDefaults.admin
}
const accessIcons = { none: LockKeyhole, files: FolderOpen, full: ShieldAlert }
const permissionLocks = computed(() => Object.fromEntries(roles.map(role => {
+1 -1
View File
@@ -140,7 +140,7 @@ def test_local_permission_platform_matrix(
("local", "local", "member", {}, False),
("local", "local", "admin", {"filesystem_scope": "host"}, False),
("local", "local", "member", {"allow_execution": True}, True),
("local", "local", "member", None, True),
("local", "local", "member", None, False),
("none", "local", "member", {}, True),
("local", "none", "member", {}, False),
("local", "sandbox", "member", {}, False),
+23 -6
View File
@@ -393,7 +393,11 @@ async def test_local_member_stays_restricted_when_admin_requirement_is_disabled(
outside_file.write_text("host secret\n", encoding="utf-8")
result = await fs_tools.FileReadTool().call(
_make_context(role="member", require_admin=False),
_make_context(
role="member",
require_admin=False,
local_permissions={"member": {"filesystem_scope": "workspace"}},
),
path=str(outside_file),
)
@@ -462,7 +466,10 @@ async def test_restricted_local_member_cannot_write_plugin_provided_skill(
plugin_skill.write_text("# Demo Skill\n", encoding="utf-8")
result = await fs_tools.FileWriteTool().call(
_make_context(role="member"),
_make_context(
role="member",
local_permissions={"member": {"filesystem_scope": "workspace"}},
),
path=str(plugin_skill),
content="# Changed\n",
)
@@ -548,13 +555,17 @@ async def test_restricted_local_member_rejects_workspace_hardlink_alias(
outside_file.write_text("outside-secret\n", encoding="utf-8")
hardlink_path = workspace / "linked.txt"
_make_hardlink_or_skip(outside_file, hardlink_path)
context = _make_context(
role="member",
local_permissions={"member": {"filesystem_scope": "workspace"}},
)
read_result = await fs_tools.FileReadTool().call(
_make_context(role="member"),
context,
path="linked.txt",
)
write_result = await fs_tools.FileWriteTool().call(
_make_context(role="member"),
context,
path="linked.txt",
content="changed\n",
)
@@ -595,7 +606,10 @@ async def test_windows_restricted_file_access_fails_closed(
monkeypatch.setattr(local_file_security, "os", SimpleNamespace(name="nt"))
result = await tool_type().call(
_make_context(role=role),
_make_context(
role=role,
local_permissions={role: {"filesystem_scope": "workspace"}},
),
**arguments,
)
@@ -757,7 +771,10 @@ async def test_restricted_local_grep_requests_read_only_os_sandbox(
monkeypatch.setattr(fs_tools, "get_booter", _fake_get_booter)
result = await fs_tools.GrepTool().call(
_make_context(role="member"),
_make_context(
role="member",
local_permissions={"member": {"filesystem_scope": "workspace"}},
),
pattern="needle",
path="target.txt",
)
+21 -7
View File
@@ -6,6 +6,7 @@ import pytest
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.computer import process_sandbox
from astrbot.core.config import default as config_defaults
from astrbot.core.tools.computer_tools import fs, python, shell, util
from astrbot.core.tools.computer_tools.shipyard_neo.browser import BrowserExecTool
from astrbot.core.tools.computer_tools.shipyard_neo.neo_skills import (
@@ -108,16 +109,29 @@ def test_local_permission_policy_treats_unknown_roles_as_members():
assert resolved.allow_execution is False
assert resolved.allow_network is False
assert resolved.filesystem_scope == "workspace"
assert (
resolved.filesystem_scope
== config_defaults.get_local_permission_defaults()["member"]["filesystem_scope"]
)
def test_local_permission_policy_defaults_admin_to_workspace_access():
resolved = get_local_permission_policy(_make_local_run_context("admin", {}))
@pytest.mark.parametrize("system", ["Windows", "Linux", "Darwin"])
@pytest.mark.parametrize("role", ["member", "admin", "unexpected"])
@pytest.mark.parametrize("permissions", [None, {}])
def test_local_permission_policy_platform_defaults(
monkeypatch, system, role, permissions
):
monkeypatch.setattr(
config_defaults, "platform", SimpleNamespace(system=lambda: system)
)
resolved = get_local_permission_policy(_make_local_run_context(role, permissions))
assert resolved.allow_execution is True
assert resolved.allow_network is True
assert resolved.filesystem_scope == "workspace"
assert resolved.requires_sandbox is True
assert resolved.allow_execution == (role == "admin")
assert resolved.allow_network == (role == "admin")
assert resolved.filesystem_scope == (
("host" if role == "admin" else "none") if system == "Windows" else "workspace"
)
assert resolved.requires_sandbox == (system != "Windows" or role != "admin")
@pytest.mark.parametrize("role", ["member", "admin", "unexpected"])
+45 -15
View File
@@ -4,11 +4,12 @@ import asyncio
import json
import os
import threading
from pathlib import Path
import pytest
from astrbot.core.config.astrbot_config import AstrBotConfig, RateLimitStrategy
from astrbot.core.config.default import DEFAULT_VALUE_MAP
from astrbot.core.config.default import DEFAULT_VALUE_MAP, get_local_permission_defaults
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.utils.auth_password import (
DEFAULT_DASHBOARD_PASSWORD,
@@ -92,27 +93,18 @@ class TestAstrBotConfigLoad:
assert config.provider_settings["enable"] is False
@pytest.mark.parametrize("require_admin", [True, False])
@pytest.mark.parametrize("system", ["Windows", "Linux", "Darwin"])
def test_migrates_legacy_local_computer_permissions(
self,
temp_config_path,
require_admin,
system,
):
"""Legacy admin switches should become explicit Local role policies."""
default_config = {
"provider_settings": {
"computer_use_require_admin": True,
"computer_use_local_permissions": {
"member": {
"allow_execution": False,
"allow_network": False,
"filesystem_scope": "workspace",
},
"admin": {
"allow_execution": True,
"allow_network": True,
"filesystem_scope": "workspace",
},
},
"computer_use_local_permissions": get_local_permission_defaults(system),
}
}
with open(temp_config_path, "w", encoding="utf-8-sig") as file:
@@ -132,15 +124,53 @@ class TestAstrBotConfigLoad:
permissions = config["provider_settings"]["computer_use_local_permissions"]
assert permissions["member"] == {
"allow_execution": not require_admin,
"allow_execution": system != "Windows" and not require_admin,
"allow_network": False,
"filesystem_scope": "workspace",
"filesystem_scope": "none" if system == "Windows" else "workspace",
}
assert permissions["admin"] == {
"allow_execution": True,
"allow_network": True,
"filesystem_scope": "host",
}
assert (
json.loads(Path(temp_config_path).read_text(encoding="utf-8-sig"))[
"provider_settings"
]["computer_use_local_permissions"]
== permissions
)
@pytest.mark.parametrize("system", ["Windows", "Linux", "Darwin"])
@pytest.mark.parametrize("scope", [None, "none", "workspace", "host"])
def test_local_defaults_preserve_existing_policies(
self, temp_config_path, system, scope
):
defaults = get_local_permission_defaults(system)
existing = {"member": {"filesystem_scope": scope}} if scope else {}
if scope:
Path(temp_config_path).write_text(
json.dumps(
{"provider_settings": {"computer_use_local_permissions": existing}}
)
)
config = AstrBotConfig(
temp_config_path,
default_config={
"provider_settings": {"computer_use_local_permissions": defaults}
},
)
expected = {
role: {**policy, **existing.get(role, {})}
for role, policy in defaults.items()
}
assert config["provider_settings"]["computer_use_local_permissions"] == expected
assert (
json.loads(Path(temp_config_path).read_text(encoding="utf-8-sig"))[
"provider_settings"
]["computer_use_local_permissions"]
== expected
)
assert defaults == get_local_permission_defaults(system)
def test_first_deploy_flag(self, temp_config_path, minimal_default_config):
"""Test first_deploy flag is set for new config."""
+14 -3
View File
@@ -47,20 +47,31 @@ async def test_profile_mutations_await_config_manager() -> None:
with pytest.raises(ValueError, match="Local permission member:") as exc:
await service.create_profile(
"Invalid", {"provider_settings": {"computer_use_runtime": "local"}}
"Invalid",
{
"provider_settings": {
"computer_use_runtime": "local",
"computer_use_local_permissions": {
role: {"filesystem_scope": "workspace"}
for role in ("member", "admin")
},
}
},
)
assert "Local permission admin:" in str(exc.value)
config_manager.create_conf.assert_not_awaited()
lifecycle.reload_pipeline_scheduler.assert_not_awaited()
result = await service.create_profile("Profile", {"timezone": "UTC"})
result = await service.create_profile(
"Profile", {"provider_settings": {"computer_use_runtime": "local"}}
)
await service.rename_profile("profile-id", "Renamed")
await service.delete_profile("profile-id")
assert result == {"conf_id": "profile-id"}
config_manager.create_conf.assert_awaited_once_with(
name="Profile",
config={"timezone": "UTC"},
config={"provider_settings": {"computer_use_runtime": "local"}},
)
lifecycle.reload_pipeline_scheduler.assert_awaited_once_with("profile-id")
config_manager.update_conf_info.assert_awaited_once_with(