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:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+16 -1
View File
@@ -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")
+69
View File
@@ -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()
+68
View File
@@ -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