fix(webui): allow remote workspace access reduction
This commit is contained in:
@@ -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
|
system configuration; it only selects among the capabilities that are already
|
||||||
available to this WebUI session.
|
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
|
## Composer
|
||||||
|
|
||||||
The composer supports plain messages, image attachments, voice input when
|
The composer supports plain messages, image attachments, voice input when
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ _LEGACY_RESTRICTED_DEFAULT_ACCESS_MODE = "restricted"
|
|||||||
_WEBUI_SCOPE_CHANNEL = "websocket"
|
_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:
|
def webui_workspace_state_path() -> Path:
|
||||||
return get_webui_dir() / "workspace-state.json"
|
return get_webui_dir() / "workspace-state.json"
|
||||||
|
|
||||||
@@ -214,11 +222,10 @@ class WebUIWorkspaceController:
|
|||||||
session_key: str | None,
|
session_key: str | None,
|
||||||
controls_available: bool,
|
controls_available: bool,
|
||||||
) -> WorkspaceScope:
|
) -> WorkspaceScope:
|
||||||
|
current = self.scope_for_session_key(session_key) if session_key else self.default_scope()
|
||||||
raw = envelope.get(WORKSPACE_SCOPE_METADATA_KEY)
|
raw = envelope.get(WORKSPACE_SCOPE_METADATA_KEY)
|
||||||
if raw is None and session_key:
|
if raw is None:
|
||||||
scope = self.scope_for_session_key(session_key)
|
scope = current
|
||||||
elif raw is None:
|
|
||||||
scope = self.default_scope()
|
|
||||||
else:
|
else:
|
||||||
scope = validate_workspace_scope_payload(
|
scope = validate_workspace_scope_payload(
|
||||||
raw,
|
raw,
|
||||||
@@ -226,7 +233,7 @@ class WebUIWorkspaceController:
|
|||||||
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
||||||
source_channel=_WEBUI_SCOPE_CHANNEL,
|
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)
|
raise WorkspaceScopeError("workspace controls are localhost-only", status=403)
|
||||||
return scope
|
return scope
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
|
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
|
||||||
default_workspace = tmp_path / "default"
|
default_workspace = tmp_path / "default"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import json
|
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.session.manager import SessionManager
|
||||||
from nanobot.webui.workspaces import (
|
from nanobot.webui.workspaces import (
|
||||||
WebUIWorkspaceController,
|
WebUIWorkspaceController,
|
||||||
@@ -181,3 +183,84 @@ def test_scope_for_session_key_reads_metadata_without_full_history(
|
|||||||
|
|
||||||
assert scope.project_path == project.resolve()
|
assert scope.project_path == project.resolve()
|
||||||
assert scope.access_mode == "full"
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user