fix(webui): allow remote workspace access reduction

This commit is contained in:
Xubin Ren
2026-07-12 22:08:27 +08:00
parent 01e4f3762a
commit 89acea6fe1
4 changed files with 141 additions and 6 deletions
+41
View File
@@ -745,6 +745,47 @@ async def test_webui_set_workspace_scope_rejects_running_chat(bus: MagicMock, tm
}
@pytest.mark.asyncio
async def test_remote_webui_scope_allows_access_reduction(
bus: MagicMock,
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default_workspace = tmp_path / "default"
default_workspace.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("203.0.113.8", 50123)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-remote",
"workspace_scope": {
"project_path": str(default_workspace),
"access_mode": "restricted",
},
},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "session_updated"
assert payload["workspace_scope"]["access_mode"] == "restricted"
saved = sessions.read_session_file("websocket:chat-remote")
assert saved["metadata"]["workspace_scope"] == {
"project_path": str(default_workspace.resolve()),
"access_mode": "restricted",
}
@pytest.mark.asyncio
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
default_workspace = tmp_path / "default"
+84 -1
View File
@@ -1,6 +1,8 @@
import json
from nanobot.security.workspace_access import default_workspace_scope
import pytest
from nanobot.security.workspace_access import WorkspaceScopeError, default_workspace_scope
from nanobot.session.manager import SessionManager
from nanobot.webui.workspaces import (
WebUIWorkspaceController,
@@ -181,3 +183,84 @@ def test_scope_for_session_key_reads_metadata_without_full_history(
assert scope.project_path == project.resolve()
assert scope.access_mode == "full"
def test_remote_existing_chat_can_reduce_its_workspace_access(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
project = tmp_path / "project"
default.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
controller = WebUIWorkspaceController(
session_manager=sessions,
default_workspace=default,
default_restrict_to_workspace=True,
)
controller.persist_scope(
"remote-chat",
default_workspace_scope(project, restrict_to_workspace=False),
)
scope = controller.scope_for_set_request(
{
"workspace_scope": {
"project_path": str(project),
"access_mode": "restricted",
}
},
chat_id="remote-chat",
chat_running=False,
controls_available=False,
)
assert scope.project_path == project.resolve()
assert scope.access_mode == "restricted"
@pytest.mark.parametrize(
("default_restricted", "project_name", "access_mode", "allowed"),
[
(False, "default", "restricted", True),
(True, "default", "full", False),
(False, "other", "restricted", False),
],
)
def test_remote_new_chat_only_allows_non_escalating_scope_change(
tmp_path,
monkeypatch,
default_restricted: bool,
project_name: str,
access_mode: str,
allowed: bool,
) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
other = tmp_path / "other"
default.mkdir()
other.mkdir()
controller = WebUIWorkspaceController(
session_manager=None,
default_workspace=default,
default_restrict_to_workspace=default_restricted,
)
requested_path = tmp_path / project_name
def resolve():
return controller.scope_for_new_chat(
{
"workspace_scope": {
"project_path": str(requested_path),
"access_mode": access_mode,
}
},
controls_available=False,
)
if allowed:
scope = resolve()
assert scope.project_path == requested_path.resolve()
assert scope.access_mode == access_mode
else:
with pytest.raises(WorkspaceScopeError, match="workspace controls are localhost-only"):
resolve()