From 89acea6fe1d591497322536c86754177bca49826 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:43:42 +0800 Subject: [PATCH] fix(webui): allow remote workspace access reduction --- docs/webui.md | 4 ++ nanobot/webui/workspaces.py | 17 +++-- tests/channels/test_websocket_channel.py | 41 ++++++++++++ tests/utils/test_webui_workspaces.py | 85 +++++++++++++++++++++++- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/docs/webui.md b/docs/webui.md index f0b4a37c..76a9159c 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -98,6 +98,10 @@ chat. It does not bypass your gateway, provider, shell sandbox, or operating system configuration; it only selects among the capabilities that are already available to this WebUI session. +Remote WebUI sessions may reduce access for the current workspace. Selecting a +different workspace or enabling Full Access remains limited to local and native +clients. + ## Composer The composer supports plain messages, image attachments, voice input when diff --git a/nanobot/webui/workspaces.py b/nanobot/webui/workspaces.py index 2d462417..6076f6d7 100644 --- a/nanobot/webui/workspaces.py +++ b/nanobot/webui/workspaces.py @@ -27,6 +27,14 @@ _LEGACY_RESTRICTED_DEFAULT_ACCESS_MODE = "restricted" _WEBUI_SCOPE_CHANNEL = "websocket" +def _scope_change_is_non_escalating(current: WorkspaceScope, requested: WorkspaceScope) -> bool: + """Allow a remote request only when it keeps the project and does not add access.""" + return ( + requested.project_path == current.project_path + and (not current.restrict_to_workspace or requested.restrict_to_workspace) + ) + + def webui_workspace_state_path() -> Path: return get_webui_dir() / "workspace-state.json" @@ -214,11 +222,10 @@ class WebUIWorkspaceController: session_key: str | None, controls_available: bool, ) -> WorkspaceScope: + current = self.scope_for_session_key(session_key) if session_key else self.default_scope() raw = envelope.get(WORKSPACE_SCOPE_METADATA_KEY) - if raw is None and session_key: - scope = self.scope_for_session_key(session_key) - elif raw is None: - scope = self.default_scope() + if raw is None: + scope = current else: scope = validate_workspace_scope_payload( raw, @@ -226,7 +233,7 @@ class WebUIWorkspaceController: default_restrict_to_workspace=self._default_restrict_to_workspace, source_channel=_WEBUI_SCOPE_CHANNEL, ) - if not controls_available and scope.metadata() != self.default_scope().metadata(): + if not controls_available and not _scope_change_is_non_escalating(current, scope): raise WorkspaceScopeError("workspace controls are localhost-only", status=403) return scope diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 66e03076..3c379dec 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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" diff --git a/tests/utils/test_webui_workspaces.py b/tests/utils/test_webui_workspaces.py index eedbd086..a6688ae5 100644 --- a/tests/utils/test_webui_workspaces.py +++ b/tests/utils/test_webui_workspaces.py @@ -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()