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:
@@ -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()
|
||||
Reference in New Issue
Block a user