feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -49,7 +49,7 @@ def test_rejects_missing_domain():
|
||||
])
|
||||
def test_blocks_private_ipv4(ip: str, label: str):
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", [ip])):
|
||||
ok, err = validate_url_target(f"http://evil.com/path")
|
||||
ok, err = validate_url_target("http://evil.com/path")
|
||||
assert not ok, f"Should block {label} ({ip})"
|
||||
assert "private" in err.lower() or "blocked" in err.lower()
|
||||
|
||||
@@ -92,6 +92,21 @@ def test_detects_wget_localhost():
|
||||
assert contains_internal_url("wget http://localhost:8080/secret")
|
||||
|
||||
|
||||
def test_loopback_exception_allows_literal_localhost_only():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("localhost", ["127.0.0.1"])):
|
||||
assert not contains_internal_url("curl http://localhost:8765/", allow_loopback=True)
|
||||
|
||||
|
||||
def test_loopback_exception_rejects_public_name_resolving_to_loopback():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["127.0.0.1"])):
|
||||
assert contains_internal_url("curl http://example.com:8765/", allow_loopback=True)
|
||||
|
||||
|
||||
def test_loopback_exception_rejects_metadata():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("169.254.169.254", ["169.254.169.254"])):
|
||||
assert contains_internal_url("curl http://169.254.169.254/latest/meta-data/", allow_loopback=True)
|
||||
|
||||
|
||||
def test_allows_normal_curl():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])):
|
||||
assert not contains_internal_url("curl https://example.com/api/data")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.security.workspace_policy import (
|
||||
WorkspaceBoundaryError,
|
||||
is_path_within,
|
||||
resolve_allowed_path,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_allowed_path_accepts_workspace_relative_path(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
target = workspace / "src" / "main.py"
|
||||
target.parent.mkdir()
|
||||
target.write_text("print('ok')", encoding="utf-8")
|
||||
|
||||
resolved = resolve_allowed_path("src/main.py", workspace=workspace, allowed_root=workspace)
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_allowed_path_blocks_parent_traversal(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
|
||||
with pytest.raises(WorkspaceBoundaryError, match="outside allowed directory"):
|
||||
resolve_allowed_path("../secret.txt", workspace=workspace, allowed_root=workspace)
|
||||
|
||||
|
||||
def test_resolve_allowed_path_blocks_symlink_escape(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
secret = outside / "secret.txt"
|
||||
secret.write_text("secret", encoding="utf-8")
|
||||
link = workspace / "linked-secret.txt"
|
||||
try:
|
||||
link.symlink_to(secret)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink creation is unavailable: {exc}")
|
||||
|
||||
assert not is_path_within(link, workspace)
|
||||
with pytest.raises(WorkspaceBoundaryError):
|
||||
resolve_allowed_path("linked-secret.txt", workspace=workspace, allowed_root=workspace)
|
||||
|
||||
|
||||
def test_resolve_allowed_path_allows_extra_root(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
image = media / "image.png"
|
||||
image.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
resolved = resolve_allowed_path(
|
||||
image,
|
||||
workspace=workspace,
|
||||
allowed_root=workspace,
|
||||
extra_allowed_roots=[media],
|
||||
)
|
||||
|
||||
assert resolved == image.resolve()
|
||||
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||
|
||||
|
||||
def test_workspace_sandbox_disabled(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=False,
|
||||
workspace=tmp_path,
|
||||
environ={},
|
||||
)
|
||||
|
||||
assert status.level == "off"
|
||||
assert status.enforced is False
|
||||
assert status.provider == "none"
|
||||
assert status.as_dict()["workspace_root"] == str(tmp_path.resolve())
|
||||
|
||||
|
||||
def test_workspace_sandbox_application_guard(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={},
|
||||
)
|
||||
|
||||
assert status.level == "application"
|
||||
assert status.enforced is False
|
||||
assert status.provider == "none"
|
||||
assert "application-level" in status.summary
|
||||
|
||||
|
||||
def test_workspace_sandbox_system_provider_from_compact_env(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={"NANOBOT_SANDBOX_ENFORCED": "macos_app_sandbox"},
|
||||
)
|
||||
|
||||
assert status.level == "system"
|
||||
assert status.enforced is True
|
||||
assert status.provider == "macos_app_sandbox"
|
||||
assert status.provider_label == "macOS App Sandbox"
|
||||
|
||||
|
||||
def test_workspace_sandbox_system_provider_from_boolean_env(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={
|
||||
"NANOBOT_WORKSPACE_SANDBOX_ENFORCED": "true",
|
||||
"NANOBOT_WORKSPACE_SANDBOX_PROVIDER": "macOS App Sandbox",
|
||||
},
|
||||
)
|
||||
|
||||
assert status.level == "system"
|
||||
assert status.enforced is True
|
||||
assert status.provider == "macos_app_sandbox"
|
||||
|
||||
|
||||
def test_workspace_sandbox_false_env_does_not_enforce(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={"NANOBOT_WORKSPACE_SANDBOX_ENFORCED": "false"},
|
||||
)
|
||||
|
||||
assert status.level == "application"
|
||||
assert status.enforced is False
|
||||
Reference in New Issue
Block a user