refactor: unify workspace restriction for file tools, remove redundant checks, fix SECURITY.md

This commit is contained in:
Re-bin 2026-02-06 09:16:20 +00:00
parent 8a23d541e2
commit c5191eed1a
8 changed files with 49 additions and 97 deletions

2
.gitignore vendored
View File

@ -13,5 +13,7 @@ docs/
*.pyz *.pyz
*.pywz *.pywz
*.pyzz *.pyzz
.venv/
__pycache__/
poetry.lock poetry.lock
.pytest_cache/ .pytest_cache/

View File

@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json
``` ```
**Security Notes:** **Security Notes:**
- Empty `allowFrom` list will **BLOCK ALL** users (fail-closed by design) - Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use)
- Get your Telegram user ID from `@userinfobot` - Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp - Use full phone numbers with country code for WhatsApp
- Review access logs regularly for unauthorized access attempts - Review access logs regularly for unauthorized access attempts
@ -120,7 +120,7 @@ npm audit fix
``` ```
**Important Notes:** **Important Notes:**
- We've updated `litellm` to `>=1.61.15` to fix critical vulnerabilities - Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability - We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
- Run `pip-audit` or `npm audit` regularly - Run `pip-audit` or `npm audit` regularly
- Subscribe to security advisories for nanobot and its dependencies - Subscribe to security advisories for nanobot and its dependencies
@ -214,7 +214,7 @@ If you suspect a security breach:
✅ **Authentication** ✅ **Authentication**
- Allow-list based access control - Allow-list based access control
- Failed authentication attempt logging - Failed authentication attempt logging
- Fail-closed by default (deny if no allowFrom configured) - Open by default (configure allowFrom for production use)
✅ **Resource Protection** ✅ **Resource Protection**
- Command execution timeouts (60s default) - Command execution timeouts (60s default)

View File

