Add read-only mode with EditingTool marker class (entire change performed by Serena)

This commit is contained in:
Michael Panchenko
2025-04-18 20:33:46 +02:00
parent 2d4081ee39
commit 468beb8fb1
6 changed files with 163 additions and 11 deletions
+5 -1
View File
@@ -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.
+46
View File
@@ -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.
+5 -1
View File
@@ -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.
+43 -9
View File
@@ -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.
"""
+20
View File
@@ -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
+44
View File
@@ -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