Newer
Older
navi-1 / navi / tools / _internal / areas.py
"""File areas a non-admin user may touch.

Two roots, both belonging to the current user:

- ``user_data/<user_id>/`` — 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/<user_id>/``, 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()