diff --git a/docs/mechanics.md b/docs/mechanics.md index 40ba010..6a592f7 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -165,7 +165,7 @@ | **FilesystemTool** | Read/write/append/edit/list/find/move/copy/delete/exists/mkdir + AI query/smart_edit + grep/diff. Path restrictions via allowlist. | `FS_ALLOWED_PATHS`, `FS_ALLOWED_PATHS_LIST` | `filesystem.py` | ✅ | | **TerminalTool** | Run shell commands. Unrestricted for admins; sandbox + allowlist for users. | `TERMINAL_ALLOWED_COMMANDS`, `TERMINAL_USER_ALLOWED_COMMANDS` | `terminal.py` | ✅ | | **SshExecTool** | SSH exec and SCP file transfer. Connection pool per-session with 20-min TTL. | `SSH_HOSTS_FILE` | `ssh_exec.py` | ✅ | -| **CodeExecTool** | Run Python in subprocess sandbox. Non-admin sandboxed to `user_data//`. | None | `code_exec.py` | ✅ | +| **CodeExecTool** | Run Python in subprocess sandbox. Non-admin confined to `user_data//` or the session directory. | None | `code_exec.py` | ✅ | | **ImageViewTool** | Load image from path/URL → resize to 1024px, JPEG, return base64 for LLM. | None | `image_view.py` | ✅ | | **MemoryTool** | Save/search/forget/list user facts. Dual search: semantic (cosine) + ILIKE fallback. | None | `memory.py` | ✅ | | **TodoTool** | Session-scoped task tracker. Set/view/update/clear. Auto-populated from planning. | None | `todo.py` | ✅ | diff --git a/manuals/code_exec.md b/manuals/code_exec.md index f910f25..8fc1add 100644 --- a/manuals/code_exec.md +++ b/manuals/code_exec.md @@ -33,7 +33,7 @@ ## Limits - Timeout is clamped: `0` becomes 1, `10000` becomes 300, and a non-numeric value falls back to 30. You cannot get more than 300 s in the foreground — use a persistent terminal for genuinely long jobs. -- For a non-admin user the working directory is confined to `user_data//`; an absolute `working_dir` outside it is silently replaced with the sandbox root, and relative paths are resolved inside it. Admin and single-user runs use the client's working directory. +- For a non-admin user the working directory is confined to `user_data//` or the current session directory; an absolute `working_dir` outside both is silently replaced with the sandbox root, and relative paths are resolved inside it. Admin and single-user runs use the client's working directory. - Temp scripts are written inside that directory and deleted afterwards. - On timeout the process is killed and you get `Code execution timed out after Ns` with `error: timeout` — a timeout is not a partial result; nothing from the run is returned. diff --git a/manuals/filesystem.md b/manuals/filesystem.md index c568257..c14649d 100644 --- a/manuals/filesystem.md +++ b/manuals/filesystem.md @@ -76,7 +76,7 @@ ## Access -In multi-user mode every path is resolved inside `user_data//`; a path that escapes it returns `Access denied: ... outside allowed paths`. Do not work around it by writing to `/tmp` or an absolute path — ask for the file to be placed in the workspace instead. In single-user/admin mode the allowed roots are the working directory and the configured paths. +In multi-user mode every path resolves inside one of two roots: `user_data//` and the current session directory (`session_files/{session_id}/`, the path is in the session context). A path outside both returns `Access denied: ... outside allowed paths`. Relative paths always resolve against `user_data//`. Do not work around a refusal by copying files into your sandbox or writing to `/tmp` — if the file you need is elsewhere, ask the user to upload it. In single-user/admin mode the allowed roots are the working directory and the configured paths. ## Common mistakes diff --git a/manuals/share_file.md b/manuals/share_file.md index 714ccaf..3d854af 100644 --- a/manuals/share_file.md +++ b/manuals/share_file.md @@ -55,6 +55,17 @@ **Option 3** — use `filesystem(action="info", path="workspace/game_project.zip")` — the result includes the absolute path. +## Where the source may live + +For a non-admin user the source must be inside one of two directories: + +- the persistent sandbox, `user_data//`; +- the current session directory, `session_files/{session_id}/` — where uploads arrive and where a previous `share_file`/`content_publish` left its file. + +Anything else returns `Access denied` and the message lists both roots. Do **not** copy the file into the sandbox to get around it: if the user uploaded it, it is already in the session directory — share it from there; if it is somewhere else entirely, ask the user to upload it. An admin has no such restriction. + +The session directory is also the copy target, so a file that already lives there is shared in place instead of being duplicated. + ## What to do with the result On success the tool returns: diff --git a/manuals/terminal.md b/manuals/terminal.md index ab905ca..9a0d84a 100644 --- a/manuals/terminal.md +++ b/manuals/terminal.md @@ -103,7 +103,7 @@ | Sequence of commands sharing state | Not yet supported (each `open` is a new shell). Use `run` with `;` or write a script. | ## Safety -- Non-admin users are restricted to a sandbox directory (`user_data//`) and a curated allowlist. +- Non-admin users are restricted to `user_data//` or the current session directory (`working_dir` outside both is refused, not silently redirected) and to a curated allowlist. - Dangerous patterns (curl, wget, ssh, sudo, `python -c`, `node -e`, etc.) are blocked for non-admins. - Admins bypass restrictions when `TERMINAL_ALLOWED_COMMANDS=*`. - Timeout prevents runaway processes (`run` defaults to 20s; background terminals are cleaned up after 30 min of inactivity). diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py index 300c161..98a8a55 100644 --- a/navi/core/context_builder.py +++ b/navi/core/context_builder.py @@ -304,8 +304,9 @@ content=( "[Security policy]\n" f"Role: user (user_id={user_id})\n" - f"Filesystem sandbox: user_data/{user_id}/\n" - "You MUST NOT attempt to access paths outside your sandbox.\n" + f"Filesystem areas: user_data/{user_id}/ and the current session directory " + "(its path is given in the session context above).\n" + "You MUST NOT attempt to access paths outside those two areas.\n" f"Terminal allowed commands: {', '.join(allowed)}\n" "You MUST NOT use terminal for: curl, wget, ssh, scp, sudo, system-wide destructive operations, " "or any command not in the allowlist.\n" diff --git a/navi/tools/_internal/areas.py b/navi/tools/_internal/areas.py new file mode 100644 index 0000000..003f55a --- /dev/null +++ b/navi/tools/_internal/areas.py @@ -0,0 +1,68 @@ +"""File areas a non-admin user may touch. + +Two roots, both belonging to the current user: + +- ``user_data//`` — the persistent sandbox; +- the current session directory — where uploads arrive and where ``share_file`` + and ``content_publish`` hand files back to the user. + +The session directory is the user's own: a session can only be opened by its +owner, and its id reaches the tools from the runtime context, never from tool +arguments. Without this second root ``share_file`` refuses the uploaded file it +was asked to send back, ``filesystem`` cannot even read it, and an agent has to +smuggle the file into its sandbox first — the very bypass the injected security +policy forbids. +""" + +from collections.abc import Iterable, Sequence +from pathlib import Path + +from navi.session_files import session_dir + + +def user_sandbox(user_id: str) -> Path: + """``user_data//``, created if missing.""" + root = (Path("user_data") / user_id).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + +def session_area(session_id: str | None) -> Path | None: + """The current session's file directory, or ``None`` when there is no session.""" + if not session_id: + return None + return session_dir(session_id).expanduser().resolve() + + +def allowed_areas(user_id: str, session_id: str | None = None) -> list[Path]: + """Every root this user may reach; the persistent sandbox always comes first.""" + areas = [user_sandbox(user_id)] + area = session_area(session_id) + if area is not None: + areas.append(area) + return areas + + +def is_within(path: Path, areas: Iterable[Path]) -> bool: + """True when ``path`` resolves inside one of ``areas``.""" + resolved = path.resolve() + for area in areas: + try: + resolved.relative_to(area) + except ValueError: + continue + return True + return False + + +def resolve_in_areas(path: Path, areas: Sequence[Path]) -> Path | None: + """Resolve ``path`` against ``areas``; ``None`` when it escapes them all. + + An absolute path is accepted only when it resolves inside one of the areas. + A relative path resolves against the first area — the persistent sandbox — + so relative writes keep landing in the user's own directory. + """ + if path.is_absolute(): + resolved = path.resolve() + return resolved if is_within(resolved, areas) else None + return (areas[0] / path).resolve() diff --git a/navi/tools/code_exec.py b/navi/tools/code_exec.py index 013e149..6c8493c 100644 --- a/navi/tools/code_exec.py +++ b/navi/tools/code_exec.py @@ -1,8 +1,9 @@ """Code execution tool — run Python code in a subprocess sandbox. Multi-user safety: -- Non-admin users run inside their sandbox directory (user_data//). -- Temp files and working directory are restricted to the sandbox. +- Non-admin users run inside their own directories (user_data// or the + current session directory). +- Temp files and working directory are restricted to those directories. - Admins bypass the sandbox and use the system temp directory. """ @@ -11,10 +12,12 @@ import tempfile from pathlib import Path +from ._internal.areas import allowed_areas, resolve_in_areas from ._internal.base import ( Tool, ToolContext, ToolResult, + current_session_id, current_user_id, current_user_role, current_working_directory, @@ -47,20 +50,11 @@ role = role or current_user_role.get() if user_id and role != "admin": - sandbox = Path("user_data") / user_id - sandbox = sandbox.expanduser().resolve() - sandbox.mkdir(parents=True, exist_ok=True) + areas = allowed_areas(user_id, current_session_id.get(None)) if working_dir: - p = Path(working_dir).expanduser() - if p.is_absolute(): - resolved = p.resolve() - try: - resolved.relative_to(sandbox) - return resolved - except ValueError: - return sandbox - return (sandbox / p).resolve() - return sandbox + resolved = resolve_in_areas(Path(working_dir).expanduser(), areas) + return resolved if resolved is not None else areas[0] + return areas[0] if working_dir: return Path(working_dir).expanduser().resolve() diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py index 404b377..9396e92 100644 --- a/navi/tools/filesystem.py +++ b/navi/tools/filesystem.py @@ -15,10 +15,12 @@ from navi.config import settings +from ._internal.areas import allowed_areas, resolve_in_areas from ._internal.base import ( Tool, ToolContext, ToolResult, + current_session_id, current_user_id, current_user_role, current_working_directory, @@ -105,8 +107,10 @@ """Return resolved Path if access is allowed, else None. When a user_id is active (multi-user mode), all paths are resolved - inside user_data//. This prevents users from accessing each - other's files or random OS directories. + inside user_data// or inside the current session directory. + This prevents users from accessing each other's files or random OS + directories, while still letting a user read what an upload put in the + session and what share_file hands back. """ if not path_str or path_str.strip() == "": return None @@ -120,21 +124,10 @@ # Admins bypass sandbox and use FS_ALLOWED_PATHS directly if user_id and role != "admin": - # Sandbox mode: resolve inside user_data// - sandbox = Path("user_data") / user_id - sandbox = sandbox.expanduser().resolve() - sandbox.mkdir(parents=True, exist_ok=True) - - if p.is_absolute(): - # Absolute paths must still be inside the sandbox - try: - p.resolve().relative_to(sandbox) - return p.resolve() - except ValueError: - return None - else: - # Relative paths are resolved against the sandbox - return (sandbox / p).resolve() + # Sandboxed mode: the user's own sandbox, plus the current session + # directory. Relative paths keep resolving inside the sandbox. + areas = allowed_areas(user_id, current_session_id.get(None)) + return resolve_in_areas(p, areas) # Fallback to FS_ALLOWED_PATHS for single-user / legacy mode / admin try: diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py index e62454a..8ee9658 100644 --- a/navi/tools/share_file.py +++ b/navi/tools/share_file.py @@ -8,6 +8,7 @@ from navi.config import settings from navi.session_files import ensure_session_dir +from ._internal.areas import allowed_areas, resolve_in_areas from ._internal.base import Tool, ToolContext, ToolResult, current_session_id, current_user_role, current_user_id @@ -59,21 +60,20 @@ role = ctx.user_role if ctx else current_user_role.get() if user_id and role != "admin": - sandbox = Path("user_data") / user_id - sandbox = sandbox.expanduser().resolve() - sandbox.mkdir(parents=True, exist_ok=True) - if raw_path.is_absolute(): - src = raw_path.resolve() - try: - src.relative_to(sandbox) - except ValueError: - return ToolResult( - success=False, - output=f"Access denied: path is outside user sandbox.", - error="access_denied", - ) - else: - src = (sandbox / raw_path).resolve() + areas = allowed_areas(user_id, session_id) + src = resolve_in_areas(raw_path, areas) + if src is None: + roots = "\n".join(f" - {a}" for a in areas) + return ToolResult( + success=False, + output=( + f"Access denied: {raw_path} is outside the directories you may " + f"share from.\nAllowed sources:\n{roots}\n" + "Uploads land in the session directory listed above; a file created " + "elsewhere must be moved there (or into your sandbox) before sharing." + ), + error="access_denied", + ) else: if not raw_path.is_absolute(): return ToolResult( diff --git a/navi/tools/terminal.py b/navi/tools/terminal.py index cec1953..84a892b 100644 --- a/navi/tools/terminal.py +++ b/navi/tools/terminal.py @@ -15,8 +15,8 @@ executables only (e.g. "ls,cat,git"). Multi-user safety: -- Non-admin users are restricted to a sandbox directory (user_data//) - and a curated allowlist of safe commands. +- Non-admin users are restricted to their own directories (user_data// + and the current session directory) and a curated allowlist of safe commands. - Dangerous patterns (curl, wget, ssh, sudo, python -c, node -e, etc.) are blocked for non-admins even if the base command is in the allowlist. - Admins bypass all restrictions and use TERMINAL_ALLOWED_COMMANDS directly. @@ -31,10 +31,12 @@ from navi.config import settings +from ._internal.areas import allowed_areas, resolve_in_areas from ._internal.base import ( Tool, ToolContext, ToolResult, + current_session_id, current_user_id, current_user_role, current_working_directory, @@ -87,20 +89,10 @@ role = role or current_user_role.get() if user_id and role != "admin": - sandbox = Path("user_data") / user_id - sandbox = sandbox.expanduser().resolve() - sandbox.mkdir(parents=True, exist_ok=True) + areas = allowed_areas(user_id, current_session_id.get(None)) if working_dir: - p = Path(working_dir).expanduser() - if p.is_absolute(): - resolved = p.resolve() - try: - resolved.relative_to(sandbox) - return resolved - except ValueError: - return None - return (sandbox / p).resolve() - return sandbox + return resolve_in_areas(Path(working_dir).expanduser(), areas) + return areas[0] if working_dir: return Path(working_dir).expanduser().resolve() diff --git a/tests/unit/tools/test_areas.py b/tests/unit/tools/test_areas.py new file mode 100644 index 0000000..9481134 --- /dev/null +++ b/tests/unit/tools/test_areas.py @@ -0,0 +1,76 @@ +"""Unit tests for the shared file-area helpers (navi/tools/_internal/areas.py).""" + +from pathlib import Path + +import pytest + +from navi.config import Settings +from navi.tools._internal.areas import ( + allowed_areas, + is_within, + resolve_in_areas, + session_area, + user_sandbox, +) + + +@pytest.fixture(autouse=True) +def _areas(monkeypatch, tmp_path): + import navi.session_files as _sf_mod + + monkeypatch.setattr(_sf_mod, "settings", Settings(session_files_dir=str(tmp_path / "sessions"))) + monkeypatch.chdir(tmp_path) + return tmp_path + + +class TestAllowedAreas: + def test_sandbox_is_created_and_comes_first(self, tmp_path): + areas = allowed_areas("7", "s1") + assert areas[0] == (tmp_path / "user_data" / "7").resolve() + assert areas[0].is_dir() + + def test_session_area_only_with_a_session(self, tmp_path): + assert session_area(None) is None + assert session_area("") is None + assert session_area("s1") == (tmp_path / "sessions" / "s1").resolve() + assert len(allowed_areas("7")) == 1 + assert allowed_areas("7", "s1")[1] == (tmp_path / "sessions" / "s1").resolve() + + def test_user_sandbox_is_per_user(self, tmp_path): + assert user_sandbox("7") != user_sandbox("8") + + +class TestResolveInAreas: + def test_relative_resolves_into_sandbox(self, tmp_path): + areas = allowed_areas("7", "s1") + assert resolve_in_areas(Path("a.txt"), areas) == (tmp_path / "user_data" / "7" / "a.txt").resolve() + + def test_relative_is_not_redirected_into_the_session_area(self, tmp_path): + areas = allowed_areas("7", "s1") + resolved = resolve_in_areas(Path("a.txt"), areas) + assert not is_within(resolved, [areas[1]]) + + def test_absolute_inside_session_area_allowed(self, tmp_path): + session = tmp_path / "sessions" / "s1" + session.mkdir(parents=True) + areas = allowed_areas("7", "s1") + assert resolve_in_areas(session / "upload.wav", areas) == (session / "upload.wav").resolve() + + def test_absolute_outside_every_area_denied(self, tmp_path): + areas = allowed_areas("7", "s1") + assert resolve_in_areas(tmp_path / "outside.txt", areas) is None + + +class TestIsWithin: + def test_sibling_prefix_is_not_inside(self, tmp_path): + areas = allowed_areas("7") + # user_data/70 is not user_data/7 — containment is per path component. + assert not is_within(tmp_path / "user_data" / "70" / "f.txt", areas) + + def test_dotdot_escape_is_not_inside(self, tmp_path): + areas = allowed_areas("7") + assert not is_within(tmp_path / "user_data" / "7" / ".." / "8", areas) + + def test_the_root_itself_is_inside(self, tmp_path): + areas = allowed_areas("7") + assert is_within(areas[0], areas) diff --git a/tests/unit/tools/test_code_exec.py b/tests/unit/tools/test_code_exec.py index b303fa9..bcb68b6 100644 --- a/tests/unit/tools/test_code_exec.py +++ b/tests/unit/tools/test_code_exec.py @@ -2,7 +2,44 @@ import pytest -from navi.tools.code_exec import CodeExecTool +from navi.tools.code_exec import CodeExecTool, _resolve_working_dir + + +class TestResolveWorkingDirSandbox: + """Non-admin cwd lives in the sandbox or in the current session directory.""" + + @pytest.fixture(autouse=True) + def _areas(self, monkeypatch, tmp_path): + import navi.session_files as _sf_mod + from navi.config import Settings + from navi.tools._internal.base import current_session_id + + monkeypatch.setattr(_sf_mod, "settings", Settings(session_files_dir=str(tmp_path / "sessions"))) + monkeypatch.chdir(tmp_path) + self.root = tmp_path + token = current_session_id.set("sess 1") + yield + current_session_id.reset(token) + + @property + def sandbox(self): + return (self.root / "user_data" / "7").resolve() + + def test_defaults_to_the_sandbox(self): + assert _resolve_working_dir(None, "7", "user") == self.sandbox + + def test_relative_resolves_inside_the_sandbox(self): + assert _resolve_working_dir("scripts", "7", "user") == self.sandbox / "scripts" + + def test_session_dir_is_allowed(self): + session = self.root / "sessions" / "sess 1" + session.mkdir(parents=True) + assert _resolve_working_dir(str(session), "7", "user") == session.resolve() + + def test_path_outside_falls_back_to_the_sandbox(self): + outside = self.root / "elsewhere" + outside.mkdir() + assert _resolve_working_dir(str(outside), "7", "user") == self.sandbox class TestCodeExecTool: diff --git a/tests/unit/tools/test_filesystem.py b/tests/unit/tools/test_filesystem.py index c81ef65..0d28144 100644 --- a/tests/unit/tools/test_filesystem.py +++ b/tests/unit/tools/test_filesystem.py @@ -39,6 +39,59 @@ assert _check_path(str(blocked / "file.txt")) is None +class TestCheckPathSandbox: + """Non-admin paths live in the sandbox or in the current session directory.""" + + @pytest.fixture(autouse=True) + def _areas(self, monkeypatch, tmp_path): + import navi.session_files as _sf_mod + from navi.config import Settings + from navi.tools._internal.base import current_session_id + + monkeypatch.setattr( + _sf_mod, + "settings", + Settings(session_files_dir=str(tmp_path / "sessions"), fs_allowed_paths="*"), + ) + monkeypatch.chdir(tmp_path) + self.root = tmp_path + # The runtime publishes the active session through this ContextVar. + token = current_session_id.set("sess 1") + yield + current_session_id.reset(token) + + def test_absolute_inside_sandbox_allowed(self): + target = self.root / "user_data" / "7" / "notes.txt" + checked = _check_path(str(target), user_id="7", role="user") + assert checked == target.resolve() + + def test_absolute_inside_session_dir_allowed(self): + session = self.root / "sessions" / "sess 1" + session.mkdir(parents=True) + target = session / "upload.csv" + target.write_text("a,b\n") + + checked = _check_path(str(target), user_id="7", role="user") + + assert checked == target.resolve() + + def test_absolute_outside_both_denied(self): + outside = self.root / "elsewhere.txt" + outside.write_text("x") + assert _check_path(str(outside), user_id="7", role="user") is None + + def test_other_session_dir_denied(self): + other = self.root / "sessions" / "sess 2" + other.mkdir(parents=True) + target = other / "leaked.txt" + target.write_text("x") + assert _check_path(str(target), user_id="7", role="user") is None + + def test_relative_still_resolves_into_sandbox(self): + checked = _check_path("notes.txt", user_id="7", role="user") + assert checked == (self.root / "user_data" / "7" / "notes.txt").resolve() + + class TestFilesystemToolBasic: @pytest.fixture(autouse=True) def _allow_all(self, monkeypatch): diff --git a/tests/unit/tools/test_share_file.py b/tests/unit/tools/test_share_file.py index 8dd6422..11ccacc 100644 --- a/tests/unit/tools/test_share_file.py +++ b/tests/unit/tools/test_share_file.py @@ -87,3 +87,79 @@ numbered = tmp_path / "sessions" / "sess 1" / "report_1.txt" assert numbered.read_text() == "new" assert result.metadata["filename"] == "report_1.txt" + + +class TestShareFileNonAdminSources: + """A non-admin may share from the sandbox and from the current session dir. + + The session directory is where uploads land, so a file the user just sent + has to be shareable from there — otherwise the agent must smuggle it into + its sandbox first, which is exactly the bypass the security policy forbids. + """ + + @pytest.fixture + def tool(self, monkeypatch, tmp_path): + async def _to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(share_file_mod.asyncio, "to_thread", _to_thread) + _test_settings = Settings( + session_files_dir=str(tmp_path / "sessions"), + share_file_max_size_mb=1024, + public_url="http://localhost:8000", + ) + monkeypatch.setattr(share_file_mod, "settings", _test_settings) + monkeypatch.setattr(session_files_mod, "settings", _test_settings) + # The sandbox is relative to the process cwd: user_data//. + monkeypatch.chdir(tmp_path) + yield ShareFileTool() + + @staticmethod + def _ctx(session_id="sess 1", user_id="7"): + return ToolContext(session_id=session_id, user_id=user_id, user_role="user") + + async def test_shares_upload_from_session_dir_in_place(self, tool, tmp_path): + session = tmp_path / "sessions" / "sess 1" + session.mkdir(parents=True) + src = session / "Project.wav" + src.write_text("audio") + + result = await tool.execute({"path": str(src)}, ctx=self._ctx()) + + assert result.success + # Copied onto itself, not duplicated into Project_1.wav. + assert result.metadata["filename"] == "Project.wav" + assert sorted(p.name for p in session.iterdir()) == ["Project.wav"] + + async def test_shares_from_sandbox(self, tool, tmp_path): + sandbox = tmp_path / "user_data" / "7" + sandbox.mkdir(parents=True) + src = sandbox / "report.txt" + src.write_text("data") + + result = await tool.execute({"path": str(src)}, ctx=self._ctx()) + + assert result.success + assert (tmp_path / "sessions" / "sess 1" / "report.txt").read_text() == "data" + + async def test_denies_source_outside_both_roots(self, tool, tmp_path): + outside = tmp_path / "elsewhere.txt" + outside.write_text("x") + + result = await tool.execute({"path": str(outside)}, ctx=self._ctx()) + + assert not result.success + assert result.error == "access_denied" + # The message names both roots, so the agent can retry in the right one. + assert str(tmp_path / "user_data" / "7") in result.output + assert str(tmp_path / "sessions" / "sess 1") in result.output + + async def test_relative_path_still_resolves_into_sandbox(self, tool, tmp_path): + sandbox = tmp_path / "user_data" / "7" + sandbox.mkdir(parents=True) + (sandbox / "notes.txt").write_text("n") + + result = await tool.execute({"path": "notes.txt"}, ctx=self._ctx()) + + assert result.success + assert (tmp_path / "sessions" / "sess 1" / "notes.txt").read_text() == "n" diff --git a/tests/unit/tools/test_terminal.py b/tests/unit/tools/test_terminal.py index c916a2e..fbc6a4d 100644 --- a/tests/unit/tools/test_terminal.py +++ b/tests/unit/tools/test_terminal.py @@ -4,9 +4,47 @@ import pytest -from navi.tools.terminal import TerminalTool +from navi.tools.terminal import TerminalTool, _resolve_working_dir from navi.tools._internal.terminal_manager import TerminalManager, _MAX_TERMINALS_PER_SESSION + +class TestResolveWorkingDirSandbox: + """Non-admin cwd lives in the sandbox or in the current session directory.""" + + @pytest.fixture(autouse=True) + def _areas(self, monkeypatch, tmp_path): + import navi.session_files as _sf_mod + from navi.config import Settings + from navi.tools._internal.base import current_session_id + + monkeypatch.setattr(_sf_mod, "settings", Settings(session_files_dir=str(tmp_path / "sessions"))) + monkeypatch.chdir(tmp_path) + self.root = tmp_path + token = current_session_id.set("sess 1") + yield + current_session_id.reset(token) + + @property + def sandbox(self): + return (self.root / "user_data" / "7").resolve() + + def test_defaults_to_the_sandbox(self): + assert _resolve_working_dir(None, "7", "user") == self.sandbox + + def test_relative_resolves_inside_the_sandbox(self): + assert _resolve_working_dir("scripts", "7", "user") == self.sandbox / "scripts" + + def test_session_dir_is_allowed(self): + session = self.root / "sessions" / "sess 1" + session.mkdir(parents=True) + assert _resolve_working_dir(str(session), "7", "user") == session.resolve() + + def test_path_outside_is_refused(self): + outside = self.root / "elsewhere" + outside.mkdir() + assert _resolve_working_dir(str(outside), "7", "user") is None + + class TestTerminalTool: @pytest.fixture def tool(self):