mirror of
https://github.com/tiennm99/serena.git
synced 2026-09-03 10:23:43 +00:00
Add read-only mode with EditingTool marker class (entire change performed by Serena)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user