From 468beb8fb1519db3357bb2ffca5b132568b41b81 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Fri, 18 Apr 2025 20:33:46 +0200 Subject: [PATCH] Add read-only mode with EditingTool marker class (entire change performed by Serena) --- README.md | 6 ++++- docs/read_only_mode.md | 46 ++++++++++++++++++++++++++++++++++ myproject.template.yml | 6 ++++- src/serena/agent.py | 52 ++++++++++++++++++++++++++++++++------- src/serena/editingtool.py | 20 +++++++++++++++ test/test_edit_marker.py | 44 +++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 docs/read_only_mode.md create mode 100644 src/serena/editingtool.py create mode 100644 test/test_edit_marker.py diff --git a/README.md b/README.md index 378afbe..99b5f87 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,11 @@ without modifying the codebase, you can consider disabling the editing tools in * `insert_at_line` * `insert_before_symbol` * `replace_symbol_body` - * `delete_lines`. + * `delete_lines` + * `execute_shell_command`. +Alternatively, you can enable read-only mode by setting `read_only: true` in your project configuration file. +This will automatically disable all editing tools and prevent any modifications to your codebase while still +allowing all analysis and exploration capabilities. For more details, see [Read-Only Mode](/docs/read_only_mode.md). In general, be sure to back up your work and use a version control system in order to avoid losing any work. diff --git a/docs/read_only_mode.md b/docs/read_only_mode.md new file mode 100644 index 0000000..422572b --- /dev/null +++ b/docs/read_only_mode.md @@ -0,0 +1,46 @@ +# Read-Only Mode + +Serena supports a read-only mode that prevents any modifications to the codebase while still allowing analysis and exploration. This feature is useful for: + +- Reviewing and analyzing code without the risk of accidental modifications +- Allowing junior developers to explore a codebase with AI assistance but without modification privileges +- Generating documentation or explanations without changing the underlying code +- Creating proof-of-concept designs without implementing them + +## Enabling Read-Only Mode + +To enable read-only mode, set the `read_only` flag to `true` in your project configuration file: + +```yaml +# whether the project is in read-only mode +read_only: true +``` + +## How It Works + +When read-only mode is enabled: + +1. All tools that perform editing operations are automatically disabled +2. Any attempt to use an editing tool will result in an error message +3. All non-editing tools remain fully functional + +The editing tools that get disabled include: +- `create_text_file`: Creating or overwriting files +- `replace_symbol_body`: Replacing code symbol definitions +- `insert_after_symbol`: Inserting code after symbols +- `insert_before_symbol`: Inserting code before symbols +- `delete_lines`: Deleting lines from files +- `replace_lines`: Replacing lines in files +- `insert_at_line`: Inserting content at specific lines +- `execute_shell_command`: Executing shell commands that could potentially modify files +- `insert_at_line`: Inserting content at specific lines + +## Implementation Details + +Serena uses the `EditingTool` marker class to identify tools that can modify code. The base `Tool` class includes a `can_edit()` method that checks if a tool is a subclass of `EditingTool`. + +When a project is configured in read-only mode: +1. During project activation, all editing tools are automatically excluded from the active tools list +2. An additional runtime check prevents any editing tools from being executed, even if they somehow remain in the active tools list + +This ensures that no modifications to the codebase can occur while in read-only mode. diff --git a/myproject.template.yml b/myproject.template.yml index 51191a4..af4f684 100644 --- a/myproject.template.yml +++ b/myproject.template.yml @@ -10,13 +10,17 @@ language: python # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 ignore_all_files_in_gitignore: true - # list of additional paths to ignore # same syntax as gitignore, so you can use * and ** # Was previously called `ignored_dirs`, please update your config if you are using that. # Added (renamed)on 2025-04-07 ignored_paths: [] +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # Below is the complete list of tools for convenience. diff --git a/src/serena/agent.py b/src/serena/agent.py index 7d3b1a6..207f4c7 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -31,6 +31,7 @@ from serena.text_utils import search_files from serena.util.class_decorators import singleton from serena.util.file_system import scan_directory from serena.util.inspection import iter_subclasses +from serena.editingtool import EditingTool from serena.util.shell import execute_shell_command if TYPE_CHECKING: @@ -79,6 +80,7 @@ class ProjectConfig(ToStringMixin): self.project_root: str = str(project_root.resolve()) self.ignored_paths: list[str] = config_dict.get("ignored_paths", []) self.excluded_tools: set[str] = set(config_dict.get("excluded_tools", [])) + self.read_only: bool = config_dict.get("read_only", False) if "ignore_all_files_in_gitignore" not in config_dict: raise SerenaConfigError( @@ -115,6 +117,7 @@ class ProjectConfig(ToStringMixin): return os.path.join(self.project_root, self.SERENA_MANAGED_DIR) + @singleton class SerenaConfig: """ @@ -260,11 +263,13 @@ class SerenaAgent: """ if self.serena_config.enable_project_activation: # With project activation, we must expose all tools and handle tool activation within Serena - # (because clients to not react to changed tools) + # (because clients do not react to changed tools) return list(self._all_tools.values()) else: + # When project activation is not enabled, we only expose the active tools return list(self._active_tools.values()) + def activate_project(self, project_config: ProjectConfig) -> None: log.info(f"Activating {project_config}") self.project_config = project_config @@ -277,6 +282,13 @@ class SerenaAgent: log.info(f"Active tools after exclusions ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") else: self._active_tools = dict(self._all_tools) + + # if read_only mode is enabled, exclude all editing tools + if self.project_config.read_only: + self._active_tools = { + key: tool for key, tool in self._active_tools.items() if not key.can_edit() + } + log.info(f"Project is in read-only mode. Editing tools excluded. Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") # start the language server self.reset_language_server() @@ -290,6 +302,7 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() + def get_active_tool_names(self) -> list[str]: """ :return: the list of names of the active tools for the current project @@ -448,6 +461,15 @@ class Tool(Component): if apply_fn is None: raise RuntimeError(f"apply not defined in {self}. Did you forget to implement it?") return apply_fn + + @classmethod + def can_edit(cls) -> bool: + """ + Returns whether this tool can perform editing operations on code. + + :return: True if the tool can edit code, False otherwise + """ + return issubclass(cls, EditingTool) @classmethod def get_tool_description(cls) -> str: @@ -511,6 +533,17 @@ class Tool(Component): f"Error: Tool '{self.get_name()}' is disabled for the active project ('{self.project_config.project_name}'); " f"active tools: {self.agent.get_active_tool_names()}" ) + + # check if the project is in read-only mode and this is an editing tool + if ( + self.agent.project_config is not None + and self.agent.project_config.read_only + and self.__class__.can_edit() + ): + return ( + f"Error: Tool '{self.get_name()}' cannot be used because the project '{self.project_config.project_name}' " + f"is in read-only mode. Editing operations are not allowed." + ) # apply the actual tool result = apply_fn(**kwargs) @@ -528,6 +561,7 @@ class Tool(Component): return result + class ToolMarkerDoesNotRequireActiveProject: pass @@ -579,7 +613,7 @@ class ReadFileTool(Tool): return self._limit_length(result, max_answer_chars) -class CreateTextFileTool(Tool): +class CreateTextFileTool(Tool, EditingTool): """ Creates/overwrites a file in the project directory. """ @@ -831,7 +865,7 @@ class FindReferencingCodeSnippetsTool(Tool): return self._limit_length(result_json_str, max_answer_chars) -class ReplaceSymbolBodyTool(Tool): +class ReplaceSymbolBodyTool(Tool, EditingTool): """ Replaces the full definition of a symbol. """ @@ -860,7 +894,7 @@ class ReplaceSymbolBodyTool(Tool): return SUCCESS_RESULT -class InsertAfterSymbolTool(Tool): +class InsertAfterSymbolTool(Tool, EditingTool): """ Inserts content after the end of the definition of a given symbol. """ @@ -889,7 +923,7 @@ class InsertAfterSymbolTool(Tool): return SUCCESS_RESULT -class InsertBeforeSymbolTool(Tool): +class InsertBeforeSymbolTool(Tool, EditingTool): """ Inserts content before the beginning of the definition of a given symbol. """ @@ -918,7 +952,7 @@ class InsertBeforeSymbolTool(Tool): return SUCCESS_RESULT -class DeleteLinesTool(Tool): +class DeleteLinesTool(Tool, EditingTool): """ Deletes a range of lines within a file. """ @@ -945,7 +979,7 @@ class DeleteLinesTool(Tool): return SUCCESS_RESULT -class ReplaceLinesTool(Tool): +class ReplaceLinesTool(Tool, EditingTool): """ Replaces a range of lines within a file with new content. """ @@ -976,7 +1010,7 @@ class ReplaceLinesTool(Tool): return SUCCESS_RESULT -class InsertAtLineTool(Tool): +class InsertAtLineTool(Tool, EditingTool): """ Inserts content at a given line in a file. """ @@ -1248,7 +1282,7 @@ class SearchForPatternTool(Tool): return self._limit_length(result, max_answer_chars) -class ExecuteShellCommandTool(Tool): +class ExecuteShellCommandTool(Tool, EditingTool): """ Executes a shell command. """ diff --git a/src/serena/editingtool.py b/src/serena/editingtool.py new file mode 100644 index 0000000..341bf1c --- /dev/null +++ b/src/serena/editingtool.py @@ -0,0 +1,20 @@ +from typing import Type + + +class EditingTool: + """ + A marker class for all tools that perform editing operations on code. + This class is used to identify tools that modify code in the project. + Tools that inherit from this class will be excluded when the project + is in read-only mode. + """ + + @classmethod + def can_edit(cls) -> bool: + """ + Returns True as editing tools are designed to modify code. + This method can be overridden by subclasses if needed. + + :return: True if the tool can edit code, False otherwise + """ + return True diff --git a/test/test_edit_marker.py b/test/test_edit_marker.py new file mode 100644 index 0000000..6e25903 --- /dev/null +++ b/test/test_edit_marker.py @@ -0,0 +1,44 @@ +import pytest +from serena.agent import Tool, CreateTextFileTool, ReadFileTool, ProjectConfig +from serena.editingtool import EditingTool + +class TestEditMarker: + def test_edit_marker_class(self): + """Test that EditingTool marker class exists""" + assert EditingTool.__doc__ is not None + assert hasattr(EditingTool, 'can_edit') + assert EditingTool.can_edit() is True + + def test_tool_can_edit_method(self): + """Test that Tool.can_edit() method works correctly""" + # Non-editing tool should return False + assert issubclass(ReadFileTool, Tool) + assert not issubclass(ReadFileTool, EditingTool) + assert not ReadFileTool.can_edit() + + # Editing tool should return True + assert issubclass(CreateTextFileTool, Tool) + assert issubclass(CreateTextFileTool, EditingTool) + assert CreateTextFileTool.can_edit() + + def test_project_config_read_only(self): + """Test that ProjectConfig has read_only property""" + config = ProjectConfig({ + "language": "python", + "project_root": "/tmp", + "ignore_all_files_in_gitignore": True, + "read_only": True + }, "test_project") + + assert hasattr(config, 'read_only') + assert config.read_only is True + + config = ProjectConfig({ + "language": "python", + "project_root": "/tmp", + "ignore_all_files_in_gitignore": True, + # read_only not specified + }, "test_project") + + assert hasattr(config, 'read_only') + assert config.read_only is False # Default should be False