diff --git a/navi/core/agent.py b/navi/core/agent.py index 2ecca9e..3d8d13e 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -321,9 +321,10 @@ """ import time as _time import uuid as _uuid - from navi.tools.base import current_session_id as _sid_var, current_model as _model_var + from navi.tools.base import current_session_id as _sid_var, current_model as _model_var, current_user_id as _uid_var _prev_sid = _sid_var.get(None) _prev_model = _model_var.get(None) + _prev_uid = _uid_var.get(None) subagent_run_id = f"subagent_{_uuid.uuid4().hex[:12]}" tool_session_id = parent_session_id or subagent_run_id _sid_var.set(tool_session_id) @@ -574,6 +575,7 @@ # Restore parent ContextVar values so background tasks don't inherit stale subagent IDs _sid_var.set(_prev_sid) _model_var.set(_prev_model) + _uid_var.set(_prev_uid) async def run_stream( self, @@ -605,9 +607,10 @@ mem = await self._ctx_builder._memory_msg(user_id=session.user_id) - # Expose session_id and model to tools via ContextVar - from navi.tools.base import current_session_id as _sid_var, current_model as _model_var + # Expose session_id, model and user_id to tools via ContextVar + from navi.tools.base import current_session_id as _sid_var, current_model as _model_var, current_user_id as _uid_var _sid_token = _sid_var.set(session_id) + _uid_token = _uid_var.set(session.user_id) _model_var.set(profile.model) # Pre-turn compression: if the last turn filled the context past the diff --git a/navi/tools/base.py b/navi/tools/base.py index f86bdd8..177d42b 100644 --- a/navi/tools/base.py +++ b/navi/tools/base.py @@ -31,6 +31,10 @@ # to tools that need to make their own LLM calls (e.g. AIHelper-powered tools). current_model: ContextVar[list[str] | str | None] = ContextVar("current_model", default=None) +# Set by run_stream() to expose the current session's user_id to tools that need +# per-user sandboxing (e.g. filesystem tool). +current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None) + @dataclass class ToolResult: diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py index eff1a92..7bc08bb 100644 --- a/navi/tools/filesystem.py +++ b/navi/tools/filesystem.py @@ -14,7 +14,7 @@ from navi.config import settings -from .base import Tool, ToolResult +from .base import Tool, ToolResult, current_user_id _READ_WARN_BYTES = 100_000 # 100 KB — add size warning in output _READ_HARD_BYTES = 1_000_000 # 1 MB — refuse full read without offset/limit @@ -69,9 +69,38 @@ # ── Path helpers ────────────────────────────────────────────────────────────── def _check_path(path_str: str) -> Path | None: - """Return resolved Path if access is allowed, else None.""" + """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. + """ try: - p = Path(path_str).expanduser().resolve() + p = Path(path_str) + except Exception: + return None + + user_id = current_user_id.get(None) + if user_id: + # 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() + + # Fallback to FS_ALLOWED_PATHS for single-user / legacy mode + try: + p = p.expanduser().resolve() except Exception: return None diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py index aefa258..adab2e3 100644 --- a/navi/tools/share_file.py +++ b/navi/tools/share_file.py @@ -8,7 +8,7 @@ from navi.config import settings from navi.session_files import ensure_session_dir -from .base import Tool, ToolResult, current_session_id +from .base import Tool, ToolResult, current_session_id, current_user_id def _fmt_size(n: int) -> str: @@ -63,17 +63,36 @@ return ToolResult(success=False, output="No active session context.", error="no_session") raw_path = Path(params["path"]).expanduser() - if not raw_path.is_absolute(): - return ToolResult( - success=False, - output=( - f"share_file requires an absolute path, got: {params['path']}\n" - "Resolve it first with filesystem info or terminal realpath, then call share_file again." - ), - error="path_not_absolute", - ) + user_id = current_user_id.get(None) - src = raw_path.resolve() + if user_id: + 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() + else: + if not raw_path.is_absolute(): + return ToolResult( + success=False, + output=( + f"share_file requires an absolute path, got: {params['path']}\n" + "Resolve it first with filesystem info or terminal realpath, then call share_file again." + ), + error="path_not_absolute", + ) + src = raw_path.resolve() + if not src.exists(): return ToolResult(success=False, output=f"File not found: {src}", error="not_found") if not src.is_file():