Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion openhands-tools/openhands/tools/file_editor/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ def _has_meaningful_diff(self) -> bool:

3. REPLACEMENT: The `new_str` parameter should contain the edited lines that replace the `old_str`. Both strings must be different.

4. BASE PATH RESTRICTION: All file operations are restricted to a base directory for security. Attempts to access files outside this directory will be rejected.

Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.
""" # noqa: E501

Expand All @@ -195,19 +197,26 @@ class FileEditorTool(ToolDefinition[FileEditorAction, FileEditorObservation]):
def create(
cls,
conv_state: "ConversationState",
base_path: str | None = None,
) -> Sequence["FileEditorTool"]:
"""Initialize FileEditorTool with a FileEditorExecutor.

Args:
conv_state: Conversation state to get working directory from.
If provided, workspace_root will be taken from
conv_state.workspace
base_path: Optional base directory that restricts all file operations.
When set, all file paths must be within this directory.
If None, defaults to workspace.working_dir for security.
"""
# Import here to avoid circular imports
from openhands.tools.file_editor.impl import FileEditorExecutor

# Initialize the executor
executor = FileEditorExecutor(workspace_root=conv_state.workspace.working_dir)
executor = FileEditorExecutor(
workspace_root=conv_state.workspace.working_dir,
base_path=base_path or conv_state.workspace.working_dir,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, base_path will always be set, is that right? Even though it's defined as an optional

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're right. In this specific code path, base_path will always have
a value (either the provided one or workspace.working_dir as fallback).

However, I kept base_path as optional in FileEditorTool's signature because:

  1. Other callers might not always want to enforce a base_path restriction
  2. The feature is opt-in (base_path=None means no restriction)
  3. Backwards compatibility - existing code doesn't need to change

The fallback here ensures a sensible default for this specific use case
while keeping the tool flexible for other scenarios.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, you're right we need to do this, to implement the restriction, but I do think you're right it should be optional too, though. The use case I have in mind is with the OpenHands-CLI, and I think it should be possible for the CLI user to enable or disable this.

This bit of code creates the executor... which I think all callers need? It's not clear to me if we have enough flexibility for the CLI to just say e.g. "--always-approve means yolo, we'll disable path enforcement"? Please correct me if I'm wrong! It's just a tiny question, nothing else, I'd like to understand this.

)

# Build the tool description with conditional image viewing support
# Split TOOL_DESCRIPTION to insert image viewing line after the second bullet
Expand Down
35 changes: 34 additions & 1 deletion openhands-tools/openhands/tools/file_editor/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ class FileEditor:
_max_file_size: int
_encoding_manager: EncodingManager
_cwd: str
_base_path: Path | None

def __init__(
self,
workspace_root: str | None = None,
base_path: str | None = None,
max_file_size_mb: int | None = None,
):
"""Initialize the editor.
Expand All @@ -74,6 +76,9 @@ def __init__(
workspace_root: Root directory that serves as the current working
directory for relative path suggestions. Must be an absolute path.
If None, no path suggestions will be provided for relative paths.
base_path: Base directory that restricts all file operations. When set,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small question, is there a reason why this isn't workspace_root?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, even I had the same hunch i.e. merging both of these parameters together...

but the original issue demanded a separate parameter and since this is my first contribution, decided to follow the proposed idea.

Let me know if this needs to change though...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I think the issue is written with the help of the agent too, so who knows, we can take it as starting point, but change it freely if it makes sense.

I think maybe workspace root makes sense

all file paths must be within this directory. Must be an absolute path.
If None, no path restrictions are enforced.
"""
self._history_manager = FileHistoryManager(max_history_per_file=10)
self._max_file_size = (
Expand All @@ -83,6 +88,17 @@ def __init__(
# Initialize encoding manager
self._encoding_manager = EncodingManager()

# Set base_path for security enforcement
if base_path is not None:
base_path_obj = Path(base_path)
# Ensure base_path is an absolute path
if not base_path_obj.is_absolute():
base_path_obj = base_path_obj.resolve()
self._base_path = base_path_obj
logger.info(f"FileEditor base path restriction enabled: {self._base_path}")
else:
self._base_path = None

# Set cwd (current working directory) if workspace_root is provided
if workspace_root is not None:
workspace_path = Path(workspace_root)
Expand Down Expand Up @@ -551,7 +567,8 @@ def validate_path(self, command: CommandLiteral, path: Path) -> None:

Validates:
1. Path is absolute
2. Path and command are compatible
2. Path is within base_path (if set)
3. Path and command are compatible
"""
# Check if its an absolute path
if not path.is_absolute():
Expand All @@ -571,6 +588,22 @@ def validate_path(self, command: CommandLiteral, path: Path) -> None:
suggestion_message,
)

# Check if path is within base_path (if set)
if self._base_path is not None:
# Resolve the path to handle symlinks and relative components
resolved_path = path.resolve(strict=False)

# Check if resolved path is within base_path
try:
resolved_path.relative_to(self._base_path)
except ValueError:
raise EditorToolParameterInvalidError(
"path",
str(path),
f"Path is outside the allowed base path. All file operations must "
f"be within: {self._base_path}",
)

# Check if path and command are compatible
if command == "create" and path.exists():
raise EditorToolParameterInvalidError(
Expand Down
5 changes: 4 additions & 1 deletion openhands-tools/openhands/tools/file_editor/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ class FileEditorExecutor(ToolExecutor):
def __init__(
self,
workspace_root: str | None = None,
base_path: str | None = None,
allowed_edits_files: list[str] | None = None,
):
self.editor: FileEditor = FileEditor(workspace_root=workspace_root)
self.editor: FileEditor = FileEditor(
workspace_root=workspace_root, base_path=base_path
)
self.allowed_edits_files: set[Path] | None = (
{Path(f).resolve() for f in allowed_edits_files}
if allowed_edits_files
Expand Down
Loading
Loading