@ -73,11 +73,12 @@ class AgentLoop:
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
"""Register the default set of tools.""" """Register the default set of tools."""
# File tools # File tools (restrict to workspace if configured)
self.tools.register(ReadFileTool()) allowed_dir = self.workspace if self.exec_config.restrict_to_workspace else None
self.tools.register(WriteFileTool()) self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
self.tools.register(EditFileTool()) self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
self.tools.register(ListDirTool()) self.tools.register(EditFileTool(allowed_dir=allowed_dir))
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
# Shell tool # Shell tool
self.tools.register(ExecTool( self.tools.register(ExecTool(

View File

@ -96,9 +96,10 @@ class SubagentManager:
try: try:
# Build subagent tools (no message tool, no spawn tool) # Build subagent tools (no message tool, no spawn tool)
tools = ToolRegistry() tools = ToolRegistry()
tools.register(ReadFileTool()) allowed_dir = self.workspace if self.exec_config.restrict_to_workspace else None
tools.register(WriteFileTool()) tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool()) tools.register(WriteFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool( tools.register(ExecTool(
working_dir=str(self.workspace), working_dir=str(self.workspace),
timeout=self.exec_config.timeout, timeout=self.exec_config.timeout,

View File

@ -6,37 +6,20 @@ from typing import Any
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
def _validate_path(path: str, base_dir: Path | None = None) -> tuple[bool, Path | str]: def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
""" """Resolve path and optionally enforce directory restriction."""
Validate path to prevent directory traversal attacks. resolved = Path(path).expanduser().resolve()
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())):
Args: raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
path: The path to validate return resolved
base_dir: Optional base directory to restrict operations to
Returns:
Tuple of (is_valid, resolved_path_or_error_message)
"""
try:
file_path = Path(path).expanduser().resolve()
# If base_dir is specified, ensure the path is within it
if base_dir is not None:
base_resolved = base_dir.resolve()
try:
# Check if file_path is relative to base_dir
file_path.relative_to(base_resolved)
except ValueError:
return False, f"Error: Path {path} is outside allowed directory"
return True, file_path
except Exception as e:
return False, f"Error: Invalid path: {str(e)}"
class ReadFileTool(Tool): class ReadFileTool(Tool):
"""Tool to read file contents.""" """Tool to read file contents."""
def __init__(self, allowed_dir: Path | None = None):
self._allowed_dir = allowed_dir
@property @property
def name(self) -> str: def name(self) -> str:
return "read_file" return "read_file"
@ -60,11 +43,7 @@ class ReadFileTool(Tool):
async def execute(self, path: str, **kwargs: Any) -> str: async def execute(self, path: str, **kwargs: Any) -> str:
try: try:
is_valid, result = _validate_path(path) file_path = _resolve_path(path, self._allowed_dir)
if not is_valid:
return str(result)
file_path = result
if not file_path.exists(): if not file_path.exists():
return f"Error: File not found: {path}" return f"Error: File not found: {path}"
if not file_path.is_file(): if not file_path.is_file():
@ -72,8 +51,8 @@ class ReadFileTool(Tool):
content = file_path.read_text(encoding="utf-8") content = file_path.read_text(encoding="utf-8")
return content return content
except PermissionError: except PermissionError as e:
return f"Error: Permission denied: {path}" return f"Error: {e}"
except Exception as e: except Exception as e:
return f"Error reading file: {str(e)}" return f"Error reading file: {str(e)}"
@ -81,6 +60,9 @@ class ReadFileTool(Tool):
class WriteFileTool(Tool): class WriteFileTool(Tool):
"""Tool to write content to a file.""" """Tool to write content to a file."""
def __init__(self, allowed_dir: Path | None = None):
self._allowed_dir = allowed_dir
@property @property
def name(self) -> str: def name(self) -> str:
return "write_file" return "write_file"
@ -108,16 +90,12 @@ class WriteFileTool(Tool):
async def execute(self, path: str, content: str, **kwargs: Any) -> str: async def execute(self, path: str, content: str, **kwargs: Any) -> str:
try: try:
is_valid, result = _validate_path(path) file_path = _resolve_path(path, self._allowed_dir)
if not is_valid:
return str(result)
file_path = result
file_path.parent.mkdir(parents=True, exist_ok=True) file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8") file_path.write_text(content, encoding="utf-8")
return f"Successfully wrote {len(content)} bytes to {path}" return f"Successfully wrote {len(content)} bytes to {path}"
except PermissionError: except PermissionError as e:
return f"Error: Permission denied: {path}" return f"Error: {e}"
except Exception as e: except Exception as e:
return f"Error writing file: {str(e)}" return f"Error writing file: {str(e)}"
@ -125,6 +103,9 @@ class WriteFileTool(Tool):
class EditFileTool(Tool): class EditFileTool(Tool):
"""Tool to edit a file by replacing text.""" """Tool to edit a file by replacing text."""
def __init__(self, allowed_dir: Path | None = None):
self._allowed_dir = allowed_dir
@property @property
def name(self) -> str: def name(self) -> str:
return "edit_file" return "edit_file"
@ -156,11 +137,7 @@ class EditFileTool(Tool):
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
try: try:
is_valid, result = _validate_path(path) file_path = _resolve_path(path, self._allowed_dir)
if not is_valid:
return str(result)
file_path = result
if not file_path.exists(): if not file_path.exists():
return f"Error: File not found: {path}" return f"Error: File not found: {path}"
@ -178,8 +155,8 @@ class EditFileTool(Tool):
file_path.write_text(new_content, encoding="utf-8") file_path.write_text(new_content, encoding="utf-8")
return f"Successfully edited {path}" return f"Successfully edited {path}"
except PermissionError: except PermissionError as e:
return f"Error: Permission denied: {path}" return f"Error: {e}"
except Exception as e: except Exception as e:
return f"Error editing file: {str(e)}" return f"Error editing file: {str(e)}"
@ -187,6 +164,9 @@ class EditFileTool(Tool):
class ListDirTool(Tool): class ListDirTool(Tool):
"""Tool to list directory contents.""" """Tool to list directory contents."""
def __init__(self, allowed_dir: Path | None = None):
self._allowed_dir = allowed_dir
@property @property
def name(self) -> str: def name(self) -> str:
return "list_dir" return "list_dir"
@ -210,11 +190,7 @@ class ListDirTool(Tool):
async def execute(self, path: str, **kwargs: Any) -> str: async def execute(self, path: str, **kwargs: Any) -> str:
try: try:
is_valid, result = _validate_path(path) dir_path = _resolve_path(path, self._allowed_dir)
if not is_valid:
return str(result)
dir_path = result
if not dir_path.exists(): if not dir_path.exists():
return f"Error: Directory not found: {path}" return f"Error: Directory not found: {path}"
if not dir_path.is_dir(): if not dir_path.is_dir():
@ -229,7 +205,7 @@ class ListDirTool(Tool):
return f"Directory {path} is empty" return f"Directory {path} is empty"
return "\n".join(items) return "\n".join(items)
except PermissionError: except PermissionError as e:
return f"Error: Permission denied: {path}" return f"Error: {e}"
except Exception as e: except Exception as e:
return f"Error listing directory: {str(e)}" return f"Error listing directory: {str(e)}"

View File

@ -3,34 +3,12 @@
import asyncio import asyncio
import os import os
import re import re
from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
# List of potentially dangerous command patterns
DANGEROUS_PATTERNS = [
r'rm\s+-rf\s+/(?:\s|$)', # rm -rf / (at root, followed by space or end)
r':\(\)\{\s*:\|:&\s*\};:', # fork bomb
r'mkfs\.', # format filesystem
r'dd\s+if=.*\s+of=/dev/(sd|hd)', # overwrite disk
r'>\s*/dev/(sd|hd)', # write to raw disk device
]
def validate_command_safety(command: str) -> tuple[bool, str | None]:
"""
Check if a command contains dangerous patterns.
Returns:
Tuple of (is_dangerous, warning_message)
"""
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return True, f"Warning: Command contains potentially dangerous pattern: {pattern}"
return False, None
class ExecTool(Tool): class ExecTool(Tool):
"""Tool to execute shell commands.""" """Tool to execute shell commands."""
@ -83,11 +61,6 @@ class ExecTool(Tool):
} }
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str: async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
# Check for dangerous command patterns
is_dangerous, warning = validate_command_safety(command)
if is_dangerous:
return f"Error: Refusing to execute dangerous command. {warning}"
cwd = working_dir or self.working_dir or os.getcwd() cwd = working_dir or self.working_dir or os.getcwd()
guard_error = self._guard_command(command, cwd) guard_error = self._guard_command(command, cwd)
if guard_error: if guard_error:

View File

@ -70,10 +70,9 @@ class BaseChannel(ABC):
""" """
allow_list = getattr(self.config, "allow_from", []) allow_list = getattr(self.config, "allow_from", [])
# Fail-closed: if no allow list is configured, deny access # If no allow list, allow everyone
# Users must explicitly configure allowed senders
if not allow_list: if not allow_list:
return False return True
sender_str = str(sender_id) sender_str = str(sender_id)
if sender_str in allow_list: if sender_str in allow_list:

View File

@ -18,7 +18,7 @@ classifiers = [
dependencies = [ dependencies = [
"typer>=0.9.0", "typer>=0.9.0",
"litellm>=1.61.15", "litellm>=1.0.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"pydantic-settings>=2.0.0", "pydantic-settings>=2.0.0",
"websockets>=12.0", "websockets>=12.0",