"""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()