diff --git a/navi/api/routes/messages.py b/navi/api/routes/messages.py index 7933099..76ef0eb 100644 --- a/navi/api/routes/messages.py +++ b/navi/api/routes/messages.py @@ -30,6 +30,12 @@ if session is None: raise HTTPException(status_code=404, detail="Session not found") check_session_access(session, user) + + # Set user context for tool sandboxing + from navi.tools.base import current_user_id as _uid_var, current_user_role as _role_var + _uid_var.set(user.id) + _role_var.set(user.role) + try: reply = await agent.run(session_id, body.content) return {"role": "assistant", "content": reply} diff --git a/navi/api/websocket.py b/navi/api/websocket.py index ed04f25..ee06c44 100644 --- a/navi/api/websocket.py +++ b/navi/api/websocket.py @@ -361,6 +361,15 @@ current_run = run _runs[session_id] = run + # Set user context for tool sandboxing (inherited by the agent task) + from navi.tools.base import current_user_id as _uid_var, current_user_role as _role_var + if user is not None: + _uid_var.set(user.id) + _role_var.set(user.role) + else: + _uid_var.set(None) + _role_var.set("user") + run.task = asyncio.create_task( _run_agent(run, agent, session_id, user_content, raw_images, original_content) ) diff --git a/navi/config.py b/navi/config.py index 9869954..3104ca2 100644 --- a/navi/config.py +++ b/navi/config.py @@ -38,6 +38,15 @@ # Terminal tool: "*" = allow all commands (recommended for local use) # or comma-separated list of allowed executables, e.g. "ls,cat,git" terminal_allowed_commands: str = "*" + # Terminal allowlist for non-admin users (multi-user mode). + # Admin bypasses this restriction. + terminal_user_allowed_commands: str = ( + "ls,cat,git,grep,egrep,fgrep,head,tail,wc,find,locate," + "ps,df,du,free,uptime,whoami,id,mkdir,touch,cp,mv,rm,chmod,chown," + "echo,printf,sort,uniq,awk,sed,tr,cut,date,uname,hostname,which,whereis," + "python,python3,node,npm,npx,pip,pip3,env,export,source," + "make,cmake,gcc,g++,rustc,cargo,javac,java,go,tsc" + ) # SSH tool: path to JSON file with named connections ssh_hosts_file: str = "ssh_hosts.json" @@ -126,5 +135,9 @@ def terminal_allowed_commands_list(self) -> list[str]: return [c.strip() for c in self.terminal_allowed_commands.split(",") if c.strip()] + @property + def terminal_user_allowed_commands_list(self) -> list[str]: + return [c.strip() for c in self.terminal_user_allowed_commands.split(",") if c.strip()] + settings = Settings() diff --git a/navi/core/agent.py b/navi/core/agent.py index 3d8d13e..f475d27 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -235,6 +235,9 @@ from navi.tools.base import current_session_id as _sid_var _sid_var.set(session_id) + # current_user_id and current_user_role are set by the caller + # (websocket_session or messages endpoint) before run()/run_stream() + user_msg = Message(role="user", content=user_message, images=images or None, created_at=datetime.now(timezone.utc)) session.messages.append(user_msg) @@ -242,13 +245,15 @@ await self._sessions.save(session) ctx_injections = await self._ctx_builder._collect_context_injections(profile) + user_id = session.user_id + user_role = current_user_role.get() for iteration in range(profile.max_iterations): log.debug("agent.iteration", session_id=session_id, iteration=iteration) response = await llm.complete( self._ctx_builder.build(session.context, profile, mem, iteration=iteration, max_iterations=profile.max_iterations, extra_system=ctx_injections, - session_id=session_id), + session_id=session_id, user_id=user_id, user_role=user_role), tools=tool_schemas if tools else None, temperature=profile.temperature, model=profile.model, @@ -321,10 +326,16 @@ """ import time as _time import uuid as _uuid - from navi.tools.base import current_session_id as _sid_var, current_model as _model_var, current_user_id as _uid_var + from navi.tools.base import ( + current_session_id as _sid_var, + current_model as _model_var, + current_user_id as _uid_var, + current_user_role as _role_var, + ) _prev_sid = _sid_var.get(None) _prev_model = _model_var.get(None) _prev_uid = _uid_var.get(None) + _prev_role = _role_var.get() 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) @@ -344,6 +355,12 @@ parent_session = await self._sessions.get(parent_session_id) if parent_session: user_id = parent_session.user_id + if user_id is not None: + _uid_var.set(user_id) + _role_var.set(_prev_role or "user") + else: + _uid_var.set(None) + _role_var.set("user") mem = await self._ctx_builder._memory_msg(user_id=user_id) # Build subagent system prompt — completely separate from the parent's system prompt. @@ -576,6 +593,7 @@ _sid_var.set(_prev_sid) _model_var.set(_prev_model) _uid_var.set(_prev_uid) + _role_var.set(_prev_role) async def run_stream( self, @@ -607,10 +625,9 @@ mem = await self._ctx_builder._memory_msg(user_id=session.user_id) - # 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 + # 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 _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 @@ -694,6 +711,8 @@ yield StreamStopped() return + user_id = session.user_id + user_role = current_user_role.get() if settings.context_compression_enabled and iteration > 0: preflight_ctx = self._ctx_builder.build( session.context, @@ -701,6 +720,8 @@ mem, extra_system=ctx_injections, session_id=session_id, + user_id=user_id, + user_role=user_role, ) estimated_tokens = self._estimate_context_tokens(preflight_ctx) if should_compress( @@ -729,7 +750,7 @@ built_ctx = self._ctx_builder.build(session.context, profile, mem, iteration=iteration, max_iterations=profile.max_iterations, extra_system=ctx_injections, - session_id=session_id) + session_id=session_id, user_id=user_id, user_role=user_role) if ( profile.goal_anchoring_enabled diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py index 88a9459..ac441de 100644 --- a/navi/core/context_builder.py +++ b/navi/core/context_builder.py @@ -118,6 +118,38 @@ lines.append("Before final response, update todo for every completed step, including the final one.") return Message(role="system", content="\n".join(lines)) + def _security_policy_msg(self, user_id: str | None, role: str) -> Message | None: + """Build a dynamic security policy system message based on user role.""" + if role == "admin": + return Message( + role="system", + content=( + "[Security policy]\n" + "Role: admin\n" + "Full system access. No restrictions apply.\n" + "You may use any tool, access any path, and execute any command." + ), + ) + if user_id: + allowed = _config.settings.terminal_user_allowed_commands_list + return Message( + role="system", + 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"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" + "If a task requires admin privileges (e.g. system-wide changes, accessing another user's files, " + "installing packages globally), tell the user to contact an admin.\n" + "Always prefer filesystem tool over terminal for file operations." + ), + ) + # Legacy / single-user mode — no policy injected + return None + def build( self, session_context: list[Message], @@ -127,6 +159,8 @@ max_iterations: int | None = None, extra_system: list[Message] | None = None, session_id: str | None = None, + user_id: str | None = None, + user_role: str = "user", ) -> list[Message]: system_prompt = self.build_system_prompt(profile) if session_id: @@ -142,6 +176,12 @@ result: list[Message] = [system_msg] if mem: result.append(mem) + + # Inject security policy for multi-user mode + policy = self._security_policy_msg(user_id, user_role) + if policy: + result.append(policy) + if extra_system: result.extend(extra_system) result.extend(conv) diff --git a/navi/tools/base.py b/navi/tools/base.py index 177d42b..0a2e0d3 100644 --- a/navi/tools/base.py +++ b/navi/tools/base.py @@ -35,6 +35,9 @@ # per-user sandboxing (e.g. filesystem tool). current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None) +# Set by run_stream() alongside current_user_id. Admins bypass sandbox restrictions. +current_user_role: ContextVar[str] = ContextVar("current_user_role", default="user") + @dataclass class ToolResult: diff --git a/navi/tools/code_exec.py b/navi/tools/code_exec.py index 73245c1..205b5f0 100644 --- a/navi/tools/code_exec.py +++ b/navi/tools/code_exec.py @@ -1,15 +1,47 @@ -"""Code execution tool — run Python code in a subprocess sandbox.""" +"""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. +- Admins bypass the sandbox and use the system temp directory. +""" import asyncio import sys import tempfile from pathlib import Path -from .base import Tool, ToolResult +from .base import Tool, ToolResult, current_user_id, current_user_role _TIMEOUT = 30 +def _resolve_working_dir(working_dir: str | None) -> Path: + """Resolve working directory with sandbox enforcement for non-admins.""" + user_id = current_user_id.get(None) + role = 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 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 + + if working_dir: + return Path(working_dir).expanduser().resolve() + return Path(tempfile.gettempdir()) + + class CodeExecTool(Tool): name = "code_exec" description = ( @@ -46,17 +78,28 @@ error="unsupported_language", ) - with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f: - f.write(code) - script_path = f.name + role = current_user_role.get() + user_id = current_user_id.get(None) + + cwd = _resolve_working_dir(None) + + if user_id and role != "admin": + # Write temp file inside the sandbox so file I/O in user code + # is implicitly sandboxed unless they escape via absolute paths. + script_path = cwd / f"_navi_exec_{id(params)}.py" + script_path.write_text(code, encoding="utf-8") + else: + with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f: + f.write(code) + script_path = Path(f.name) try: proc = await asyncio.create_subprocess_exec( sys.executable, - script_path, + str(script_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - cwd=tempfile.gettempdir(), + cwd=str(cwd), ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT) @@ -72,7 +115,7 @@ if stdout: output_parts.append(stdout.decode(errors="replace")) if stderr: - output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}") + output_parts.append(f"[stderr]\n{stderr.decode(errors="replace")}") success = proc.returncode == 0 return ToolResult( @@ -84,4 +127,4 @@ except Exception as e: return ToolResult(success=False, output=f"Execution error: {e}", error=str(e)) finally: - Path(script_path).unlink(missing_ok=True) + script_path.unlink(missing_ok=True) diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py index 7bc08bb..27d79f9 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, current_user_id +from .base import Tool, ToolResult, current_user_id, current_user_role _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 @@ -81,7 +81,10 @@ return None user_id = current_user_id.get(None) - if user_id: + role = current_user_role.get() + + # 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() @@ -98,7 +101,7 @@ # Relative paths are resolved against the sandbox return (sandbox / p).resolve() - # Fallback to FS_ALLOWED_PATHS for single-user / legacy mode + # Fallback to FS_ALLOWED_PATHS for single-user / legacy mode / admin try: p = p.expanduser().resolve() except Exception: diff --git a/navi/tools/share_file.py b/navi/tools/share_file.py index adab2e3..356c403 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, current_user_id +from .base import Tool, ToolResult, current_session_id, current_user_role, current_user_id def _fmt_size(n: int) -> str: @@ -64,8 +64,9 @@ raw_path = Path(params["path"]).expanduser() user_id = current_user_id.get(None) + role = current_user_role.get() - if user_id: + if user_id and role != "admin": sandbox = Path("user_data") / user_id sandbox = sandbox.expanduser().resolve() sandbox.mkdir(parents=True, exist_ok=True) diff --git a/navi/tools/terminal.py b/navi/tools/terminal.py index 7be3ea6..94264c2 100644 --- a/navi/tools/terminal.py +++ b/navi/tools/terminal.py @@ -5,18 +5,83 @@ Set TERMINAL_ALLOWED_COMMANDS to a comma-separated list to restrict to specific 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. +- 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. """ import asyncio +import re import shlex +from pathlib import Path from navi.config import settings -from .base import Tool, ToolResult +from .base import Tool, ToolResult, current_user_id, current_user_role _TIMEOUT = 300 _MAX_OUTPUT_CHARS = 5_000 +# Substrings that make a command line dangerous for non-admin users. +_DANGEROUS_PATTERNS = [ + r"\bcurl\b", + r"\bwget\b", + r"\bnc\b", + r"\bnetcat\b", + r"\bssh\b", + r"\bscp\b", + r"\bsftp\b", + r"\bsudo\b", + r"\bsu\b", + r"\bpython\b.*-\bc\b", + r"\bpython3\b.*-\bc\b", + r"\bnode\b.*-\be\b", + r"\beval\b", + r"\bexec\b", + r";\s*rm\s+-rf\s+/", + r">\s+/dev/", + r"<\s*/dev/", +] + +_DANGEROUS_RE = re.compile("|".join(_DANGEROUS_PATTERNS), re.IGNORECASE) + + +def _check_dangerous(command: str) -> str | None: + """Return block reason if the command contains dangerous patterns, else None.""" + if _DANGEROUS_RE.search(command): + return "Command contains disallowed patterns for non-admin users." + return None + + +def _resolve_working_dir(working_dir: str | None) -> Path | None: + """Resolve working directory with sandbox enforcement for non-admins.""" + user_id = current_user_id.get(None) + role = 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 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 + + if working_dir: + return Path(working_dir).expanduser().resolve() + return None + class TerminalTool(Tool): name = "terminal" @@ -53,12 +118,35 @@ if not command: return ToolResult(success=False, output="Empty command.", error="empty_command") - unrestricted = settings.terminal_allowed_commands.strip() == "*" + role = current_user_role.get() + user_id = current_user_id.get(None) - if unrestricted: - return await self._run_shell(command, working_dir, timeout) - else: - return await self._run_restricted(command, working_dir, timeout) + # Admins and single-user / legacy mode: use the existing restriction logic + if not user_id or role == "admin": + unrestricted = settings.terminal_allowed_commands.strip() == "*" + if unrestricted: + return await self._run_shell(command, working_dir, timeout) + else: + return await self._run_restricted(command, working_dir, timeout) + + # Non-admin multi-user mode: sandbox + curated allowlist + dangerous-pattern block + cwd = _resolve_working_dir(working_dir) + if cwd is None and working_dir: + return ToolResult( + success=False, + output="Working directory is outside your sandbox.", + error="sandbox_violation", + ) + + danger = _check_dangerous(command) + if danger: + return ToolResult( + success=False, + output=f"Blocked: {danger}", + error="dangerous_command", + ) + + return await self._run_user_restricted(command, cwd, timeout) async def _run_shell(self, command: str, cwd: str | None, timeout: int) -> ToolResult: """Run via shell — supports pipes, redirects, etc.""" @@ -79,7 +167,7 @@ if stdout: output_parts.append(stdout.decode(errors="replace")) if stderr: - output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}") + output_parts.append(f"[stderr]\n{stderr.decode(errors="replace")}") combined = "\n".join(output_parts) or "(no output)" if len(combined) > _MAX_OUTPUT_CHARS: @@ -131,7 +219,7 @@ if stdout: output_parts.append(stdout.decode(errors="replace")) if stderr: - output_parts.append(f"[stderr]\n{stderr.decode(errors='replace')}") + output_parts.append(f"[stderr]\n{stderr.decode(errors="replace")}") combined = "\n".join(output_parts) or "(no output)" if len(combined) > _MAX_OUTPUT_CHARS: @@ -148,3 +236,25 @@ return ToolResult(success=False, output=f"Command not found: {tokens[0]}", error="not_found") except Exception as e: return ToolResult(success=False, output=f"Execution error: {e}", error=str(e)) + + async def _run_user_restricted(self, command: str, cwd: Path | None, timeout: int) -> ToolResult: + """Run for non-admin users: allowlist + shell features + sandbox cwd.""" + try: + tokens = shlex.split(command) + except ValueError as e: + return ToolResult(success=False, output=f"Invalid command syntax: {e}", error=str(e)) + + if not tokens: + return ToolResult(success=False, output="Empty command.", error="empty_command") + + allowed = settings.terminal_user_allowed_commands_list + if tokens[0] not in allowed: + return ToolResult( + success=False, + output=f"Command '{tokens[0]}' is not in the allowed list for non-admin users. " + f"Allowed: {allowed}.", + error="not_allowed", + ) + + # Shell invocation so pipes/redirects work, but we already validated the base command + return await self._run_shell(command, str(cwd) if cwd else None, timeout)